diff --git a/docs/mobile-presence-lock.md b/docs/mobile-presence-lock.md index 3aaf8f7fe04..12584a51dab 100644 --- a/docs/mobile-presence-lock.md +++ b/docs/mobile-presence-lock.md @@ -105,14 +105,14 @@ Invariants: | Current driver | Trigger | Next driver | Side effect | |---|---|---|---| -| `idle` | mobile subscribes with `displayMode='auto'\|'phone'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims | +| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims | | `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims | | `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — | | `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims | | `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — | | `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") | | `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts | -| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto/phone | +| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto | | `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) | | `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) | | `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims | @@ -174,7 +174,7 @@ connected; mobile sees the desktop-sized terminal because PTY snapped back **Mobile types something while you're reclaimed.** Banner reappears. Your next keystroke is blocked. PTY may snap back to phone dims (if mobile is in -auto/phone mode). +auto mode). **Mobile disconnects.** Banner gone permanently. Driver returns to `idle`. @@ -207,7 +207,7 @@ This is asymmetric and we are accepting it deliberately: - **Mobile reclaim is naturally signaled.** When a mobile user types while desktop drives, the runtime flips the driver to `mobile{thatClient}`, the desktop banner remounts, and (if mobile is in - auto/phone mode) the PTY snaps back to phone dims. The mobile user sees + auto mode) the PTY snaps back to phone dims. The mobile user sees the pane reflow and their keystrokes appear in the output stream. There is no silent black-hole condition on mobile that a banner would resolve. @@ -255,7 +255,7 @@ listener. The `TerminalPane` banner mounts when ### Why not reuse `getFitOverrideForPty`? The fit override only fires when the PTY was actually resized -(`mode='auto'|'phone'` and `wasResizedToPhone=true`). It misses the +(`mode='auto'` and `wasResizedToPhone=true`). It misses the desktop-mode case where mobile is subscribed but no resize happened. The driver state is broader than the fit override. diff --git a/docs/mobile-terminal-layout-state-machine.md b/docs/mobile-terminal-layout-state-machine.md new file mode 100644 index 00000000000..5537321db38 --- /dev/null +++ b/docs/mobile-terminal-layout-state-machine.md @@ -0,0 +1,1433 @@ +# Mobile Terminal Layout State Machine + +Design doc for replacing the four-way split state (`mobileSubscribers[*].wasResizedToPhone`, +`mobileDisplayModes`, `terminalFitOverrides`, `currentDriver`, plus the implicit "actual PTY dims" +in `getTerminalSize`) with a single per-PTY layout state machine and a monotonic sequence-numbered +wire event so the desktop runtime, the mobile xterm, and the desktop renderer can never disagree +about who owns the PTY or what dims it should be at. + +## Problem + +Today the runtime carries five overlapping pieces of state per PTY that together describe "what +should the PTY look like right now": + +- `mobileSubscribers[ptyId][clientId]` — a per-client record that includes `wasResizedToPhone`, + `viewport`, `previousCols/Rows`, `subscribedAt`, `lastActedAt`. +- `mobileDisplayModes[ptyId]` — the user-selected display mode (`auto` | `phone` | `desktop`). +- `terminalFitOverrides[ptyId]` — a desktop-renderer-facing flag that suppresses safeFit while + mobile is at phone dims. +- `currentDriver[ptyId]` — `idle` | `desktop` | `mobile{clientId}` from the presence-lock work. +- The actual PTY size (`ptySizes[ptyId]` / `getTerminalSize`) — what node-pty was last told via + `provider.resize(...)`. + +Plus several debounced or timed side channels that mutate the above asynchronously: + +- `pendingRestoreTimers` (300 ms debounce on last-subscriber-leaves). +- `pendingSoftLeavers` (250 ms grace before driver flips to idle). +- `resizeSuppressedUntil` (500 ms global window after a desktop-fit transition). +- The desktop renderer's safeFit cascade (IPC → React re-render → rAF → DOM measure → IPC back). +- The mobile xterm's `applyFitScale` frame-retry loop. + +Each piece is individually defensible, but together they admit at least seven race conditions +(documented in the investigation notes that triggered this doc) where two pieces of state disagree +and the mobile user sees: + +- "Claim worked but the mobile terminal is still zoomed out" — `wasResizedToPhone === true` while + the PTY is actually at desktop dims (or vice-versa). +- "Release didn't restore the desktop" — `applyMobileDisplayMode` for `auto`/`phone` early-returns + when `wasResizedToPhone === true` even though the PTY drifted. +- "Pressing the resize button twice fixes it" — the first toggle hits the early-return; the second + toggle (now with `wasResizedToPhone === false`) actually resizes. +- "Switching tabs back and forth on the phone fixes it" — a fresh `terminal.subscribe` rebuilds + the whole subscriber record from scratch, papering over the desync. + +The structural cause is that **no single function decides "what should the PTY be at right now," +diffs against current state, and emits exactly one resize plus one wire event**. Every callsite +mutates a subset of the five pieces and hopes the other four catch up. + +## Goal + +Per PTY, exactly one **target layout state** at any moment: + +```ts +type PtyLayoutTarget = + | { kind: 'desktop'; cols: number; rows: number } + | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } +``` + +`desktop` means "the PTY runs at the desktop renderer's pane geometry; mobile (if present) +watches passively." `phone` means "the PTY runs at `ownerClientId`'s viewport; the desktop +renderer's auto-fit is suppressed." + +Plus an authoritative current state with a monotonic sequence number per PTY: + +```ts +type PtyLayoutState = PtyLayoutTarget & { + // Monotonically increasing per ptyId. Bumped on every successful transition. + // Wire events (`resized`, `scrollback`) carry this number so mobile clients + // can drop stale events that arrive after a newer transition. + seq: number + // Wallclock of last successful transition, for debug/telemetry only. + appliedAt: number +} +``` + +All transitions go through a single function `applyLayout(ptyId, target)` that: + +1. Computes the diff from current state. +2. Calls `ptyController.resize` at most once. +3. Updates all derived state atomically (no other writer touches these maps). +4. Bumps `seq`. +5. Emits exactly one `resized` event on the subscribe stream. +6. Returns the new state synchronously. + +Driver state and presence-lock semantics ride on top of this layer unchanged — the mobile-presence +lock spec (`docs/mobile-presence-lock.md`) is **not modified**. Driver flips remain a separate +concern from layout transitions; they happen to share triggers (e.g. `terminal.send` from a mobile +client takes the floor AND can trigger a layout transition) but are distinct outputs. + +## Non-goals + +- Changing the wire protocol's `terminal.subscribe` / `terminal.send` / `terminal.setDisplayMode` + / `terminal.updateViewport` shapes beyond adding a `seq` field to the streamed events. Existing + client builds continue to work; they just lose stale-event filtering. +- Adding a new banner UX or rethinking the take-back flow. +- Touching SSH or local PTY provider code paths. +- Changing the presence-lock model. + +## Architecture + +The state machine sits inside the Electron main process and is the single point through which +all PTY layout transitions flow. The surrounding actors — desktop renderer, mobile WebView, RPC +methods, daemon-side ptyController — interact with it through narrow, well-defined edges. + +``` +┌─────────────────────────────┐ ┌────────────────────────────┐ +│ Desktop renderer │ │ Mobile WebView (RN) │ +│ (src/renderer/...) │ │ (mobile/app, mobile/src) │ +│ │ │ │ +│ • mobile-fit-overrides.ts │ │ • session/[worktreeId].tsx │ +│ • pane safeFit cascade │ │ • TerminalWebView │ +│ • Take Back / banner UX │ │ • layoutSeqRef stale-drop │ +└─────┬─────────────────▲─────┘ └─────┬──────────────────▲───┘ + │ pty:resize IPC │ terminalFitOverrideChanged │ terminal.* RPC │ subscribe stream + │ (renderer→main) │ (main→renderer) │ (mobile→main) │ (main→mobile) + │ │ │ │ scrollback / data + │ │ │ │ resized {seq} + ▼ │ ▼ │ +┌────────────────────────────────────────────────────────────────────────────┐ +│ Electron main process │ +│ │ +│ src/main/runtime/rpc/methods/terminal.ts │ +│ subscribe / send / setDisplayMode / updateViewport / unsubscribe │ +│ │ │ +│ ▼ │ +│ OrcaRuntime (src/main/runtime/orca-runtime.ts) │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ Public triggers: │ │ +│ │ onMobileSubscribe / onMobileTookFloor / onMobileViewportChanged │ │ +│ │ onMobileSetDisplayMode / onDesktopTakeBack / │ │ +│ │ onLastMobileLeaves / onDesktopGeometryReported / onPtyExit │ │ +│ │ │ │ +│ │ each trigger ── compute target ──▶ enqueue applyLayout(ptyId) │ │ +│ │ │ │ +│ │ Per-PTY async queue (layoutQueues): │ │ +│ │ serializes applyLayout calls behind ptyController.resize await │ │ +│ │ │ │ +│ │ applyLayout(ptyId, target): │ │ +│ │ • diff currentSize vs target │ │ +│ │ • await ptyController.resize (single point of mutation) │ │ +│ │ • bump layouts[ptyId].seq │ │ +│ │ • emit terminalFitOverrideChanged (renderer) │ │ +│ │ • emit resized {seq} (mobile subscribe stream) │ │ +│ │ │ │ +│ │ Owned state (only applyLayout writes): │ │ +│ │ layouts, restoreBaseline*, terminalFitOverrides, ptySizes** │ │ +│ │ *per subscriber, see "Restore baseline" │ │ +│ │ **via ptyController.resize │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ptyController (daemon adapter — local node-pty or SSH) │ +│ • resize(ptyId, cols, rows): Promise │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +Four data-flow paths cross this boundary: + +1. **Mobile-driven layout transition.** mobile WebView → `terminal.send` / + `terminal.setDisplayMode` / `terminal.updateViewport` → RPC handler → trigger method → + layoutQueue → applyLayout → ptyController.resize → seq bump → `resized {seq}` back to + mobile + `terminalFitOverrideChanged` to renderer. The RPC response awaits the queue tail + so the response is at the correct seq. +2. **Desktop-driven take-back.** renderer "Take Back" button → IPC → `onDesktopTakeBack` → + layoutQueue → applyLayout → resize to `restoreBaseline` → events to both sides. +3. **Renderer geometry report (passive, layout=desktop).** renderer pane safeFit → `pty:resize` + IPC → `onExternalPtyResize` (gated by `isResizeSuppressed`) → updates `restoreBaseline` + only, no resize emitted (PTY is already at the reported size; the renderer is reporting, + not requesting). If layout is `phone`, the report is recorded for future restore but does + not call applyLayout. +4. **Disconnect / PTY exit cleanup.** WS close → `onClientDisconnected` → if last subscriber, + enqueue applyLayout to `desktop` (or schedule via `pendingRestoreTimers`). PTY exit → + `onPtyExit` clears all per-PTY state and short-circuits any pending queued work. + +## State machine + +### States + +Two stable states (`desktop`, `phone`) plus a one-frame transient (`transitioning`) used only as +an internal critical-section marker — never observable on the wire because `applyLayout` is +synchronous on the runtime thread. + +Initial state for a PTY when no mobile client has ever subscribed: `desktop` with cols/rows +captured from the spawn-time pty geometry. + +### Inputs (transition triggers) + +The runtime exposes one method per trigger. Each one synchronously computes the new target, +calls `applyLayout`, and returns. They do **not** directly mutate `mobileSubscribers`, +`mobileDisplayModes`, `terminalFitOverrides`, or `ptySizes` — only `applyLayout` does. + +| Trigger | Method | Effect on target | +|---|---|---| +| Mobile fresh subscribe with `displayMode='auto'` and viewport | `onMobileSubscribe(ptyId, clientId, viewport, mode)` | target = `phone(viewport, clientId)` | +| Mobile fresh subscribe with `displayMode='desktop'` | `onMobileSubscribe(ptyId, clientId, viewport, 'desktop')` | target unchanged (passive watch) | +| Mobile sends keystroke (`terminal.send` with `client.type='mobile'`) | `onMobileTookFloor(ptyId, clientId)` | if currently `desktop`, target = `phone(thatClient's viewport, thatClient)`. If already `phone` with a different owner, target = `phone(thatClient's viewport, thatClient)`. | +| Mobile updates viewport (`terminal.updateViewport`) | `onMobileViewportChanged(ptyId, clientId, viewport)` | if `phone{clientId}`, target = `phone(viewport, clientId)`. Otherwise no-op (record viewport for next take-floor). | +| Mobile sets display mode | `onMobileSetDisplayMode(ptyId, clientId, mode)` | `auto` ⇒ target = `phone(viewport, clientId)`. `desktop` ⇒ target = `desktop(restoreCols, restoreRows)`. | +| Desktop "Take Back" | `onDesktopTakeBack(ptyId)` | target = `desktop(restoreCols, restoreRows)`. | +| Last mobile subscriber leaves | `onLastMobileLeaves(ptyId)` | After 300 ms debounce, if no peer rejoined: target = `desktop(restoreCols, restoreRows)`. | +| Desktop renderer reports its actual pane geometry | `onDesktopGeometryReported(ptyId, cols, rows)` | If currently `desktop`, target = `desktop(cols, rows)`. If `phone`, just record for future restore. | +| PTY exits | `onPtyExit(ptyId)` | clear all per-PTY state. | + +`restoreCols/Rows` is computed from the per-PTY **restore baseline** (see "Restore baseline" +below). `viewport` for `phone` targets is sourced from the most-recent-actor's stored viewport. + +#### onMobileViewportChanged pseudocode + +The keyboard show/hide path is the highest-frequency trigger and the one most likely to +regress under the rewrite — today's `updateMobileViewport` is ~70 lines and mutates five maps. +After: + +```ts +async onMobileViewportChanged( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } +): Promise { + const inner = this.mobileSubscribers.get(ptyId) + const sub = inner?.get(clientId) + if (!sub) return + + // Always record the latest viewport, even if this client isn't the + // current owner. Future take-floor by this client uses this value. + sub.viewport = viewport + sub.lastActedAt = Date.now() + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') return // passive watch; no resize + + // Pick whose viewport drives the layout. Today: most-recent-actor across + // all subscribers. The actor may not be `clientId` (another phone could + // have taken the floor between this update arriving and being processed). + const driver = this.pickMostRecentActor(ptyId) + if (!driver) return + + const target: PtyLayoutTarget = { + kind: 'phone', + cols: driver.viewport.cols, + rows: driver.viewport.rows, + ownerClientId: driver.clientId, + } + await this.enqueueLayout(ptyId, target) +} +``` + +If the picked driver's viewport equals the current layout dims (common: keyboard didn't +actually change height), `applyLayout` short-circuits — `dimsChanged === false`, no resize +call, but seq still bumps and the wire event is emitted (so the mobile xterm re-fits +defensively). The owner-clientId mismatch with the keyboard-event source is intentional: +viewport updates are informational for non-owners and only steer the PTY when the sender is +also the most-recent-actor. + +#### onClientDisconnected pseudocode + +The disconnect path is the second-trickiest trigger after `onMobileViewportChanged`: it has +four distinct sub-cases (cancel pending timers, promote any soft-leave grace, restore +last-leaver PTYs, re-elect driver where peers survived). Today's implementation (~lines +1431–1545 in `orca-runtime.ts`) directly mutates `ptyController`, `terminalFitOverrides`, +and emits events from each sub-case independently, plus a fifth legacy +`terminalFitOverrides` cleanup branch — the exact multi-writer pattern this design is +replacing. After the rewrite, every PTY-mutating sub-case routes through `enqueueLayout`; +the disconnect handler itself only does subscriber-map bookkeeping and driver flips, and +the legacy fifth branch becomes dead code. + +```ts +async onClientDisconnected(clientId: string): Promise { + // (1) Cancel pending restore-debounce timers owned by this client. + // The client is gone; the debounce target (300 ms last-leaver restore) + // is meaningless and would race with the immediate path below. + for (const [ptyId, entry] of this.pendingRestoreTimers) { + if (entry.clientId === clientId) { + clearTimeout(entry.timer) + this.pendingRestoreTimers.delete(ptyId) + } + } + + // (2) Promote any soft-leave grace owned by this client into immediate + // finalization. Grace existed to absorb a quick re-subscribe; a real + // disconnect kills any chance of re-subscribe, so we run the + // grace-fire path now rather than after 250 ms. + for (const [ptyId, soft] of this.pendingSoftLeavers) { + if (soft.clientId !== clientId) continue + clearTimeout(soft.timer) + this.pendingSoftLeavers.delete(ptyId) + + // Cancel any 300 ms restore timer too — we'll do it inline. + const pending = this.pendingRestoreTimers.get(ptyId) + if (pending) { + clearTimeout(pending.timer) + this.pendingRestoreTimers.delete(ptyId) + } + + // The grace held the inner-map empty already, so the layout + // transition target is unconditionally `desktop` if the layout + // is currently `phone`. (If layout is already `desktop`, the + // call is a no-op via applyLayout's idempotency.) + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + const restore = this.resolveDesktopRestoreTarget(ptyId) + // Ignore the result — the client is gone, no one is awaiting + // an RPC response. A pty-exited or resize-failed outcome is + // logged inside applyLayout and self-recovers on the next + // user gesture. + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows, + }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (3) Immediate restore for PTYs where this client was the last + // mobile subscriber. With multi-mobile, peer subscribers keep the + // floor; only when the inner map empties do we transition to + // desktop. We split into two passes (collect first, mutate second) + // so the iteration is stable across the inner-map deletes. + const ptysWithSurvivingPeers: string[] = [] + const ptysToRestore: string[] = [] + for (const [ptyId, inner] of this.mobileSubscribers) { + if (!inner.has(clientId)) continue + inner.delete(clientId) + if (inner.size > 0) { + ptysWithSurvivingPeers.push(ptyId) + } else { + this.mobileSubscribers.delete(ptyId) + ptysToRestore.push(ptyId) + } + } + for (const ptyId of ptysToRestore) { + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + const restore = this.resolveDesktopRestoreTarget(ptyId) + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows, + }) + } + this.setDriver(ptyId, { kind: 'idle' }) + } + + // (4) Driver re-election where peers survived. If the disconnecting + // client was the active driver, the most-recent surviving actor + // takes the floor. The new driver may have a different viewport, + // so we enqueue a phone target with their dims — applyLayout + // short-circuits on dimsChanged===false if the viewport matches. + for (const ptyId of ptysWithSurvivingPeers) { + const driver = this.getDriver(ptyId) + if (driver.kind !== 'mobile' || driver.clientId !== clientId) continue + const next = this.pickMostRecentActor(ptyId) + if (!next) continue + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') continue // passive watch — no resize + void this.enqueueLayout(ptyId, { + kind: 'phone', + cols: next.viewport.cols, + rows: next.viewport.rows, + ownerClientId: next.clientId, + }) + } + + // Sub-case (5) — legacy resizeForClient callers. Older mobile builds use + // the `terminal.resizeForClient` RPC directly and never populate + // `mobileSubscribers`. They DO get an entry in `terminalFitOverrides` + // (applyLayout writes it on every kind:'phone' transition) carrying the + // owning clientId. We walk the overrides map for entries owned by the + // disconnecting client where mobileSubscribers has no matching entry, and + // restore the layout. Without this branch, an old phone disconnecting + // would leave the PTY phone-fitted forever. + // + // Note: this also acts as a defense-in-depth assertion for the invariant + // "applyLayout is the sole writer of terminalFitOverrides ⟺ a matching + // mobileSubscribers entry exists". A violation would surface as a + // restore that didn't fire (rather than silent stale state). + for (const [ptyId, override] of terminalFitOverrides) { + if (override.clientId !== clientId) continue + if (mobileSubscribers.has(ptyId)) continue + const cur = layouts.get(ptyId) + if (cur?.kind !== 'phone') continue + const fallback = resolveDesktopRestoreTarget(ptyId) + enqueueLayout(ptyId, { + kind: 'desktop', + cols: override.previousCols ?? fallback.cols, + rows: override.previousRows ?? fallback.rows, + }) + } +} +``` + +Notes on the rewrite: + +- **`resolveDesktopRestoreTarget(ptyId)`** is the four-step fallback chain defined in the + Helpers section above. Always returns `{cols, rows}`; the chain's terminal branch + (`{80,24}`) guarantees a value, so the disconnect handler never has to defend against + `null`. +- **`void this.enqueueLayout(...)`** — disconnect callers do not await. The disconnect + itself is fire-and-forget (the WS is gone), and awaiting would block the disconnect + handler on a queued resize that might be slow over SSH. Failures inside `applyLayout` + log + self-heal on the next gesture; nothing observable ever depends on a disconnect- + triggered enqueue resolving. +- **Driver flips happen synchronously**, before `enqueueLayout`. Driver state is + decoupled from layout state (per the architecture diagram); `setDriver` writes to + `currentDriver` directly and does not depend on the layout queue. +- **Sub-case ordering is for clarity, not concurrency.** The handler body has zero + `await` statements before its sub-cases run, so the entire body executes in a single + synchronous turn — no setTimeout callback can interleave mid-iteration. Sub-case (1) + cancels deferred work first, then (2)/(3) drive new work, then (4) re-elects, in + reading order. Reordering the sub-cases would not introduce a race; it would just + hurt readability. +- **Why `cur?.kind === 'phone'` guards before enqueue**: the disconnect handler may run + for a client that was never the active phone-fit owner (e.g. it subscribed with + `mode='desktop'` and only watched passively). Skipping the enqueue when the layout + is already `desktop` avoids spurious seq bumps and wire events for non-owners. +- **Sub-case (2) is mode-decoupled.** The pendingSoftLeavers promotion + restores any PTY whose layout is `phone` and whose last mobile + subscriber just disconnected, regardless of stored display mode. + Originally relevant when the wire enum included a sticky-phone mode; now + that 'phone' has been collapsed out of the request schema, the simpler + rule ("no subscriber ⇒ layout converges to desktop") is the only rule + that fires. + +### Helpers + +The pseudocode below references four helper methods on the runtime. Each is private and pure +with respect to the runtime's owned maps (no side effects beyond reads). + +`isFreshSubscribe(ptyId: string): boolean` — returns `true` iff `handleMobileSubscribe` is +currently mid-flight for `ptyId` and has flagged this PTY as expecting its very first +`enqueueLayout` call. It exists solely so `enqueueLayout`'s "no layouts entry" short-circuit +doesn't fire on the very first transition for a PTY, where the entry doesn't exist yet +*because* we're about to create it. Storage: + +```ts +private freshSubscribeGuard = new Set() +``` + +`handleMobileSubscribe` adds `ptyId` to the set synchronously (before the `enqueueLayout` +call), and removes it in a `finally` block after the `await enqueueLayout(...)` resolves: + +```ts +this.freshSubscribeGuard.add(ptyId) +try { + result = await this.enqueueLayout(ptyId, target) +} finally { + this.freshSubscribeGuard.delete(ptyId) +} +``` + +`isFreshSubscribe(ptyId)` is the one-line `return this.freshSubscribeGuard.has(ptyId)`. + +`pickEarliestRestoreBaseline(ptyId: string): { cols: number; rows: number } | null` — iterates +`this.mobileSubscribers.get(ptyId)` and returns the `{ cols, rows }` pair from the subscriber +with the smallest `subscribedAt` whose `restoreBaselineCols` and `restoreBaselineRows` are both +non-null. Returns the cols and rows together so callers cannot accidentally combine cols from +one subscriber's baseline with rows from another's. Returns `null` if no subscriber has a +captured baseline; callers fall back to the chain documented in "Restore baseline." + +`pickMostRecentActor(ptyId: string): Subscriber | null` — iterates the inner map for `ptyId` +and returns the subscriber with the largest `lastActedAt`, or `null` if the map is empty or +absent. Used by `onMobileViewportChanged` and `onMobileTookFloor` to decide whose viewport +drives the next phone target. + +`resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number }` — the four-step +fallback chain spec'd in "Restore baseline," wrapped as a single helper so every callsite +that builds a `desktop` target uses the same logic and cannot drift from it: + +1. `pickEarliestRestoreBaseline(ptyId)` — earliest-by-`subscribedAt` non-null subscriber baseline. +2. `lastRendererSizes.get(ptyId)` — most-recent desktop renderer geometry report. +3. `getTerminalSize(ptyId)` — current PTY dims. +4. `{ cols: 80, rows: 24 }` — hard default; only reachable under bug. + +Returns `{cols, rows}` always; the terminal branch (4) guarantees a value. Callers +(`onMobileSetDisplayMode` desktop branch, `onDesktopTakeBack`, `onLastMobileLeaves`, +`onClientDisconnected`'s sub-cases 2/3/5, the soft-leave-promotion path) use this helper +exclusively; no callsite re-implements the chain inline. + +### applyLayout + +`ptyController.resize` is async (the daemon adapter awaits SSH/local round-trips). If two +trigger methods both call `applyLayout` and interleave around the await, the seq bumped by the +later call can be observed on the wire **before** the earlier call's resize has actually been +applied — defeating the seq-as-truth promise. To prevent that, every trigger method enqueues +its applyLayout work onto a per-PTY async queue. Inside the queue, applyLayout runs +fully serially: diff → await resize → bump seq → emit events. The seq the wire sees is the seq +that matches the PTY's actual current dims. + +```ts +private layouts = new Map() + +// Result type returned by both applyLayout and enqueueLayout. Callers +// (especially the RPC layer) need to distinguish "we shipped a new state +// at seq N" from "we did nothing — caller should not claim a seq it didn't +// produce." The reason field tells the caller whether to retry or give up. +type ApplyLayoutResult = + | { ok: true; state: PtyLayoutState } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } + +// Per-PTY queue slots. See "enqueueLayout coalescing" below for the +// 2-slot invariant on coalesce-eligible targets and the appended-task +// path used when a non-coalesce-eligible target arrives. +interface LayoutQueueEntry { + // The currently-running applyLayout, if any. + running: Promise | null + // FIFO of deferred targets. Coalesce-eligible viewport tail can + // collapse into a single slot (see coalescing rule below); a + // non-eligible target is appended as a new slot. Length is bounded + // by user-gesture frequency in practice. + pending: Array<{ + target: PtyLayoutTarget + // Resolvers from every caller whose enqueue resolved into this slot. + // When the slot runs, all of them resolve with the same result. + waiters: Array<(r: ApplyLayoutResult) => void> + }> +} +private layoutQueues = new Map() + +private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // PTY-exit short-circuit. The fresh-subscribe gate lets the very first + // transition through even though layouts has no entry yet. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return Promise.resolve({ ok: false, reason: 'pty-exited' }) + } + + let entry = this.layoutQueues.get(ptyId) + if (!entry) { + entry = { running: null, pending: [] } + this.layoutQueues.set(ptyId, entry) + } + + return new Promise((resolve) => { + if (!entry!.running) { + // Nothing running — start immediately. + entry!.running = this.runLayoutSlot(ptyId, target, [resolve]) + return + } + const tail = entry!.pending[entry!.pending.length - 1] + if (tail && this.coalescesWith(tail.target, target)) { + // Coalesce: replace the deferred tail's target with this newer one. + // The original caller's promise piggybacks onto whatever runs next, + // so they observe the eventually-applied state — not their target. + tail.target = target + tail.waiters.push(resolve) + return + } + // Non-coalesce-eligible (mode flip, take-floor, take-back, or a + // different-owner phone target): append as a new slot. + entry!.pending.push({ target, waiters: [resolve] }) + }) +} + +// Coalescing rule: a new viewport-only update from the same owner +// supersedes a queued tail with the same target shape. Anything else +// (mode flip, owner change, take-back) appends — losing a take-floor +// to a viewport tick would be a fairness hole. +private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { + if (prev.kind !== next.kind) return false + if (prev.kind === 'phone' && next.kind === 'phone') { + return prev.ownerClientId === next.ownerClientId + } + return true // both 'desktop': dim-only refresh is coalesce-eligible +} + +// Runs a single applyLayout, then promotes the next pending slot if any. +// Invariant: at most one runLayoutSlot is in flight per ptyId at any time. +// The pending FIFO is bounded in practice by user-gesture frequency +// (mode flips, owner changes); coalesce-eligible viewport storms collapse +// into a single tail slot, preserving the 2-slot bound for that case. +private async runLayoutSlot( + ptyId: string, + target: PtyLayoutTarget, + waiters: Array<(r: ApplyLayoutResult) => void>, +): Promise { + let result: ApplyLayoutResult + try { + result = await this.applyLayout(ptyId, target) + } catch (err) { + this.log?.error?.('applyLayout threw', { ptyId, err }) + result = { ok: false, reason: 'resize-failed' } + } + for (const w of waiters) w(result) + + const entry = this.layoutQueues.get(ptyId) + if (!entry) return result + const next = entry.pending.shift() + if (next) { + entry.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + } else { + entry.running = null + // Drop the entry once both running and pending are empty so the + // map doesn't grow without bound across short-lived PTYs. + this.layoutQueues.delete(ptyId) + } + return result +} + +private async applyLayout( + ptyId: string, + target: PtyLayoutTarget +): Promise { + const prev = this.layouts.get(ptyId) + const seq = (prev?.seq ?? 0) + 1 + const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } + + const currentSize = this.getTerminalSize(ptyId) + const dimsChanged = + currentSize?.cols !== target.cols || currentSize?.rows !== target.rows + const modeChanged = + (prev?.kind ?? 'desktop') !== target.kind + + // Snapshot the maps we are about to write so we can roll back on resize + // failure. layouts is captured by value above; for restoreBaseline / + // terminalFitOverrides we capture the previous entries (or "absent") + // before mutating so we can put them back exactly. + const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null + const prevLayoutEntry = prev ?? null + + // Tentative writes — the resize is the point of no return. + this.layouts.set(ptyId, next) + if (target.kind === 'phone') { + // The fit-override is a renderer-facing flag; the canonical baseline + // lives on each subscriber's restoreBaselineCols/Rows. Pull cols+rows + // atomically from the same subscriber so they can't desync. + const baseline = this.pickEarliestRestoreBaseline(ptyId) + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: target.cols, + rows: target.rows, + previousCols: baseline?.cols ?? null, + previousRows: baseline?.rows ?? null, + updatedAt: next.appliedAt, + clientId: target.ownerClientId, + }) + } else { + this.terminalFitOverrides.delete(ptyId) + } + + if (dimsChanged) { + let ok = false + try { + ok = (await this.ptyController?.resize?.(ptyId, target.cols, target.rows)) ?? true + } catch (err) { + ok = false + this.log?.error?.('ptyController.resize threw', { ptyId, err }) + } + if (!ok) { + // Roll back to the pre-call snapshot. The seq is NOT bumped on the + // wire because we never emit below. + if (prevLayoutEntry) { + this.layouts.set(ptyId, prevLayoutEntry) + } else { + this.layouts.delete(ptyId) + } + if (prevFitOverride) { + this.terminalFitOverrides.set(ptyId, prevFitOverride) + } else { + this.terminalFitOverrides.delete(ptyId) + } + return { ok: false, reason: 'resize-failed' } + } + this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) + } + + // Emit fit-override-changed only when the *mode* flips. Layouts can change + // dims without flipping mode (e.g. keyboard show/hide while phone), and we + // don't want every viewport tick to wake the renderer. + if (modeChanged) { + // Phone→desktop: arm the renderer-cascade suppress window before the + // collateral safeFit IPCs arrive. See "Renderer cascade suppression". + if (target.kind === 'desktop') this.suppressResizesForMs(500) + this.notifier?.terminalFitOverrideChanged( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows, + ) + } + + // Mobile-facing event always fires (phone clients need to re-fit on every + // dim change, not just mode flips). + this.notifyTerminalResize(ptyId, { + cols: target.cols, + rows: target.rows, + displayMode: target.kind === 'phone' ? 'phone' : 'desktop', + reason: 'apply-layout', + seq, + }) + + return { ok: true, state: next } +} +``` + +#### enqueueLayout coalescing + +`enqueueLayout` is **not** an unbounded FIFO for the high-frequency case. Android keyboard +show/hide can emit 10–15 viewport updates over ~250 ms, and each `applyLayout` awaits +`ptyController.resize` (~30–80 ms over SSH). A naive FIFO queues 300 ms–1.2 s of work that +the RPC response also waits on. Coalescing keeps tail latency bounded at ≤ 2× a single +applyLayout duration for that case. + +Coalescing is **selective**, gated by `coalescesWith(prev, next)`: + +- **Coalesce-eligible**: the new target's `kind` matches the queued tail's `kind`, and (for + `phone` targets) the `ownerClientId` matches. This is the actual high-frequency case: a + single owner's keyboard-show/hide viewport ticks, or repeated desktop dim refreshes from + the renderer cascade. +- **Not coalesce-eligible**: mode flips (`phone` ↔ `desktop`), take-floor / take-back, or a + phone target whose `ownerClientId` differs from the queued tail's owner. These are + semantic transitions, not noise — collapsing them would lose a user gesture (e.g. one + phone's Take Floor button could silently do nothing if a competing phone's update + superseded it). + +When `enqueueLayout(ptyId, target)` is called: + +1. **No task running.** Start `target` immediately as the running slot. +2. **One task running, pending FIFO empty.** Append `target` as the first deferred slot. +3. **One task running, pending FIFO has a tail.** + - If `coalescesWith(tail.target, target)` is `true`: **replace** the tail's target with + `target`. The superseded caller's promise piggybacks onto whatever runs next, resolving + with the result of the *replaced* task. Both callers' waiters are carried in the same + slot's `waiters` array and resolve with the same `ApplyLayoutResult`. + - Otherwise: **append** `target` as a new slot at the tail of the FIFO. The pending FIFO + grows beyond one slot in this case. + +The invariant is: **at most 2 task slots per ptyId for coalesce-eligible targets** (one +running plus one deferred tail that absorbs same-owner viewport storms). Appended tasks for +non-coalesce-eligible targets queue beyond that bound, but they are bounded in practice by +user-gesture frequency (mode-toggle taps, take-floor/take-back presses, multi-phone +hand-offs), not by keyboard animation rates — not a perf concern. + +The contract every RPC caller observes is: "your update was honored, or it was superseded by +a more-recent update from the same client with the same target shape." A caller whose +intermediate viewport got coalesced away sees, in their RPC response, the seq of whatever +final target was actually applied. This is sound for the high-frequency triggers — +`onMobileViewportChanged` (keyboard show/hide) and `onDesktopGeometryReported` (renderer +cascade) — because the only consumer who cares about the intermediate frames is the +keyboard animation, and the keyboard animation emits the *next* update before the previous +one would have rendered anyway. Take-floor and mode-flip callers, by contrast, see their +target run as a distinct slot with its own seq, even if a coalesce-eligible tail is queued +before them. + +Mode flips (`onMobileSetDisplayMode`, `onDesktopTakeBack`), take-floor, take-back, and +last-leaver are intrinsically rate-limited by user gesture or by the existing 250 ms / +300 ms debounces. The pending FIFO almost always has 0 or 1 slot in those cases; appended +slots beyond 1 only appear under contrived multi-phone rapid-gesture stress, and each one +runs in order. + +The rollback path on `ptyController.resize` failure runs inside the actually-running slot — +the failure rolls back that target's tentative writes and resolves all current waiters with +`{ ok: false, reason: 'resize-failed' }`. Any pending slots are unaffected and the next one +starts fresh on the following tick with its own snapshot of `prev`. + +Trigger methods become `async` and `await this.enqueueLayout(...)`. Mobile RPC handlers in +`src/main/runtime/rpc/methods/terminal.ts` (`terminal.send`, `terminal.setDisplayMode`, +`terminal.subscribe`, `terminal.updateViewport`) await the queue tail before sending the RPC +response so the response always reflects the seq that has actually shipped to the wire. +Concretely: + +```ts +async function terminalSetDisplayMode(req) { + const result = await runtime.onMobileSetDisplayMode(req.ptyId, req.clientId, req.mode) + if (!result.ok) { + // Propagate the discriminator so the mobile client can decide whether + // to retry, drop the subscription, or surface a toast. Reading + // runtime.layouts here would lie: rollback / pty-exited cases left + // the seq either at its old value or absent entirely. + return { ok: false, reason: result.reason } + } + // result.state.seq is exactly the seq emitted on the mobile subscribe + // stream's `resized` event for this transition; monotonicity is preserved. + return { ok: true, seq: result.state.seq } +} +``` + +The reason discriminator's client-side semantics are documented under "Mobile ← server (RPC +response shape)" in the Wire protocol changes section. + +On PTY exit, `onPtyExit` does `this.layouts.delete(ptyId); this.layoutQueues.delete(ptyId)`. +Any work already chained onto the (now-detached) tail will run, but every `applyLayout` checks +`this.layouts.has(ptyId)` (or that the call is a fresh subscribe creating the entry) and +short-circuits if the PTY is gone, so no resize is issued against a dead pty handle. + +### Restore baseline + +The restore baseline answers "if we transition to `desktop` right now, what dims should we +restore the PTY to?" It is **per-subscriber**, not per-PTY, on purpose: when a second mobile +client (e.g. an iPad) joins a PTY that's already phone-fitted, it must capture `null` (skip +capture) so the *first* subscriber's pre-mobile baseline survives. A per-PTY single-slot +baseline would let the iPad overwrite the phone's baseline with the already-shrunk phone dims, +and the next desktop transition would restore to the wrong size. + +Each `Subscriber` record carries: + +```ts +interface Subscriber { + clientId: string + viewport: { cols: number; rows: number } + subscribedAt: number + lastActedAt: number + // Pre-mobile-fit PTY dims captured at this subscriber's first auto/phone + // subscribe. null when this subscriber joined while another mobile client + // had already phone-fitted the PTY (no pristine baseline to capture). + // Renamed from previousCols / previousRows for clarity. + restoreBaselineCols: number | null + restoreBaselineRows: number | null +} +``` + +The desktop-restore target is picked through a four-step fallback chain. Every callsite that +needs to compute a `desktop` target (last-leaver debounce, take-back, mode-flip-to-desktop, +soft-leave promotion) walks the chain in this exact order, taking the first non-null result: + +1. **`pickEarliestRestoreBaseline(ptyId)`** — the earliest-by-`subscribedAt` subscriber with + a non-null `restoreBaselineCols/Rows` pair. The earliest non-null capture is the genuine + pre-mobile geometry; later joiners whose baseline is `null` are passed over. +2. **`lastRendererSizes.get(ptyId)`** — the most-recent desktop renderer report (set by + `onDesktopGeometryReported`). Covers the case where every subscriber's baseline is `null` + (e.g. mobile subscribed with `mode='desktop'` and never captured) but the renderer has + reported its actual pane geometry at least once. +3. **`getTerminalSize(ptyId)`** — the PTY's actual current dims. Always available while the + PTY is running; this is the size node-pty believes it has right now. Acceptable as a + restore target because if we're transitioning to `desktop`, "do nothing" (resize to current + dims) is a valid outcome — the renderer will fit-cascade to its real pane geometry on the + next safeFit and report the correct size via path (2) for future restores. +4. **`{ cols: 80, rows: 24 }`** — a hard default. Catastrophic; should never reach this branch + in practice (the PTY is running, so step 3 is always available). Defined explicitly so the + behavior under bug is "land on a sane default" rather than "throw or no-op silently." + +This chain is wrapped in the `resolveDesktopRestoreTarget(ptyId)` helper (defined in +"Helpers"); every callsite that needs a desktop-restore target calls that helper rather +than re-implementing the chain inline. + +Writes to subscriber baselines now flow through `applyLayout` indirectly: + +- A fresh mobile subscribe with `mode=auto/phone` captures the current PTY size into its own + subscriber record's `restoreBaselineCols/Rows` *before* enqueuing applyLayout. If another + subscriber on the same PTY already has a non-null baseline, the new subscriber's baseline + stays `null` (the existing baseline is the canonical one). +- `onDesktopGeometryReported` (today's `onExternalPtyResize`) while the layout is `desktop` + refreshes `restoreBaselineCols/Rows` on every subscriber whose existing baseline is + non-null — those baselines now point at the renderer-current dims, not the stale spawn-time + size. Subscribers with `null` baselines stay `null`. This is the **only** path that mutates + baselines after subscribe; applyLayout itself does not write subscriber baselines. + +The restore baseline is **not** touched by transient renderer cascades during the +resize-suppress window — see "Renderer cascade suppression" below. + +## Wire protocol changes + +Backward-compatible additive change to the streamed event payloads. + +### Server → mobile (subscribe stream) + +Today: +```ts +emit({ type: 'scrollback', lines, truncated, serialized, cols, rows, displayMode }) +emit({ type: 'data', chunk }) +emit({ type: 'resized', cols, rows, serialized, displayMode, reason }) +emit({ type: 'end' }) +``` + +After: +```ts +emit({ type: 'scrollback', lines, truncated, serialized, cols, rows, displayMode, seq }) +emit({ type: 'data', chunk }) +emit({ type: 'resized', cols, rows, serialized, displayMode, reason, seq }) +emit({ type: 'end' }) +``` + +`seq` is the value of `layouts[ptyId].seq` at the moment the event was emitted. Mobile clients +track the highest `seq` they have applied per PTY and **drop** any `scrollback`/`resized` event +whose `seq` is less than or equal to the highest applied. This is the entire stale-event filter. + +If the PTY has never transitioned (no prior `enqueueLayout` call), `seq` on the first +`scrollback` is `undefined` and the mobile filter's fail-open path applies — the event is +applied unconditionally. Subsequent `resized` events carry `seq` starting at 1 from the first +transition. A passive (`mode='desktop'`) subscribe to a PTY that has previously transitioned +receives the current `layouts[ptyId].seq` on its `scrollback`, treating it as the established +high-water mark; future `resized` events for that PTY use higher seqs and the passive +subscriber's filter behaves the same as an active one's. + +`data` events carry no `seq` because they are byte-level pass-through and ordering is preserved +by the underlying WS stream. + +### Server → renderer (existing IPC) + +`terminalFitOverrideChanged` is unchanged on the wire. Internally it now carries a derived +representation of the layout state, but the renderer-side fields stay identical so +`mobile-fit-overrides.ts` does not need to know about `seq`. + +**Emission gating: mode-flip only.** Today, the runtime emits `terminalFitOverrideChanged` on +several paths that don't actually flip the renderer-visible mode (e.g. dim-only updates while +already in mobile-fit). After the rewrite, the event fires **only when the layout's `kind` +flips between `phone` and `desktop`** (`mobile-fit` ↔ `desktop-fit`). Dim-only changes (a +keyboard show/hide tweaking phone rows, a phone-owner switch keeping mobile-fit but with a +different viewport) do not fire the event. The renderer's mobile-fit-overrides hydration only +cares about the boolean "is this PTY currently in mobile-fit mode," and re-renders triggered +by every keyboard tap are wasteful churn. Mobile clients still receive a `resized` event on +every dim change because the mobile xterm needs to re-fit on dim changes too; the asymmetry is +deliberate. + +`terminalDriverChanged` is unchanged. Driver state remains independent of layout state. + +### Mobile → server (request schemas) + +No changes. Existing `terminal.subscribe`, `terminal.send`, `terminal.setDisplayMode`, +`terminal.updateViewport`, `terminal.unsubscribe` shapes are preserved. The presence-lock +`client: { id, type }` field continues to do the same job. + +### Mobile ← server (RPC response shape) + +The RPC handlers that drive layout transitions (`terminal.setDisplayMode`, +`terminal.updateViewport`, the take-floor side of `terminal.send`) return a discriminated union +matching `ApplyLayoutResult`: + +```ts +type TerminalLayoutRpcResponse = + | { ok: true; seq: number } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } +``` + +This replaces today's "always return `{ ok: true, seq }` and pretend things worked" pattern, +which would lie when applyLayout rolled back or short-circuited because the PTY had exited. +Mobile-side RPC clients should treat the failure variants as follows: + +- `'pty-exited'` is a terminal-gone signal: drop the local subscription, optionally show a + toast ("session ended"), and stop sending RPCs against this `ptyId`. Retrying will not help. +- `'resize-failed'` is transient (SSH transport blip, node-pty hiccup): the runtime rolled + back; no `resized` event was emitted; the caller can retry the same RPC after a short + backoff. + +The response shape is additive: older mobile clients that only check `ok` and read `seq` +unconditionally will treat a failure response as `{ ok: false, seq: undefined }` and most +existing code paths already handle that gracefully (the seq filter is fail-open). + +`terminal.resizeForClient` returns this same `TerminalLayoutRpcResponse` discriminator. The +shim path described in the migration table computes a target, awaits `enqueueLayout`, and +returns the result verbatim. This is additive on the wire — older mobile builds that only +read `ok`/`seq` are unaffected by the new optional `reason` field, and older builds that +already retry on `ok: false` continue to work because `'resize-failed'` is retryable in their +model too (their existing retry path makes no claim about *why* the call failed). + +## Mobile client changes + +### Sequence-aware reinit guard + +`mobile/app/h/[hostId]/session/[worktreeId].tsx` tracks the highest `seq` applied per terminal +handle in a ref: + +```ts +const layoutSeqRef = useRef>(new Map()) +``` + +In the subscribe callback: + +```ts +if (data.type === 'scrollback' || data.type === 'resized') { + const seq = typeof data.seq === 'number' ? data.seq : null + if (seq != null) { + const last = layoutSeqRef.current.get(handle) ?? 0 + // Fresh-subscription detection: a scrollback whose seq is far below + // the stored seq means the server restarted the per-PTY counter (or + // we missed an unsubscribe locally). Treat as a fresh subscription + // and reset the gate. Threshold of 20 is generous — a real stale + // event from an in-flight transition is at most a handful of seqs + // behind the latest applied. + if (data.type === 'scrollback' && last - seq > 20) { + layoutSeqRef.current.delete(handle) + } else if (seq <= last) { + return // stale — a newer transition has already applied + } + layoutSeqRef.current.set(handle, seq) + } + // existing init / write / fit logic +} +``` + +Lifecycle: the seq gate is **per-mounted-handle** and must be cleared on every event that +discards the xterm-side state, otherwise a fresh PTY's first `scrollback` (seq=1) gets dropped +because the ref still holds a much higher seq from a previous handle. Specifically: + +1. **Unsubscribe.** The cleanup callback returned by `terminal.subscribe` does + `layoutSeqRef.current.delete(handle)` after closing the stream. +2. **WebView reload.** `handleLoadStart` (and the iOS-foreground-resume re-entry, which fires + after the WebView is paused while backgrounded) deletes the entry for every handle owned by + this WebView. The xterm instance inside the WebView is gone; the JS ref outside is stale. +3. **Fresh-subscription detection (the >20 case above).** A defensive fallback for cases where + 1 and 2 didn't run (e.g. server-side state was lost but our cleanup didn't fire — server + crash + reconnect). The first `scrollback` with seq much lower than the stored seq triggers + a reset and the event is applied. + +The fail-open behavior — `seq == null` ⇒ apply — is preserved across all three paths so older +servers that don't emit `seq` continue to work. + +Stale events from in-flight transitions during rapid mode toggles or tab switches are dropped +without action. This kills the "init at desktop dims after subscribe-with-phone-viewport already +applied" race entirely, while the lifecycle reset paths kill the inverse "fresh subscribe is +ignored as stale" failure mode. + +### Cold-load fit scale fix + +`TerminalWebView.applyFitScale` today commits when `scrollWidth` is stable across two frames. +On a cold load, it can commit during a transient layout where xterm reports a positive but +partial `scrollWidth`, and the `>= 0.95` snap rounds the result to 1.0 — visible to the user as +"the terminal didn't zoom in." + +After: + +1. Compute the **expected** `scrollWidth` from the renderer's reported cell width and the + current `term.cols`: `expected = cellWidth * cols`. If we have a renderer object with valid + dimensions, the expected width is known exactly. +2. Wait until `scrollWidth === expected` (within 1 px tolerance) **or** the retry budget + exhausts. The retry budget moves from 30 frames (~500 ms) to 60 frames (~1 s) since this is + the cold-load case where Android Samsung devices have been observed to take 600 ms+. +3. Drop the `>= 0.95` snap entirely. Sub-pixel scale (e.g. 0.97) is correct when the terminal + has a one-cell scrollbar gutter; snapping to 1.0 was a workaround for the broken commit + timing, not a real correctness rule. After step 1 the partial-layout case is closed and + `currentScale` is exact. + +```js +function applyFitScale() { + if (!term || !term.element) return + const token = ++fitRetryToken + let attempts = 0 + + function attempt() { + if (token !== fitRetryToken) return + if (!term || !term.element) return + + var cellWidth = 0 + var core = term._core + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width + } + var w = term.element.scrollWidth + var expected = cellWidth > 0 ? cellWidth * term.cols : 0 + + attempts++ + if (cellWidth > 0 && w > 0 && Math.abs(w - expected) <= 1) { + commitFitScale() + return + } + if (attempts >= 60) { + commitFitScale() + return + } + requestAnimationFrame(attempt) + } + requestAnimationFrame(attempt) +} + +function commitFitScale() { + if (!term || !term.element) return + currentScale = computeFitScale() // unchanged: min(1, vpWidth / scrollWidth) + // No more `>= 0.95` snap. + userScale = 1 + panX = 0; panY = 0 + updateTransform() +} +``` + +### Re-fit on first live data chunk + +Add a one-shot re-fit after the first `data` event following an `init`. Live PTY data can cause +xterm to relayout (font baseline shifts, cursor row changes), and the cold-load `applyFitScale` +may have committed before the canvas finished rendering. After: + +```js +function write(data) { + writeQueue.push(data) + pumpWrites(terminalGeneration) + if (firstDataPending) { + firstDataPending = false + afterWritesDrained(function () { applyFitScale() }) + } +} +``` + +`firstDataPending` is set to `true` inside `init` and `commitFitScale` does **not** clear it — +the reset live-data refit is independent of the eager init refit. Both fire; both are no-ops if +the scale is already correct. + +### Removal of post-init `setTimeout(resetZoom, 200)` workarounds + +`mobile/app/h/[hostId]/session/[worktreeId].tsx` has two `setTimeout(() => resetZoom(), 200)` +calls (after `scrollback` and after `resized`) that were workarounds for the cold-load race. Once +the WebView fix above lands, these are no-ops 99% of the time. Keep one 200 ms guard call after +`resized` only; the WebView's frame-retry handles the cold case directly. + +## Server-side migration + +Single PR, no feature flag. The change is internal — wire shapes are additive (`seq` is optional +on consumers). + +### Files touched + +1. `src/main/runtime/orca-runtime.ts`: + - Add `private layouts = new Map()`. + - Add `private layoutQueues = new Map()` (the per-PTY + coalescing slot pair; see "enqueueLayout coalescing"). + - Add `private applyLayout(ptyId, target): Promise`, + `private enqueueLayout(...)`, and `private runLayoutSlot(...)` (all private — only + callable from this file). + - **Every existing callsite that touches `terminalFitOverrides`, `ptySizes` (via + `ptyController.resize`), or emits `terminalFitOverrideChanged` / + `notifyFitOverrideListeners` / `notifyTerminalResize` is rewritten to compute a + `PtyLayoutTarget` and `await this.enqueueLayout(...)` instead.** Concretely the rewritten + callsites are: + + | Existing callsite (orca-runtime.ts) | Today's behavior | After | + |---|---|---| + | `handleMobileSubscribe` | Captures `previousCols/Rows`, sets `wasResizedToPhone`, calls `ptyController.resize`, sets `terminalFitOverrides`, emits `terminalFitOverrideChanged` + `notifyTerminalResize`. | Captures subscriber baseline, computes phone target, awaits enqueueLayout. | + | `handleMobileUnsubscribe` | Removes subscriber, may schedule `pendingRestoreTimers`. | Removes subscriber, schedules debounce; debounce callback enqueues a `desktop` target. | + | `applyMobileDisplayMode` | Branch on mode; mutates `wasResizedToPhone`, `terminalFitOverrides`, calls `ptyController.resize`, emits events. | Computes target from mode + most-recent-actor; awaits enqueueLayout. | + | `mobileTookFloor` (lines ~1628–1655) | Re-applies phone-fit by calling `applyMobileDisplayMode`; updates `lastActedAt`. | Updates `lastActedAt`; if currently `desktop` or owner mismatch, awaits enqueueLayout with phone target. Driver flip stays separate. | + | `updateMobileViewport` (lines ~1665–1735) | Mutates 5 maps. | Updates subscriber viewport; if layout is phone and this client is owner (or this client is most-recent-actor), awaits enqueueLayout with new viewport target. See pseudocode below. | + | `reclaimTerminalForDesktop` | Resets `wasResizedToPhone`, calls `ptyController.resize`, deletes `terminalFitOverrides`, emits events. | Computes desktop target from `resolveDesktopRestoreTarget(ptyId)`; awaits enqueueLayout. | + | `resizeForClient` (mobile RPC legacy path; lines ~1340–1410) | Sets `terminalFitOverrides` directly, calls `ptyController.resize`, emits events. The desktop-restore branch (lines ~1390–1400) calls `ptyController.resize` then sets the override flag. | Becomes a thin shim: compute target, enqueueLayout. The rollback-on-resize-failure logic moves into applyLayout. | + | `onClientDisconnected` (lines ~1431–1545 across 4 sub-cases) | Directly mutates `terminalFitOverrides`, calls `ptyController.resize`, emits events for each disconnecting subscriber and each soft-leave promotion. | Becomes subscriber-map bookkeeping + driver flips only; every PTY-mutating sub-case routes through `enqueueLayout`. See "onClientDisconnected pseudocode" above. | + | `pendingRestoreTimers` debounce callback (300 ms; lines ~2080–2100) | Calls `ptyController.resize`, deletes `terminalFitOverrides`, emits events. | Awaits enqueueLayout with `desktop` target. | + | `pendingSoftLeavers` callback | Same as above. | Same as above. | + | `onPtyExit` | Clears per-PTY maps. | Clears `layouts`, `layoutQueues`, plus the existing maps. Pending queued work checks `layouts.has(ptyId)` and short-circuits. | + | `onExternalPtyResize` (lines ~2186–2200) | Mutates `lastRendererSizes` and overwrites every non-phone-fitted subscriber's `previousCols/Rows`. | Renamed conceptually to `onDesktopGeometryReported`. Updates `lastRendererSizes`. Refreshes subscriber `restoreBaselineCols/Rows` only on subscribers whose existing baseline is non-null (matching today's gate but using the renamed fields). Does **not** call applyLayout — it's a passive geometry report. | + + Removes `wasResizedToPhone`, `previousCols`, `previousRows` from the `Subscriber` record; + replaces with `restoreBaselineCols`, `restoreBaselineRows` (rename + semantic preservation + per the Restore baseline section). + - `onClientDisconnected` and `onPtyExit` additionally clean up `layouts` and `layoutQueues`. + - `pendingRestoreTimers` and `pendingSoftLeavers` are kept (debounce + grace are a separate + concern from the state machine), but their fire callbacks now call `enqueueLayout` instead + of mutating fields directly. + +2. `src/main/runtime/rpc/methods/terminal.ts`: + - `terminal.subscribe` emits `seq` on `scrollback` and `resized` events. Source: read + `runtime.layouts.get(ptyId)?.seq` after `handleMobileSubscribe` returns (it bumps the seq). + - No new schemas; no new methods. + +3. `src/main/runtime/orca-runtime.test.ts`: + - Existing transition-table tests pass unchanged (they assert observable behavior — emitted + events, PTY size after transition — not internal flag state). + - New tests: + - `applyLayout` is idempotent for identical targets (no second resize, no second event). + - `applyLayout` bumps `seq` exactly once per call. + - Multi-mobile owner switch produces exactly one resize event with the new owner's + viewport. + - Take Back from `phone` to `desktop` produces exactly one resize event back to + `restoreBaseline`. + - Mode toggle phone→desktop→phone produces three resize events with monotonically + increasing `seq`, regardless of `wasResizedToPhone`-equivalent internal state — the + Bug 2 / Bug 5 / Bug 6 regressions from the investigation. + +4. `mobile/app/h/[hostId]/session/[worktreeId].tsx`: + - Add `layoutSeqRef` and the stale-event drop filter. + - Drop the `setTimeout(() => resetZoom(), 200)` after `scrollback` (no longer needed; the + WebView's own frame-retry handles cold-load). Keep the one after `resized` as a safety net. + +5. `mobile/src/terminal/TerminalWebView.tsx`: + - Replace `applyFitScale` per the spec above. + - Remove the `>= 0.95` snap in `commitFitScale`. + - Add the first-data-chunk re-fit hook in `write()`. + - No public-API changes; existing callers see the same `init`/`write`/`clear`/`resetZoom` + surface. + +### Renderer cascade suppression + +`resizeSuppressedUntil` is kept. The 500 ms global suppression after a desktop-fit transition +exists because the desktop renderer's collateral safeFit cascade (across all panes, not just the +affected one) can fire spurious `pty:resize` IPCs at the wrong dims. Under the new model: + +- `applyLayout` calls `suppressResizesForMs(500)` whenever it transitions **out of** `phone` to + `desktop` (the cascade trigger). +- `pty:resize` IPC handler still drops requests during the suppress window (unchanged). +- `onDesktopGeometryReported` ignores reports during the suppress window — they would + otherwise repollute `restoreBaseline` with the cascade's transient bad value. + +This is the only remaining "two writers can disagree" surface, and the suppression window is the +defense. After 500 ms the renderer's own measurement has converged on the correct pane geometry. + +### Soft-leave grace + +`pendingSoftLeavers` (250 ms) is kept. When the last mobile client unsubscribes, we don't want +the desktop banner to flash off-then-on if the same client immediately resubscribes (e.g. +keyboard show/hide on older mobile builds that don't use `terminal.updateViewport`). The grace +holds `currentDriver` at `mobile{clientId}` for 250 ms. + +The grace does **not** touch the layout state. The layout transitions to `desktop` only after +the 300 ms restore debounce fires AND the inner subscriber map is empty. Driver and layout are +decoupled. + +## Edge cases + +- **Mobile drops network mid-transition.** WS doesn't immediately know. The seq the mobile last + saw is preserved in its `layoutSeqRef`. On reconnect, `terminal.subscribe` emits a fresh + `scrollback` event with a higher seq — mobile applies it, updating the ref. No special + reconnect logic needed. +- **Two phones, different viewports, rapid take-floor ping-pong.** Each `mobileTookFloor` + computes a new target with the current actor's viewport and calls `enqueueLayout`. Because + consecutive take-floor targets carry different `ownerClientId`s, none of them coalesce — + each one is appended as its own slot in the pending FIFO. Every appended slot runs in + order, each transition bumps seq, each emits its own `resized` event. Both phones see all + transitions in order; their seq filter only drops events older than what they've already + applied. (Coalescing only collapses same-owner viewport ticks, so a Take Floor button can + never be silently dropped by a competing phone's viewport update.) +- **Take Back during the 300 ms restore debounce.** The debounced callback checks + `isMobileSubscriberActive(ptyId)` — if the desktop user took back manually (driver flipped to + `desktop`, banner unmounted, layout target = `desktop`) the debounce no-ops. If the debounce + fires first, `applyLayout` is called with `desktop` target; the manual Take Back arrives next + and computes the same target → idempotent no-op. +- **PTY exits during a transition.** `onPtyExit` clears `layouts` and `restoreBaseline`. Any + pending debounce callbacks check `layouts.has(ptyId)` and bail early. +- **Renderer cascade reports a wrong size during the suppress window.** `pty:resize` IPC is + dropped at the entry point. `onDesktopGeometryReported` checks `isResizeSuppressed()` and + bails. The suppress window fully isolates the cascade. +- **Renderer geometry report while layout is `phone`.** `onDesktopGeometryReported` records the + size but does not call `applyLayout` — the desktop panel is invisible to mobile while phone is + active, and `restoreBaseline` is already captured. The recorded size is used the **next** time + a transition to `desktop` runs, so the restore lands at the renderer's current pane geometry, + not the pre-mobile snapshot. (Pre-mobile capture is the fallback when no renderer report has + arrived yet.) +- **iOS WebView background→foreground pause/resume.** When the OS pauses the WebView, in-flight + layout transitions queue on the main thread; on resume the WebView replays them. The seq + filter would drop everything older than the highest seq it had applied before pausing, but + that's wrong if the foreground re-mount actually started a fresh xterm instance. The + WebView-reload reset path in the seq lifecycle (point 2 above) covers this: foreground resume + triggers `handleLoadStart`-equivalent which clears the per-handle seq, and the next + `scrollback` is applied even though its seq is lower than the pre-pause high-water mark. +- **Network drop with reconnect inside the soft-leave grace.** A phone briefly drops WS for less + than 250 ms (the soft-leave grace) and reconnects. Today, the runtime issues two PTY + resizes — one when the soft-leave fires (no, soft-leave doesn't fire because grace holds + things stable) and one on resubscribe with a fresh viewport. Under the new model the same + thing happens: the disconnect path clears the subscriber after grace expires (or the + reconnect aborts the grace), and a fresh subscribe enqueues a phone-target applyLayout. + Either way, exactly one or two PTY resizes per blip — same as today. Acceptable. + +## Compatibility + +- **Older mobile builds** (no seq filter): they apply every `scrollback`/`resized` event in + order, exactly as today. The seq field is ignored. They get all the bug fixes from the + server-side state-machine consolidation but lose the stale-event drop. Acceptable — + pre-state-machine is what they have today, and the state machine itself fixes the bulk of the + observable bugs. +- **Older mobile builds** (no `terminal.updateViewport`): they unsubscribe → resubscribe on + keyboard show/hide. The 250 ms soft-leave grace covers the gap; the new subscribe runs + through `applyLayout` and produces a clean transition. Same as today. +- **Renderer**: the existing `mobile-fit-overrides.ts` hydration and listener API is unchanged. + The renderer never sees `seq`. +- **CLI / desktop runtime callers** of `runtime.resizeForClient`: these are the legacy mobile + RPC paths that still flow through `resizeForClient`. After the rewrite, `resizeForClient` + becomes a thin shim that computes a target and calls `applyLayout`. Behavior is unchanged for + all observable inputs; the layout state machine just owns the writes now. + +## Test plan + +- **Unit tests** in `orca-runtime.test.ts`: + - One per transition row in the inputs table above. Each asserts (prev layout, trigger) → + (new layout, exactly one PTY resize call, exactly one resize wire event with monotonic seq). + - Idempotency: calling `applyLayout` with the current state target is a no-op (no resize, no + event, no seq bump). + - Multi-mobile: A subscribes (auto), B subscribes (auto, different viewport), B takes floor, + A takes floor. Three seq bumps; the last layout is `phone(A.viewport, A)`. + - PTY exit during pending-restore debounce. + - Renderer cascade during suppress window does not pollute `restoreBaseline`. +- **Test stubs for rollback path.** Add a `failsOnNthCall` ptyController fake at + `tests/fakes/pty-controller.ts` (or wherever runtime test fakes live in this repo). It + tracks call count and is configurable to return `false` or throw on the Nth call. A new + test file `applyLayout-rollback.test.ts` uses it to force a rollback and asserts: + (a) `layouts` reverts to its pre-call snapshot, + (b) `terminalFitOverrides` reverts to its pre-call snapshot, + (c) no `resized` event is emitted on the mobile subscribe stream, + (d) no `terminalFitOverrideChanged` is emitted to the renderer, + (e) the RPC handler returns `{ ok: false, reason: 'resize-failed' }`, + (f) the next successful applyLayout starts at pre-failure `seq + 1` (the failed attempt + did not bump the wire seq). +- **Integration test** `mobile-subscribe-integration.test.ts`: + - Phone subscribes, takes floor, gets `phone` dims; toggles to desktop, gets `desktop` dims; + toggles back to auto, gets `phone` dims again. All three transitions produce a `resized` + event with strictly increasing seq. + - Stale event drop: simulate a delayed `resized` from an older seq arriving after a newer + one has been applied; assert mobile-side handler does not reinit xterm. +- **Renderer test** `fit-override-integration.test.ts`: + - Existing safeFit-suppression tests pass unchanged (the wire shape is the same). +- **Mobile unit test** in `mobile/`: + - `TerminalWebView.applyFitScale` correctly handles a zero-scrollWidth first frame and only + commits once `cellWidth * cols` matches `scrollWidth`. Mock `requestAnimationFrame`. + - First-data-chunk re-fit: after `init` then `write`, exactly two `applyFitScale` invocations + occur (one inside init's after-drain callback, one in the write hook). After write a + second time, no further `applyFitScale`. + +## Out of scope + +- Mobile-side "Desktop is driving" banner. +- Unifying desktop and mobile write paths into a single coordinator (forward path, see + `docs/mobile-presence-lock.md`). +- Reworking the worktree-tab focus model to avoid the "active terminal mismatch" bug. +- **Two-phone rapid take-floor byte-stream interleaving.** When two phones rapidly hand off the + floor, byte-stream events from the previous owner can arrive at the renderer interleaved + with the new owner's bytes. This is an orthogonal coordinator-level concern (about + serializing input streams, not about layout), not a layout-state-machine concern. The state + machine guarantees layout convergence; byte-stream interleaving is filed against the + presence-lock forward-path work. + +## Rollout + +Single PR. Server changes (state machine consolidation), wire-format addition (`seq`), and +mobile/renderer changes ship together. We considered splitting the wire-format change from the +runtime refactor for tighter bisect, but rejected that: the wire change is meaningless without +the runtime emitting `seq` from the new state machine, and the runtime's correctness guarantees +depend on the mobile side honoring `seq`. Pairing them in one PR keeps the test surface +coherent. + +The bisect strategy under one PR relies on the wire change being **strictly additive on both +sides**: + +- The `seq` field is optional in the streamed event payloads. Older mobile builds ignore it; + they apply every event, exactly as today, and lose the stale-event drop. +- The mobile-side seq filter is **fail-open**: if `data.seq` is missing or null, the event is + applied. A partial mobile revert (back to a build that doesn't emit seq) running against a + new server still works. +- Equivalently, a partial server-only revert (back to a build that doesn't bump seq) running + against a new mobile build still works — the mobile side sees seq missing on every event and + applies them all. + +So a regression that is local to either side can be reverted on that side alone without +breaking the other. If the state machine consolidation itself regresses something subtle, a +single-commit revert restores prior behavior on both sides simultaneously. + +## Appendix A: Investigation Evidence + +This appendix anchors the design's motivation in concrete code locations. The four user-visible +symptoms that triggered this work each map to a specific buggy interaction between the +overlapping pieces of state described in the Problem section. For each symptom: where the bug +lives in `src/main/runtime/orca-runtime.ts` (current `main` at the time of writing), and a +numbered repro that triggers it. + +> **Note on line numbers.** These references are for ground-truth audit only. They are not +> automatically maintained; ordinary refactors will drift them. When reading after a few weeks, +> cross-reference by symbol name (`applyMobileDisplayMode`, `resizeForClient`, `mobileTookFloor`, +> `onExternalPtyResize`) rather than by raw line number. + +### Symptom 1: "Claim worked but the mobile terminal is still zoomed out" + +**Location.** `applyMobileDisplayMode`, the `mode === 'auto'` (non-desktop) branch — the +`!subscriberRecord.wasResizedToPhone` early-return check at orca-runtime.ts ~lines 2167–2175. +When `wasResizedToPhone` is `true` but the PTY has actually drifted back to desktop dims (e.g. +because a renderer cascade or take-back ran without clearing the flag on every subscriber), the +branch skips the resize, leaving the mobile xterm at desktop dims. + +**Repro.** +1. Phone subscribes with `mode='auto'`, viewport e.g. 80×24. PTY resizes to phone dims; + `wasResizedToPhone = true`. +2. Desktop user clicks "Take Back". `applyMobileDisplayMode` with `mode='desktop'` clears + `wasResizedToPhone` on the **subscriber-record** path but the test fixture happens to + trigger an `onExternalPtyResize` mid-cascade that restores the flag (see Symptom 4 below). +3. Phone toggles mode back to `auto`. The early-return fires; no resize is issued. +4. Mobile xterm stays at desktop dims; user perceives "claim didn't work." + +### Symptom 2: "Release didn't restore the desktop" + +**Location.** `resizeForClient` desktop-restore path at orca-runtime.ts ~lines 1390–1400. The +restore branch reads `previousCols/Rows` off the override, calls `ptyController.resize`, then +deletes the override. But if `previousCols/Rows` are `null` (e.g. the override was created by a +non-resize-capturing path, or a peer subscriber stored its baseline as `null` during a join +into an already-fitted PTY), the resize is skipped and the PTY stays at phone dims while the +override is deleted — visible as "release didn't restore." + +**Repro.** +1. Phone A subscribes with `mode='auto'`. Captures `previousCols/Rows` = (200, 50). PTY at + 80×24 (phone dims). +2. Phone B subscribes (also `mode='auto'`). It joins the already-fitted PTY; per today's + per-subscriber capture logic, B's `previousCols/Rows` are captured as the *current* phone + dims (80×24) — the existing override blocks pristine capture. +3. Phone A leaves. The remaining `terminalFitOverrides[ptyId].clientId` flips to B. The override + keeps A's previousCols/Rows. +4. Phone B sets `mode='desktop'`. `resizeForClient`'s restore branch reads the override's + `previousCols/Rows` — but the test setup or a race has nulled them through one of the + `wasResizedToPhone`-clearing paths. Resize is skipped. PTY stays at 80×24. +5. Renderer banner unmounts (driver = desktop), but the desktop pane is still showing 80×24 + content with broken line wrapping. + +### Symptom 3: "Pressing the resize button twice fixes it" + +**Location.** Same as Symptom 1 — the `wasResizedToPhone` early-return at ~lines 2167–2175. +First toggle hits the early-return because the flag is still `true`. The toggle's mode update +(`setDisplayMode` flipping the mode in `mobileDisplayModes`) lands, but no resize fires. Second +toggle: `wasResizedToPhone` was cleared by some adjacent path between the two clicks (or by the +mode change itself, depending on the path), so the second toggle's `applyMobileDisplayMode` +takes the actual-resize branch. + +**Repro.** Same as Symptom 1, plus: +6. Phone toggles mode again. Second time through the same code, the flag has been cleared (the + `applyMobileDisplayMode` desktop branch on the previous click cleared + `anyWasResized = true; sub.wasResizedToPhone = false`); now the auto/phone branch's + subscriber check passes, `handleMobileSubscribe` fires, PTY resizes correctly. + +### Symptom 4: "Switching tabs back and forth on the phone fixes it" + +**Location.** `onExternalPtyResize` at orca-runtime.ts ~lines 2186–2200. This handler runs when +the desktop renderer's safeFit cascade emits a `pty:resize` IPC mid-mobile-fit. Today it +unconditionally walks every subscriber and overwrites their `previousCols/Rows` if their +`wasResizedToPhone` is `false` — but during a cascade-mid-restore window, the flag is in +transition: some subscribers have been cleared, others haven't. The result is that the +just-cleared subscriber's pristine baseline (which we needed for the restore) gets overwritten +with a transient renderer-cascade size (often a wrong intermediate value like 214 cols mid- +animation). The next restore lands on the wrong dims; tab-switch on the phone forces a +`terminal.unsubscribe` → resubscribe, which rebuilds the entire subscriber record from scratch +and "fixes" the desync by happening to re-read the now-converged renderer size. + +**Repro.** +1. Phone subscribes. Baseline captured = (200, 50). PTY fits to 80×24. +2. Desktop user resizes the Electron window. The renderer's safeFit cascade emits two or three + `pty:resize` IPCs in rapid succession (the cascade fires for each pane and re-fires after + React re-renders). +3. `onExternalPtyResize` runs once per IPC. Each run walks subscribers and overwrites + `previousCols/Rows` for any subscriber whose `wasResizedToPhone` is `false`. (The fixture + path that triggers this regression is when one of the prior IPCs has already cleared the + flag for some subscribers but not others.) The pristine 200×50 baseline is overwritten + with the cascade's transient 214×42. +4. Phone sets `mode='desktop'`. Restore target = (214, 42). PTY ends up at 214×42 instead of + 200×50; desktop pane content wraps wrong. +5. Phone unsubscribes by switching tabs and resubscribes. The fresh subscribe captures the + *current* (now-converged) renderer size as its baseline, which happens to be 200×50, and + subsequent restores work — the desync is "papered over" by re-reading state from scratch. + +### Symptom cross-reference + +The four symptoms collectively prove the structural problem: each piece of state +(`wasResizedToPhone`, `previousCols/Rows`, `terminalFitOverrides`, `mobileDisplayModes`, the +actual PTY size) is mutated by a different subset of callsites, and no single function takes +responsibility for keeping them consistent. The state machine introduced in this design closes +all four by routing every transition through `applyLayout`, which is the only writer of the +maps and the only emitter of the wire events. diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 342077407d4..b6670898b0e 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -33,9 +33,15 @@ import { useHostClient, useCloseHost, useForceReconnect, - useReconnectAttempt + useReconnectAttempt, + useLastConnectedAt } from '../../../src/transport/client-context' -import type { ConnectionState, RpcSuccess } from '../../../src/transport/types' +import { + classifyConnection, + unreachableHint, + type ConnectionVerdict +} from '../../../src/transport/connection-health' +import type { RpcSuccess } from '../../../src/transport/types' import { triggerMediumImpact } from '../../../src/platform/haptics' import { StatusDot } from '../../../src/components/StatusDot' import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal' @@ -93,29 +99,8 @@ type FilterState = { selectedRepos: Set } -const STATUS_LABELS: Record = { - connecting: 'Connecting…', - handshaking: 'Securing…', - connected: 'Connected', - disconnected: 'Disconnected', - reconnecting: 'Reconnecting…', - 'auth-failed': 'Auth failed' -} - -// Why: same threshold as the home screen — kicks the label from -// "Reconnecting…" to "Can't connect" once the rpc-client has cycled enough -// times to indicate a real problem (wrong port, server down, network change). -const RECONNECT_FAILURE_THRESHOLD = 3 - -function getStatusDisplay( - state: ConnectionState, - attempts: number -): { label: string; isError: boolean } { - if (state === 'auth-failed') return { label: 'Auth failed', isError: true } - if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) { - return { label: "Can't connect", isError: true } - } - return { label: STATUS_LABELS[state], isError: false } +function isErrorVerdict(v: ConnectionVerdict): boolean { + return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed' } const SORT_OPTIONS: PickerOption[] = [ @@ -282,6 +267,7 @@ export default function HostScreen() { // docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) + const lastConnectedAt = useLastConnectedAt(hostId) const clientRef = useRef(null) const closeHostClient = useCloseHost() const forceReconnectHost = useForceReconnect() @@ -713,12 +699,21 @@ export default function HostScreen() { {connState !== 'connected' && (() => { - const status = getStatusDisplay(connState, reconnectAttempts) - const showReconnectButton = status.isError && hostId && connState !== 'auth-failed' + const verdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + const isError = isErrorVerdict(verdict) + const showReconnectButton = + isError && + hostId && + verdict.kind !== 'auth-failed' && + verdict.kind !== 'unreachable' return ( - - {status.label} + + {verdict.label} {showReconnectButton && ( )} + {/* Unreachable banner: surfaces the "host moved / network changed" + case after enough hard-fail reconnects. We keep retrying in the + background, but the user gets an actionable affordance instead + of an indefinite "Reconnecting…". */} + {connState !== 'auth-failed' && + (() => { + const verdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + if (verdict.kind !== 'unreachable') return null + return ( + + {unreachableHint(verdict.reason)} + + router.push('/pair-scan')}> + Re-pair + + {hostId && ( + void forceReconnectHost(hostId)} + > + Retry + + )} + setConfirmRemoveHost(true)}> + Remove + + + + ) + })()} + {/* Search bar */} {showSearch && ( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 196a2f9fa6b..8a9f8d9c295 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -163,6 +163,13 @@ export default function SessionScreen() { const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) const subscribeSeqRef = useRef>(new Map()) + // Why: server-side layout state machine emits a monotonic seq on every + // applyLayout. Track the highest seq we've observed per handle and drop + // any scrollback/resized event with a strictly older seq — these are + // late-arriving events from a superseded layout (e.g. phone-fit dims + // landing after the user toggled to desktop). Drops below `>20`-window + // gap reset (treat as a fresh subscription, e.g. server restart). + const layoutSeqRef = useRef>(new Map()) const sendingRef = useRef(false) // Why: tracks the pixel height of the terminal frame so measureFitDimensions // can use the exact container height instead of relying on window.innerHeight, @@ -180,6 +187,10 @@ export default function SessionScreen() { terminalUnsubsRef.current.delete(handle) subscribingHandlesRef.current.delete(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) }, []) const clearTerminalCache = useCallback(() => { @@ -191,6 +202,7 @@ export default function SessionScreen() { initializedHandlesRef.current.clear() webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() + layoutSeqRef.current.clear() for (const term of terminalRefs.current.values()) { term.clear() } @@ -226,6 +238,13 @@ export default function SessionScreen() { const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1 subscribeSeqRef.current.set(handle, seq) + console.log('[fit][session] subscribe', { + handle: handle.slice(-8), + seq, + viewport: viewportRef.current, + viewportMeasured: viewportMeasuredRef.current + }) + // Why: server handles auto-fit on subscribe — no terminal.focus call needed. // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit @@ -240,10 +259,46 @@ export default function SessionScreen() { (result) => { if (subscribeSeqRef.current.get(handle) !== seq) return const data = result as Record + // Why: stale-event filter. Server-side state machine bumps a + // monotonic seq on every applyLayout. Drop scrollback/resized + // events whose seq is strictly older than what we've already + // observed for this handle — they're late-arriving from a + // superseded layout. >20-event regress = treat as server + // restart (fresh state machine), reset the high-water mark. + const eventSeq = typeof data.seq === 'number' ? data.seq : null + if (eventSeq != null && (data.type === 'scrollback' || data.type === 'resized')) { + const last = layoutSeqRef.current.get(handle) + if (last != null && eventSeq < last && last - eventSeq <= 20) { + console.log('[fit][session] DROP-stale-seq', { + handle: handle.slice(-8), + type: data.type, + eventSeq, + lastSeq: last, + cols: data.cols, + rows: data.rows, + displayMode: data.displayMode + }) + return + } + layoutSeqRef.current.set(handle, eventSeq) + } + if (data.type === 'scrollback' || data.type === 'resized') { + console.log('[fit][session] event', { + handle: handle.slice(-8), + type: data.type, + cols: data.cols, + rows: data.rows, + displayMode: data.displayMode, + seq: eventSeq, + reason: data.reason + }) + } if (data.type === 'scrollback') { if (initializedHandlesRef.current.has(handle)) return const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 + const scrollbackCols = cols + const scrollbackRows = rows const initialData = typeof data.serialized === 'string' && data.serialized.length > 0 ? data.serialized @@ -255,29 +310,65 @@ export default function SessionScreen() { new Map(prev).set(handle, data.displayMode as MobileDisplayMode) ) } - // Why: cold-start fit-to-screen guard. The first init() runs - // before xterm's DOM/canvas has fully laid out, so the - // applyFitScale that init queues internally can land while - // term.element.scrollWidth is still stale or zero — leaving - // the terminal un-zoomed until the user toggles the resize - // button. Re-fire resetZoom after a short delay so it runs - // against a settled DOM. Mirrors the 'resized' handler below. + // Why: belt-and-suspenders cold-start fit. The applyFitScale + // queued by init() runs after writes drain, but on cold start + // xterm's scrollWidth can still be transient when it commits. + // Re-fire after a short delay so it runs against a settled DOM. + // Mirrors the 'resized' handler below. setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200) // Why: viewport measurement needs xterm to be initialized (cell // dimensions come from the renderer). On the first subscribe the // WebView hasn't loaded yet, so viewportRef is null and the server // can't auto-fit. After the first init we can measure, then // resubscribe so the server gets the viewport and phone-fits. - if (!viewportMeasuredRef.current) { + // If viewport was measured by a parallel path BUT the scrollback + // we just received came back at desktop dims, our subscribe + // beat the measure; the server still has a null viewport for + // this subscriber record — resubscribe so it gets stored. + const needsResubscribe = + !viewportMeasuredRef.current || + (viewportRef.current != null && + (scrollbackCols !== viewportRef.current.cols || + scrollbackRows !== viewportRef.current.rows)) + if (needsResubscribe) { void (async () => { + console.log('[fit][session] post-scrollback measure-start', { + handle: handle.slice(-8), + containerHeight: terminalFrameHeightRef.current + }) + // Why: wait for the WebView's init() rAF chain to fully + // run (term.open → renderService population → first + // paint) before measuring. Without this, the measure + // postMessage races ahead of init's async work and + // returns null (term not ready / cells size 0), the + // resubscribe never fires, and the server never gets + // phone dims. See log dump 2026-05-06 confirming the + // race + measure-result null pattern. + await getTerminalRef(handle)?.awaitReady() const dims = await getTerminalRef(handle)?.measureFitDimensions( terminalFrameHeightRef.current || undefined ) - if (dims && !viewportMeasuredRef.current) { + // Why: we just got `scrollback` with cols=80 (server's + // default fallback for null viewport). That means the + // server-side subscriber record was registered before we + // could send viewport. Even if `viewportMeasuredRef` + // raced ahead via a parallel `measureViewportOnce`, the + // server still has a null viewport for THIS subscriber + // record — we MUST resubscribe so the server stores it. + console.log('[fit][session] post-scrollback measure-result', { + handle: handle.slice(-8), + dims, + alreadyMeasured: viewportMeasuredRef.current + }) + if (dims) { viewportRef.current = dims viewportMeasuredRef.current = true unsubscribeTerminal(handle) initializedHandlesRef.current.delete(handle) + console.log('[fit][session] post-scrollback re-subscribe', { + handle: handle.slice(-8), + viewport: dims + }) subscribeToTerminal(handle) } })() @@ -324,17 +415,29 @@ export default function SessionScreen() { if (!client) return if (toggleInFlightRef.current.has(handle)) return const current = terminalModes.get(handle) ?? 'auto' - const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto' + // Why: 'phone' on the wire is an observation ("currently phone-fitted"), + // not a setting. The toggle only ever requests 'auto' or 'desktop'. + const next: 'auto' | 'desktop' = + current === 'auto' || current === 'phone' ? 'desktop' : 'auto' + console.log('[fit][session] toggleDisplayMode', { + handle: handle.slice(-8), + current, + next + }) toggleInFlightRef.current.add(handle) try { await client.sendRequest('terminal.setDisplayMode', { terminal: handle, mode: next, - // Why: presence-lock take-floor signal. Sending mode=auto/phone - // is a deliberate "I want to drive at phone dims" gesture. + // Why: presence-lock take-floor signal — requesting 'auto' is the + // explicit "I want to drive at phone dims" gesture. ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) + : {}), + // Why: late-bind viewport for terminals whose subscribe record + // was registered before measurement landed. Without this the + // server's stored viewport is null and auto toggles no-op. + ...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {}) }) } catch { // Mode change failed — server state unchanged, UI stays in sync. @@ -666,6 +769,7 @@ export default function SessionScreen() { // scrollback snapshot. Only resubscribe if this is a reload — on // first load the subscription is already running and pendingMessages // will flush the queued init after this callback returns. + // (unsubscribeTerminal also clears layoutSeqRef for this handle.) unsubscribeTerminal(handle) initializedHandlesRef.current.delete(handle) if (handle === activeHandleRef.current) { @@ -676,10 +780,18 @@ export default function SessionScreen() { // Why: on first web-ready, the initial subscribeToTerminal call from // fetchTerminals may have been skipped (reason=no-ref, WebView wasn't // mounted yet). Now that the WebView is ready, subscribe if this is the - // active terminal and no subscription is running. + // active terminal and no subscription is running. Await measure before + // subscribe so the very first subscribe carries the viewport — without + // this, subscribe(viewport=null) lands on the server first and the + // post-scrollback measure path's resubscribe sees alreadyMeasured=true + // (because measureViewportOnce won the race) and silently skips. if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { - void measureViewportOnce(handle) - subscribeToTerminal(handle) + void (async () => { + await measureViewportOnce(handle) + if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + subscribeToTerminal(handle) + } + })() } }, [measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index c5c4579c620..cd025325a79 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -32,6 +32,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' +import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -53,32 +54,6 @@ function endpointLabel(endpoint: string): string { } } -const STATUS_LABELS: Record = { - connected: 'Connected', - connecting: 'Connecting…', - disconnected: 'Disconnected', - reconnecting: 'Reconnecting…', - handshaking: 'Connecting…', - 'auth-failed': 'Auth failed' -} - -// Why: a few quick reconnects are normal (laptop wake, brief network blip). -// After this many failed attempts in a row, the user almost certainly has -// a real problem (wrong port, server down, network change), so escalate -// the label and color so it's obvious something's wrong. -const RECONNECT_FAILURE_THRESHOLD = 3 - -function getStatusDisplay( - state: ConnectionState, - attempts: number -): { label: string; isError: boolean } { - if (state === 'auth-failed') return { label: 'Auth failed', isError: true } - if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) { - return { label: "Can't connect", isError: true } - } - return { label: STATUS_LABELS[state], isError: false } -} - type StatsSummary = { totalAgentsSpawned: number totalPRsCreated: number @@ -226,6 +201,7 @@ export default function HomeScreen() { const [confirmRemove, setConfirmRemove] = useState(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) + const [hostLastConnected, setHostLastConnected] = useState>({}) const [stats, setStats] = useState(null) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) @@ -342,6 +318,18 @@ export default function HomeScreen() { } return changed ? next : prev }) + setHostLastConnected((prev) => { + const next: Record = { ...prev } + let changed = false + for (const entry of allClients) { + const t = entry.client.getLastConnectedAt() + if (next[entry.hostId] !== t) { + next[entry.hostId] = t + changed = true + } + } + return changed ? next : prev + }) setHostStates((prev) => { const next: Record = { ...prev } let changed = false @@ -636,9 +624,18 @@ export default function HomeScreen() { renderItem={({ item }) => { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 + const lastConnectedAt = hostLastConnected[item.id] ?? null const connected = state === 'connected' const info = worktreeInfo[item.id] - const status = getStatusDisplay(state, attempts) + const verdict = classifyConnection({ + state, + reconnectAttempts: attempts, + lastConnectedAt + }) + const isError = + verdict.kind === 'warning' || + verdict.kind === 'unreachable' || + verdict.kind === 'auth-failed' return ( [styles.hostCard, pressed && styles.hostCardPressed]} @@ -664,10 +661,8 @@ export default function HomeScreen() { - - {status.label} + + {verdict.label} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} @@ -854,9 +849,15 @@ export default function HomeScreen() { state === 'connecting' || state === 'handshaking' || state === 'reconnecting' + // Why: "Reconnect" implies "you were connected, try again". If + // the client has never reached 'connected' this session (cold + // start, unreachable host, or after Disconnect) the action is + // functionally a fresh Connect — using the right verb makes + // the affordance match what tapping it actually does. + const hasEverConnected = (hostLastConnected[host.id] ?? null) != null const items: ActionSheetAction[] = [] items.push({ - label: 'Reconnect', + label: hasEverConnected && isLive ? 'Reconnect' : 'Connect', icon: RefreshCw, onPress: () => { setActionTarget(null) diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 9766354f1e5..13262fd2dd5 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -10,6 +10,11 @@ export type TerminalWebViewHandle = { clear: () => void measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> resetZoom: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise } type Props = { @@ -82,14 +87,49 @@ const XTERM_HTML = ` var terminalGeneration = 0; var activeAltScreenSnapshot = false; var handledMessageIds = []; + // Why: after init() the initial scrollback applyFitScale may have run + // against an empty buffer (or one without the widest line yet). Re-fit + // once when the first live data chunk arrives so a wider line that pushes + // scrollWidth past the previously-measured value gets re-scaled to fit. + var firstDataPending = false; + // Diagnostic logger — bridges WebView console.log to RN via postMessage. + // Tag with [fit] so it's easy to filter in the Expo/Metro logs. + function flog(tag, payload) { + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'log', tag: '[fit]' + tag, payload: payload + })); + } + } catch (e) {} + } + + function getCellWidth() { + if (!term || !term._core) return 0; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.width || 0; + } + return 0; + } + + // Why: width measurement strategy. + // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses + // to lay out and is independent of buffer content. It's the "logical + // width" of the terminal grid. + // 2. Fall back to term.element.scrollWidth — the actual rendered DOM + // width — only when cellWidth isn't available yet (renderer not + // initialized). This is content-dependent (reflects widest row), + // but better than nothing. + // 3. If both are 0, return 1 (no scale change). The retry loop in + // applyFitScale will keep trying until one is positive. function computeFitScale() { if (!term) return 1; - var el = term.element; - if (!el) return 1; - var termWidth = el.scrollWidth; - var vpWidth = window.innerWidth; + var cellW = getCellWidth(); + var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); if (termWidth <= 0) return 1; + var vpWidth = window.innerWidth; return Math.min(1, vpWidth / termWidth); } @@ -159,33 +199,63 @@ const XTERM_HTML = ` } } - // Why: on cold start (first WebView load + first scrollback) xterm's DOM - // and canvas need several frames to reflow after term.open(). If we - // computeFitScale() too eagerly we read scrollWidth=0 or a stale width - // from before the new cols took effect, scrollWidth/vpWidth >= 1, and - // currentScale snaps to 1 — which is exactly the "didn't zoom to fit" - // bug users see on first load. Retry across frames until we get a - // positive, stable scrollWidth, then commit. Capped to keep this from - // spinning forever if the WebView never lays out (e.g. backgrounded). - var FIT_RETRY_MAX_FRAMES = 30; + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; var fitRetryToken = 0; - function applyFitScale() { - if (!term || !term.element) return; + function applyFitScale(reason) { + if (!term || !term.element) { + flog('skip-no-term', { reason: reason }); + return; + } var token = ++fitRetryToken; var attempts = 0; - var lastWidth = -1; + var lastScrollWidth = -1; + flog('start', { + reason: reason, + cols: term.cols, + rows: term.rows, + vpWidth: window.innerWidth, + vpHeight: window.innerHeight + }); function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - var w = term.element.scrollWidth; - attempts++; - if (w > 0 && w === lastWidth) { - commitFitScale(); + if (token !== fitRetryToken) { + flog('cancel-superseded', { reason: reason, attempts: attempts }); return; } - lastWidth = w; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + flog('commit-cellW', { reason: reason, attempts: attempts, cellW: cellW, cols: term.cols }); + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + flog('commit-stableSW', { reason: reason, attempts: attempts, scrollWidth: w }); + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; if (attempts >= FIT_RETRY_MAX_FRAMES) { - commitFitScale(); + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); return; } requestAnimationFrame(attempt); @@ -193,17 +263,39 @@ const XTERM_HTML = ` requestAnimationFrame(attempt); } - function commitFitScale() { + function commitFitScale(reason, attempts, gate) { if (!term || !term.element) return; - currentScale = computeFitScale(); - // Why: when the scale is very close to 1 (e.g. 0.97 due to xterm - // scrollbar width), snap to 1.0 to avoid sub-pixel shrinkage. + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. if (currentScale >= 0.95) currentScale = 1; userScale = 1; panX = 0; panY = 0; updateTransform(); adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + flog(suspect ? 'commit-SUSPECT' : 'commit', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW, + suspect: suspect + }); } function isAltScreenActive(data) { @@ -254,6 +346,16 @@ const XTERM_HTML = ` writesDraining = false; afterDrainCallbacks = []; initRows = rows || 24; + firstDataPending = true; + flog('init', { + cols: cols, + rows: rows, + hasInitialData: typeof initialData === 'string' && initialData.length > 0, + initialDataLen: typeof initialData === 'string' ? initialData.length : 0, + vpWidth: window.innerWidth, + vpHeight: window.innerHeight, + gen: gen + }); var replayData = normalizeInitialData(initialData); activeAltScreenSnapshot = isAltScreenActive(replayData); if (term) term.dispose(); @@ -304,7 +406,7 @@ const XTERM_HTML = ` ready = true; afterWritesDrained(function() { if (gen !== terminalGeneration) return; - applyFitScale(); + applyFitScale('init-replay'); notify({ type: 'ready', cols: cols, rows: rows }); }); }); @@ -313,6 +415,18 @@ const XTERM_HTML = ` function write(data) { writeQueue.push(data); pumpWrites(terminalGeneration); + // Why: first live data chunk after init may widen the buffer past + // what the post-replay applyFitScale measured. Re-fit once after this + // chunk drains to catch the wider line. Subsequent chunks don't re-fit + // (the user's manual zoom is sticky after that). + if (firstDataPending) { + firstDataPending = false; + var gen = terminalGeneration; + afterWritesDrained(function() { + if (gen !== terminalGeneration) return; + applyFitScale('first-data'); + }); + } } function notify(msg) { @@ -321,22 +435,35 @@ const XTERM_HTML = ` } } - function measureFitDimensions(containerHeightPx) { - if (!term || !term.element) { - notify({ type: 'measure-result', cols: null, rows: null }); - return; - } - // Why: measure actual xterm cell dimensions from the renderer, not from - // font metrics alone. This accounts for the exact font, size, and line - // height that xterm is using. - var core = term._core; + function measureFitDimensions(containerHeightPx, retriesLeft) { + if (typeof retriesLeft !== 'number') retriesLeft = 30; + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + var notReady = !term || !term.element; var cellWidth = 0; var cellHeight = 0; - if (core && core._renderService && core._renderService.dimensions) { - cellWidth = core._renderService.dimensions.css.cell.width; - cellHeight = core._renderService.dimensions.css.cell.height; + if (!notReady) { + var core = term._core; + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width; + cellHeight = core._renderService.dimensions.css.cell.height; + } } - if (cellWidth <= 0 || cellHeight <= 0) { + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function() { + measureFitDimensions(containerHeightPx, retriesLeft - 1); + }); + return; + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }); notify({ type: 'measure-result', cols: null, rows: null }); return; } @@ -381,7 +508,7 @@ const XTERM_HTML = ` } else if (msg.type === 'measure') { measureFitDimensions(msg.containerHeight); } else if (msg.type === 'reset-zoom') { - applyFitScale(); + applyFitScale('reset-zoom-msg'); } } @@ -528,6 +655,11 @@ const XTERM_HTML = ` }); window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); adjustRowsForViewport(); clampPan(); updateTransform(); @@ -554,6 +686,12 @@ export const TerminalWebView = forwardRef(function const measureResolveRef = useRef< ((result: { cols: number; rows: number } | null) => void) | null >(null) + // Why: each init() call posts 'init' to the WebView and arms a fresh + // ready promise. WebView's init() rAF chain ends with a 'ready' notify + // that resolves it. measureFitDimensions awaits this so it doesn't + // race ahead of term.open() / renderService population. + const readyPromiseRef = useRef | null>(null) + const readyResolveRef = useRef<(() => void) | null>(null) const sendToWebView = useCallback((msg: TerminalMessage) => { messageIdRef.current += 1 @@ -592,6 +730,15 @@ export const TerminalWebView = forwardRef(function isWebReadyRef.current = true onWebReady?.() flushPendingMessages() + } else if (msg.type === 'ready') { + // Why: the WebView's init() rAF chain has run — term is open, + // renderService is populated, first paint has happened. Resolve + // any pending awaitReady() so a queued measure can now safely + // read cell dims. + const resolve = readyResolveRef.current + readyResolveRef.current = null + readyPromiseRef.current = null + resolve?.() } else if (msg.type === 'measure-result') { const resolve = measureResolveRef.current measureResolveRef.current = null @@ -600,6 +747,11 @@ export const TerminalWebView = forwardRef(function const rows = typeof msg.rows === 'number' ? msg.rows : null resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null) } + } else if (msg.type === 'log') { + // Surface fit-scale diagnostics in the RN/Metro console. + const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]' + // eslint-disable-next-line no-console + console.log(tag, msg.payload) } }, [flushPendingMessages, onWebReady] @@ -616,6 +768,14 @@ export const TerminalWebView = forwardRef(function postMessage({ type: 'write', data }) }, init(cols: number, rows: number, initialData?: string) { + // Why: arm a fresh ready promise BEFORE posting init. The WebView + // resolves it via the 'ready' notify at the end of its rAF chain. + // Re-init supersedes any prior in-flight ready (we don't bridge + // generations; the older promise just never resolves, and its + // callers have moved on by then). + readyPromiseRef.current = new Promise((resolve) => { + readyResolveRef.current = resolve + }) postMessage({ type: 'init', cols, rows, initialData }) }, clear() { @@ -642,6 +802,14 @@ export const TerminalWebView = forwardRef(function }, resetZoom() { postMessage({ type: 'reset-zoom' }) + }, + async awaitReady(): Promise { + // Why: returns the in-flight ready promise (set by init); resolves + // immediately if no init is pending. Capped at 3s so a stuck + // WebView doesn't hang the caller. + const p = readyPromiseRef.current + if (!p) return + await Promise.race([p, new Promise((resolve) => setTimeout(resolve, 3000))]) } }), [postMessage, sendToWebView] diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 176b80355e8..8807c4c33e9 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -37,6 +37,11 @@ type ContextValue = { closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState getReconnectAttempt: (hostId: string) => number + // Why: timestamp (ms epoch) of the last successful 'connected' state + // transition for this host, or null if never connected this session. + // Used by the UI to escalate "Reconnecting…" into a "host appears + // unreachable, re-pair?" prompt. + getLastConnectedAt: (hostId: string) => number | null subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => Array<{ hostId: string; client: RpcClient }> subscribeAllHosts: (listener: () => void) => () => void @@ -230,6 +235,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0 }, []) + const getLastConnectedAt = useCallback((hostId: string): number | null => { + return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null + }, []) + const subscribeHostState = useCallback( (hostId: string, listener: (state: ConnectionState) => void) => { let set = stateListenersRef.current.get(hostId) @@ -289,6 +298,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { closeHost: closeEntry, getState, getReconnectAttempt, + getLastConnectedAt, subscribeHostState, getAllClients, subscribeAllHosts, @@ -301,6 +311,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { closeEntry, getState, getReconnectAttempt, + getLastConnectedAt, subscribeHostState, getAllClients, subscribeAllHosts, @@ -448,3 +459,18 @@ export function useReconnectAttempt(hostId: string | undefined): number { }, [ctx, hostId]) return hostId ? ctx.getReconnectAttempt(hostId) : 0 } + +// Why: timestamp of last successful connect for this host, or null if +// the client has never connected. Combined with reconnectAttempt this +// distinguishes "transient blip" (recently connected) from "host +// appears unreachable" (never connected, or hasn't connected in N +// seconds despite many retry attempts). +export function useLastConnectedAt(hostId: string | undefined): number | null { + const ctx = useCtx() + const [, force] = useState(0) + useEffect(() => { + if (!hostId) return + return ctx.subscribeHostState(hostId, () => force((n) => n + 1)) + }, [ctx, hostId]) + return hostId ? ctx.getLastConnectedAt(hostId) : null +} diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts new file mode 100644 index 00000000000..b9524150b5f --- /dev/null +++ b/mobile/src/transport/connection-health.ts @@ -0,0 +1,86 @@ +import type { ConnectionState } from './types' + +// Why: thresholds for escalating connection UX from neutral +// "Reconnecting…" to alarming "host appears unreachable, re-pair?". +// +// - WARNING_ATTEMPTS: 3 → label flips to "Can't connect" (existing +// behavior). Calibrated to absorb a normal laptop wake / brief +// network blip without alarming the user. +// - UNREACHABLE_ATTEMPTS: 6 → with the 4s backoff cap that's ≈ 20s of +// continuous failure. Combined with the never-connected / +// stale-since-last-connect heuristic below, this is the trigger to +// surface a "re-pair?" affordance. +// - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this +// session but haven't been for ≥ 1 minute despite the retry loop +// spinning, treat the same as never-connected. Catches the case +// where the desktop's IP changed mid-session. +export const WARNING_ATTEMPTS = 3 +export const UNREACHABLE_ATTEMPTS = 6 +export const STALE_SINCE_LAST_CONNECT_MS = 60_000 + +export type ConnectionVerdict = + | { kind: 'normal'; label: string } + | { kind: 'warning'; label: string } // "Can't connect" + | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'auth-failed'; label: string } + +// Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have +// to recompute "are we currently stale" against now() each render. +// Centralized so home + host-detail show identical verdicts. +export function classifyConnection(args: { + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + nowMs?: number +}): ConnectionVerdict { + const { state, reconnectAttempts, lastConnectedAt } = args + const now = args.nowMs ?? Date.now() + + if (state === 'auth-failed') { + return { kind: 'auth-failed', label: 'Auth failed' } + } + + // Connected / connecting / handshaking are normal. + if (state === 'connected') return { kind: 'normal', label: 'Connected' } + if (state === 'connecting' || state === 'handshaking') { + return { kind: 'normal', label: 'Connecting…' } + } + + if (state === 'disconnected') { + return { kind: 'normal', label: 'Disconnected' } + } + + // state === 'reconnecting' from here. + if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) { + if (lastConnectedAt == null) { + return { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'never-connected' + } + } + if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { + return { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale' + } + } + } + + if (reconnectAttempts >= WARNING_ATTEMPTS) { + return { kind: 'warning', label: "Can't connect" } + } + + return { kind: 'normal', label: 'Reconnecting…' } +} + +// Why: the message under the banner explains what likely happened so the +// user understands why we're suggesting Re-pair. Tuned to be specific +// about IP/port without being technical (we don't want to leak +// "ws://192.168.x.y:port" unless someone is debugging). +export function unreachableHint(reason: 'never-connected' | 'stale'): string { + return reason === 'never-connected' + ? "Can't reach this Orca desktop. Its network address may have changed since pairing — try re-pairing from the desktop's Settings → Mobile screen." + : 'Lost contact with the Orca desktop. If your network changed (different Wi-Fi, IP renewed, or desktop restarted), try re-pairing.' +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index d1269000382..5fb7bf674de 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -28,6 +28,10 @@ export type RpcClient = { // Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross // a threshold. 0 means never failed; counter is reset on successful open. getReconnectAttempt: () => number + // Why: timestamp (ms epoch) of the last time we reached 'connected'. + // null = never connected since the client was created. Used by the UI + // to distinguish "host moved/never reachable" from "transient blip". + getLastConnectedAt: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void close: () => void } @@ -40,6 +44,14 @@ export type RpcClient = { // manual Reconnect button bypassed the timer, which is why it felt // "magic". Shorter backoff makes the auto-recovery path feel as fast. const RECONNECT_DELAYS = [500, 1000, 2000, 4000] +// Why: cap auto-retry once we're clearly unreachable. With the 4s +// backoff cap that's ≈20s of solid failure before we stop. The UI +// renders an "unreachable, re-pair?" banner at this point; user taps +// Retry or Re-pair to resume. MUST stay aligned with +// connection-health.ts UNREACHABLE_ATTEMPTS so the verdict matches the +// moment the loop pauses — if these drift the user sees "Reconnecting…" +// while the loop is silently parked. +const GIVE_UP_AFTER_ATTEMPTS = 6 const REQUEST_TIMEOUT_MS = 30_000 const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 @@ -75,6 +87,7 @@ export function connect( let handshakeTimer: ReturnType | null = null let activityProbeTimer: ReturnType | null = null let intentionallyClosed = false + let lastConnectedAt: number | null = null // Why: fresh ephemeral keypair per connection provides forward secrecy. // The shared key is derived from our ephemeral secret + server's static public key. @@ -90,10 +103,26 @@ export function connect( stateListeners.add(onStateChange) } + // Diagnostic: tracks how long we've been in the current state. Useful + // for spotting "stuck in connecting" or "stuck in reconnecting" cases + // in the logs. + let stateEnteredAt = Date.now() + function setState(next: ConnectionState) { if (state === next) return + const prev = state + const dwelt = Date.now() - stateEnteredAt state = next + stateEnteredAt = Date.now() + console.log('[net] state', { + from: prev, + to: next, + dweltMs: dwelt, + attempt: reconnectAttempt, + endpoint: redactedEndpoint(endpoint) + }) if (next === 'connected') { + lastConnectedAt = Date.now() for (const w of connectWaiters.splice(0)) w.resolve() } else if (next === 'disconnected' || next === 'auth-failed') { const reason = @@ -105,6 +134,17 @@ export function connect( } } + // Why: don't dump device tokens / full URLs into log scrolls; truncate to + // the host:port so reconnect lifecycles are still readable. + function redactedEndpoint(ep: string): string { + try { + const m = ep.match(/^wss?:\/\/([^/]+)/i) + return m ? m[1] : 'unknown' + } catch { + return 'unknown' + } + } + function waitForConnected(): Promise { if (state === 'connected') return Promise.resolve() if (intentionallyClosed) return Promise.reject(new Error('Client closed')) @@ -120,6 +160,10 @@ export function connect( function openConnection() { if (intentionallyClosed) return + console.log('[net] openConnection', { + attempt: reconnectAttempt, + endpoint: redactedEndpoint(endpoint) + }) setState('connecting') sharedKey = null @@ -132,14 +176,19 @@ export function connect( connectTimer = setTimeout(() => { connectTimer = null if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { + console.log('[net] connect-timeout fired (onopen never arrived)', { + attempt: reconnectAttempt, + timeoutMs: CONNECT_TIMEOUT_MS + }) openingWs.close() if (ws === openingWs) { - handleSocketClosed(openingWs) + handleSocketClosed(openingWs, { timedOut: true }) } } }, CONNECT_TIMEOUT_MS) ws.onopen = () => { + console.log('[net] ws.onopen', { attempt: reconnectAttempt }) clearConnectTimer() reconnectAttempt = 0 setState('handshaking') @@ -158,6 +207,9 @@ export function connect( handshakeTimer = setTimeout(() => { handshakeTimer = null + console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', { + timeoutMs: HANDSHAKE_TIMEOUT_MS + }) ws?.close() }, HANDSHAKE_TIMEOUT_MS) } @@ -194,12 +246,16 @@ export function connect( clearTimeout(handshakeTimer) handshakeTimer = null } + console.log('[net] e2ee_authenticated — connected', { + streamCount: streamListeners.size + }) setState('connected') startActivityProbe() for (const [id, stream] of streamListeners) { sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) } } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { + console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) intentionallyClosed = true ws?.close() ws = null @@ -277,17 +333,39 @@ export function connect( } } - ws.onclose = () => { + ws.onclose = (event) => { + const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined + console.log('[net] ws.onclose', { + code: e?.code, + reason: e?.reason, + wasClean: e?.wasClean, + state, + attempt: reconnectAttempt, + intentionallyClosed, + endpoint: redactedEndpoint(endpoint) + }) handleSocketClosed(openingWs) } - ws.onerror = () => { - // onclose will fire after this + ws.onerror = (event) => { + // Why: RN surfaces network errors here (DNS failure, TCP RST, etc). + // onclose fires right after, but logging the error message gives us + // the original cause that the close code alone can hide. + const e = event as { message?: string } | undefined + console.log('[net] ws.onerror', { + message: e?.message, + state, + attempt: reconnectAttempt + }) } } - function handleSocketClosed(closedWs: WebSocket) { + function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) { if (ws !== closedWs) { + console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', { + state, + attempt: reconnectAttempt + }) return } clearConnectTimer() @@ -299,18 +377,42 @@ export function connect( } stopActivityProbe() if (intentionallyClosed) { + console.log('[net] handleSocketClosed — intentional close') setState('disconnected') rejectAllPending('Connection closed') return } + console.log('[net] handleSocketClosed → reconnect', { + timedOut: !!opts.timedOut, + pendingCount: pending.size, + streamCount: streamListeners.size, + attempt: reconnectAttempt + }) rejectAllPending('Connection interrupted') setState('reconnecting') scheduleReconnect() } function scheduleReconnect() { + // Why: spinning reconnect forever drains battery and floods logs + // when the host is genuinely unreachable (wrong IP, port closed, + // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a + // "Can't reach desktop, re-pair?" banner at this point and the + // user can tap Retry (forceReconnect creates a fresh client, + // resetting the counter) or Re-pair. Without an explicit cap the + // worst-case is a phone left on the home screen burning a socket + // open every 4s indefinitely. + if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + console.log('[net] reconnect-paused', { + attempt: reconnectAttempt, + reason: 'give-up-cap', + endpoint: redactedEndpoint(endpoint) + }) + return + } const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! reconnectAttempt++ + console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -341,10 +443,15 @@ export function connect( // half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the // user wait nearly a minute before reconnect kicks in. const id = nextId() + const probeStart = Date.now() let timedOut = false const timeout = setTimeout(() => { timedOut = true pending.delete(id) + console.log('[net] activity-probe TIMEOUT — forcing reconnect', { + waitedMs: Date.now() - probeStart, + state + }) // Why: only force-close if this is still the same socket the // probe was sent on; a normal close that already swapped `ws` // shouldn't trigger a redundant terminate. @@ -389,6 +496,23 @@ export function connect( ws.send(encrypt(JSON.stringify(request), sharedKey)) return true } + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: !!ws, + readyState: ws?.readyState, + hasKey: !!sharedKey, + state + }) + // Why: if the state machine still thinks we're connected but the + // underlying WebSocket has flipped to CLOSING/CLOSED without onclose + // having fired (RN's WebSocket sometimes drops the event, or the + // server half-closed the stream), force a reconnect. Without this + // every send silently fails forever and the user sees a frozen UI. + if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { + console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { + readyState: ws.readyState + }) + handleSocketClosed(ws, { timedOut: false }) + } return false } @@ -396,12 +520,25 @@ export function connect( return { async sendRequest(method: string, params?: unknown): Promise { + const waitStart = Date.now() + const wasConnected = state === 'connected' await waitForConnected() + if (!wasConnected) { + console.log('[net] sendRequest waited for connect', { + method, + waitedMs: Date.now() - waitStart + }) + } return new Promise((resolve, reject) => { const id = nextId() const timeout = setTimeout(() => { pending.delete(id) + console.log('[net] sendRequest TIMEOUT', { + method, + timeoutMs: REQUEST_TIMEOUT_MS, + state + }) reject(new Error(`Request timed out: ${method}`)) }, REQUEST_TIMEOUT_MS) @@ -430,6 +567,11 @@ export function connect( if (state === 'connected') { sendEncrypted({ id, deviceToken, method, params }) + } else { + // Stream is registered but the actual outbound subscribe will be + // sent (or re-sent) when the channel reaches 'connected'. Useful + // when terminals don't load — confirms the request is queued. + console.log('[net] subscribe queued — waiting for connected', { method, state }) } return () => { @@ -477,6 +619,10 @@ export function connect( return reconnectAttempt }, + getLastConnectedAt(): number | null { + return lastConnectedAt + }, + onStateChange(listener: (state: ConnectionState) => void): () => void { stateListeners.add(listener) return () => stateListeners.delete(listener) diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts index a8e7e2ba85c..6bd09a750b7 100644 --- a/src/main/runtime/fit-override-integration.test.ts +++ b/src/main/runtime/fit-override-integration.test.ts @@ -68,7 +68,7 @@ const store = { } describe('fit override integration', () => { - it('full lifecycle: fit → getSize → restore → verify PTY dims', () => { + it('full lifecycle: fit → getSize → restore → verify PTY dims', async () => { const runtime = new OrcaRuntimeService(store) const currentSize = { cols: 150, rows: 40 } const resizes: { ptyId: string; cols: number; rows: number }[] = [] @@ -130,7 +130,7 @@ describe('fit override integration', () => { expect(currentSize).toEqual({ cols: 150, rows: 40 }) console.log('\n=== Step 2: Mobile fit to 45x20 ===') - const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) console.log('Fit result:', fitResult) console.log('PTY size after fit:', currentSize) console.log('Override:', runtime.getTerminalFitOverride('pty-1')) @@ -142,7 +142,7 @@ describe('fit override integration', () => { // Simulate what runtime:restoreTerminalFit IPC handler does const override = runtime.getTerminalFitOverride('pty-1') expect(override).not.toBeNull() - const restoreResult = runtime.resizeForClient('pty-1', 'restore', override!.clientId) + const restoreResult = await runtime.resizeForClient('pty-1', 'restore', override!.clientId) console.log('Restore result:', restoreResult) console.log('PTY size after restore:', currentSize) console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1')) @@ -165,17 +165,17 @@ describe('fit override integration', () => { console.log('\n=== Step 6: Mobile restore via RPC path ===') // Re-fit, then restore via the mobile RPC handler path - runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) + await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20) expect(currentSize).toEqual({ cols: 45, rows: 20 }) // This is what terminal.resizeForClient RPC handler does - const mobileRestore = runtime.resizeForClient('pty-1', 'restore', 'client-phone') + const mobileRestore = await runtime.resizeForClient('pty-1', 'restore', 'client-phone') console.log('Mobile restore result:', mobileRestore) console.log('PTY size after mobile restore:', currentSize) expect(currentSize).toEqual({ cols: 150, rows: 40 }) }) - it('restore resizes PTY even with mounted leaf (the bug fix)', () => { + it('restore resizes PTY even with mounted leaf (the bug fix)', async () => { const runtime = new OrcaRuntimeService(store) let ptySize = { cols: 120, rows: 35 } const resizes: string[] = [] @@ -229,17 +229,17 @@ describe('fit override integration', () => { }) // Mobile fit - runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18) + await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18) expect(ptySize).toEqual({ cols: 42, rows: 18 }) // Restore — THIS is the critical assertion. // Before the fix, mounted leaves skipped the PTY resize. - runtime.resizeForClient('pty-1', 'restore', 'phone-a') + await runtime.resizeForClient('pty-1', 'restore', 'phone-a') expect(ptySize).toEqual({ cols: 120, rows: 35 }) expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35']) }) - it('disconnect auto-restore also resizes PTY', () => { + it('disconnect auto-restore also resizes PTY', async () => { const runtime = new OrcaRuntimeService(store) let ptySize = { cols: 100, rows: 30 } @@ -267,11 +267,13 @@ describe('fit override integration', () => { terminalDriverChanged: vi.fn() }) - runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) + await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20) expect(ptySize).toEqual({ cols: 45, rows: 20 }) // Simulate WS disconnect runtime.onClientDisconnected('phone-disconnect') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + await new Promise((r) => setTimeout(r, 0)) expect(ptySize).toEqual({ cols: 100, rows: 30 }) expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() }) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eea0cb88512..bf474ec8d74 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -110,20 +110,20 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) - it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', () => { + it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', async () => { const { runtime, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) }) - it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', () => { + it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', async () => { const { runtime, driverEvents } = createRuntime() // Pretend a previous take-back put us in desktop mode. runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) // Driver stays idle (the desktop banner was already gone). Phone is // "passively watching" at desktop dims. @@ -131,37 +131,37 @@ describe('mobile presence lock — driver state machine', () => { expect(driverEvents.find((e) => e.driver.kind === 'mobile')).toBeUndefined() }) - it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', () => { + it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', async () => { const { runtime } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) expect(runtime.getDriver('pty-1').kind).toBe('mobile') - expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) // Idempotent — second call is a no-op (no active mobile subscriber to // reclaim from). - expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) }) - it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', () => { + it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) - runtime.reclaimTerminalForDesktop('pty-1') + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') expect(runtime.getDriver('pty-1').kind).toBe('desktop') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) - runtime.mobileTookFloor('pty-1', 'phone-A') + await runtime.mobileTookFloor('pty-1', 'phone-A') expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) // PTY is back at phone dims. expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) - it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', () => { + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { const { runtime, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'phone-A') @@ -170,31 +170,31 @@ describe('mobile presence lock — driver state machine', () => { // doesn't cause a desktop banner flash. expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) - vi.advanceTimersByTime(250) + await vi.advanceTimersByTimeAsync(250) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'idle' }) }) - it('resubscribe within soft-leave grace cancels idle without driver flap', () => { + it('resubscribe within soft-leave grace cancels idle without driver flap', async () => { const { runtime, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'phone-A') expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) // Same client re-subscribes inside the grace window — no idle should // ever be observed by the renderer. - vi.advanceTimersByTime(100) - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) - vi.advanceTimersByTime(500) + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(500) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) expect(driverEvents.find((e) => e.driver.kind === 'idle')).toBeUndefined() }) - it('onPtyExit clears driver state and emits idle', () => { + it('onPtyExit clears driver state and emits idle', async () => { const { runtime, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) runtime.onPtyExit('pty-1', 0) @@ -209,25 +209,25 @@ describe('mobile presence lock — multi-mobile semantics', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) - it('most-recent actor wins active phone-fit dims (B subscribes after A)', () => { + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) // Advance fake clock so B's subscribedAt is strictly greater than A's. - vi.advanceTimersByTime(10) + await vi.advanceTimersByTimeAsync(10) // B's narrower viewport must win when it subscribes. - runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) }) - it('B unsubscribes — A still present, driver re-elects to A', () => { + it('B unsubscribes — A still present, driver re-elects to A', async () => { const { runtime, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) - vi.advanceTimersByTime(10) - runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) runtime.handleMobileUnsubscribe('pty-1', 'phone-B') @@ -235,51 +235,53 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' }) }) - it('A then B unsubscribes — peer survives; final unsubscribe goes idle', () => { + it('A then B unsubscribes — peer survives; final unsubscribe goes idle', async () => { const { runtime } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) - vi.advanceTimersByTime(10) - runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) runtime.handleMobileUnsubscribe('pty-1', 'phone-A') expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) runtime.handleMobileUnsubscribe('pty-1', 'phone-B') // Last leaver enters soft-grace; advance past it before asserting idle. - vi.advanceTimersByTime(250) + await vi.advanceTimersByTimeAsync(250) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) - it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', () => { + it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) // Advance the fake clock so phone-B's subscribe records a strictly // later subscribedAt/lastActedAt — keeps tie-break deterministic. - vi.advanceTimersByTime(10) - runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) // Switch to desktop, then phone-B types — its viewport wins on re-fit. runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) // Simulate B taking the floor by typing (advance again so lastActedAt // is unambiguously the most recent). - vi.advanceTimersByTime(10) - runtime.mobileTookFloor('pty-1', 'phone-B') + await vi.advanceTimersByTimeAsync(10) + await runtime.mobileTookFloor('pty-1', 'phone-B') expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) }) - it('updateMobileViewport re-fits PTY without flipping the driver', () => { + it('updateMobileViewport re-fits PTY without flipping the driver', async () => { const { runtime, ptySizes, driverEvents } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) const before = driverEvents.length // Keyboard opens — viewport shrinks. - expect(runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe(true) + expect(await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe( + true + ) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 16 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) @@ -288,18 +290,20 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) }) - it('updateMobileViewport then disconnect restores PTY to original baseline', () => { + it('updateMobileViewport then disconnect restores PTY to original baseline', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) // Keyboard cycles a few times. - runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) - runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 }) - runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 }) + await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 }) // Phone disconnects (router.back → WS close). runtime.onClientDisconnected('phone-A') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers. + await vi.advanceTimersByTimeAsync(0) // PTY must restore to the original 150x40 baseline, not the last // phone-fit dim. This was the stuck-dim bug. @@ -307,39 +311,40 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) - it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', () => { + it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) // Simulate legacy keyboard re-subscribe cycle within grace. runtime.handleMobileUnsubscribe('pty-1', 'phone-A') - vi.advanceTimersByTime(100) - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 }) + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 }) // Apply mode after re-subscribe so the new viewport drives PTY dims. - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') // PTY at new viewport, phone-A still drives. expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) // Disconnect — must restore to original 150x40, not 49x16. runtime.onClientDisconnected('phone-A') + await vi.advanceTimersByTimeAsync(0) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) - it('earliest-subscribe restore target is preserved when peers churn', () => { + it('earliest-subscribe restore target is preserved when peers churn', async () => { const { runtime, ptySizes } = createRuntime() // A captures the original 150x40 baseline. - runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) // B subscribes later at 38x18 (advance clock for unambiguous ordering). - vi.advanceTimersByTime(10) - runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await vi.advanceTimersByTimeAsync(10) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) // A leaves, B leaves — final restore must use A's earliest baseline (150x40), // NOT B's (which captured 45x20 when it joined a phone-fitted PTY). runtime.handleMobileUnsubscribe('pty-1', 'phone-A') runtime.handleMobileUnsubscribe('pty-1', 'phone-B') - vi.advanceTimersByTime(300) + await vi.advanceTimersByTimeAsync(300) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index fd344c61511..5c6d7a895e7 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -118,10 +118,13 @@ describe('mobile subscribe integration', () => { vi.useRealTimers() }) - it('handleMobileSubscribe resizes PTY to phone dims', () => { + it('handleMobileSubscribe resizes PTY to phone dims', async () => { const { runtime, ptySizes, resizes, notifications } = createRuntime() - const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', { + cols: 45, + rows: 20 + }) expect(result).toBe(true) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) @@ -130,83 +133,88 @@ describe('mobile subscribe integration', () => { expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true) }) - it('handleMobileSubscribe skips resize when mode is desktop', () => { + it('handleMobileSubscribe skips resize when mode is desktop', async () => { const { runtime, ptySizes, resizes } = createRuntime() runtime.setMobileDisplayMode('pty-1', 'desktop') - const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', { + cols: 45, + rows: 20 + }) expect(result).toBe(false) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(resizes).toEqual([]) }) - it('handleMobileSubscribe skips resize when no viewport provided', () => { + it('handleMobileSubscribe skips resize when no viewport provided', async () => { const { runtime, ptySizes, resizes } = createRuntime() - const result = runtime.handleMobileSubscribe('pty-1', 'client-a') + const result = await runtime.handleMobileSubscribe('pty-1', 'client-a') expect(result).toBe(false) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(resizes).toEqual([]) }) - it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => { + it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') // Not yet restored expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) - vi.advanceTimersByTime(300) + await vi.advanceTimersByTimeAsync(300) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) - it('handleMobileUnsubscribe does not restore in phone mode', () => { + // Why: 'phone' (sticky-fit) mode was removed — there are now only 'auto' + // and 'desktop'. Auto-mode always restores on last unsubscribe. Test + // kept and inverted to lock in the new contract. + it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => { const { runtime, ptySizes } = createRuntime() - runtime.setMobileDisplayMode('pty-1', 'phone') - // In phone mode, handleMobileSubscribe still resizes because mode is 'phone' - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + // mode defaults to 'auto' + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') - vi.advanceTimersByTime(1000) - // Still at phone dims — no restore for phone mode - expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(1000) + // Restored to desktop dims — no sticky-phone retention. + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) // TODO: inline restore on re-subscribe not yet implemented - it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => { + it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') // Re-subscribe to a different terminal before the timer fires - vi.advanceTimersByTime(100) - runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + await vi.advanceTimersByTimeAsync(100) + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) // pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 }) // Advancing past the 300ms debounce should not cause a second restore - vi.advanceTimersByTime(300) + await vi.advanceTimersByTimeAsync(300) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) // TODO: inline restore on re-subscribe not yet implemented - it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => { + it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', async () => { const { runtime, ptySizes } = createRuntime() // Subscribe to A - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) // Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore runtime.handleMobileUnsubscribe('pty-1', 'client-a') - runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) // pty-1 should be restored inline (not waiting for timer) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) @@ -214,41 +222,41 @@ describe('mobile subscribe integration', () => { // Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore runtime.handleMobileUnsubscribe('pty-2', 'client-a') - runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) // Verify final state - vi.advanceTimersByTime(1000) + await vi.advanceTimersByTimeAsync(1000) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 }) expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 }) }) - it('preserves previousDims across re-subscribes to same terminal', () => { + it('preserves previousDims across re-subscribes to same terminal', async () => { const { runtime, ptySizes } = createRuntime() // First subscribe at desktop 150x40 - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) // Re-subscribe to the same terminal (e.g., after reconnect) // The PTY is already at 45x20, but previousDims should still be 150x40 - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) // Unsubscribe and let restore fire runtime.handleMobileUnsubscribe('pty-1', 'client-a') - vi.advanceTimersByTime(300) + await vi.advanceTimersByTimeAsync(300) // Should restore to original desktop dims, not 45x20 expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) - it('clamps viewport to valid range', () => { + it('clamps viewport to valid range', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 }) // Should clamp to minimum 20x8 expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 }) }) @@ -261,9 +269,6 @@ describe('mobile subscribe integration', () => { it('set/get round-trip', () => { const { runtime } = createRuntime() - runtime.setMobileDisplayMode('pty-1', 'phone') - expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone') - runtime.setMobileDisplayMode('pty-1', 'desktop') expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop') @@ -274,16 +279,16 @@ describe('mobile subscribe integration', () => { }) describe('applyMobileDisplayMode', () => { - it('desktop mode restores PTY when currently phone-fitted', () => { + it('desktop mode restores PTY when currently phone-fitted', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) const resizeEvents: unknown[] = [] runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event)) runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(resizeEvents).toHaveLength(1) @@ -291,17 +296,17 @@ describe('mobile subscribe integration', () => { cols: 150, rows: 40, displayMode: 'desktop', - reason: 'mode-change' + reason: 'apply-layout' }) }) - it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => { + it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) // Switch to desktop (restores to 150x40) runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) const resizeEvents: unknown[] = [] @@ -309,25 +314,27 @@ describe('mobile subscribe integration', () => { // Switch back to auto (should re-fit to phone dims) runtime.setMobileDisplayMode('pty-1', 'auto') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) expect(resizeEvents).toHaveLength(1) expect(resizeEvents[0]).toMatchObject({ - displayMode: 'auto', - reason: 'mode-change' + displayMode: 'phone', + reason: 'apply-layout' }) }) }) describe('cleanup paths', () => { - it('onClientDisconnected restores all PTYs immediately (no debounce)', () => { + it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) - runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 }) runtime.onClientDisconnected('client-a') + // onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers. + await vi.advanceTimersByTimeAsync(0) // Both PTYs restored immediately expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) @@ -336,22 +343,22 @@ describe('mobile subscribe integration', () => { expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false) }) - it('onClientDisconnected cancels pending restore timers', () => { + it('onClientDisconnected cancels pending restore timers', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') // Timer is pending runtime.onClientDisconnected('client-a') // Timer should be cancelled, PTY already restored by disconnect handler - vi.advanceTimersByTime(1000) + await vi.advanceTimersByTimeAsync(1000) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) - it('onPtyExit cleans up mobileSubscribers and pending timers', () => { + it('onPtyExit cleans up mobileSubscribers and pending timers', async () => { const { runtime } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') // Timer pending for pty-1 @@ -360,31 +367,31 @@ describe('mobile subscribe integration', () => { expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') // Timer should have been cancelled — no crash from resizing a dead PTY - vi.advanceTimersByTime(1000) + await vi.advanceTimersByTimeAsync(1000) }) - it('onPtyExit does not cancel timers for other PTYs', () => { + it('onPtyExit does not cancel timers for other PTYs', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') // pty-2 exits — should not affect pty-1's pending restore runtime.onPtyExit('pty-2', 0) - vi.advanceTimersByTime(300) + await vi.advanceTimersByTimeAsync(300) expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) }) describe('resize listener system', () => { - it('subscribe/unsubscribe lifecycle', () => { + it('subscribe/unsubscribe lifecycle', async () => { const { runtime } = createRuntime() const events: unknown[] = [] const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e)) - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(events.length).toBeGreaterThan(0) @@ -393,36 +400,41 @@ describe('mobile subscribe integration', () => { // After unsubscribe, no more events runtime.setMobileDisplayMode('pty-1', 'auto') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(events.length).toBe(countBefore) }) }) describe('onExternalPtyResize', () => { - it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => { + it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => { const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) // Toggle to desktop — restores to previousCols (150x40) runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + // Phone→desktop arms the 500ms renderer-cascade suppress window per + // docs/mobile-terminal-layout-state-machine.md. Wait it out before the + // renderer's correcting fit is allowed to update lastRendererSizes. + await vi.advanceTimersByTimeAsync(500) + // Simulate desktop renderer's safeFit correcting to split-pane width runtime.onExternalPtyResize('pty-1', 105, 40) // Toggle back to auto — should capture previousCols=105 (not 150) runtime.setMobileDisplayMode('pty-1', 'auto') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) // Toggle to desktop again — should restore to 105 (the corrected value) runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) }) - it('uses lastRendererSize for previousCols on first subscribe', () => { + it('uses lastRendererSize for previousCols on first subscribe', async () => { const { runtime, ptySizes } = createRuntime() // Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105 @@ -430,41 +442,46 @@ describe('mobile subscribe integration', () => { runtime.onExternalPtyResize('pty-1', 105, 40) // First mobile subscribe — should use rendererSize (105) not ptySizes (214) - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) // Toggle to desktop — should restore to 105, not 214 runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') + await runtime.applyMobileDisplayMode('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 }) }) - it('does not update previousCols when PTY is phone-fitted', () => { + it('refreshes baseline on phone-fitted subscribers when their baseline is non-null', async () => { + // Behavior change per docs/mobile-terminal-layout-state-machine.md: + // legacy `!wasResizedToPhone` gate is replaced with `previousCols != null`. + // A phone-fitted subscriber's baseline IS non-null (captured at subscribe), + // so onExternalPtyResize now overwrites it with the renderer's reported geometry. const { runtime, ptySizes } = createRuntime() - runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) - // PTY is phone-fitted (wasResizedToPhone=true) — external resize should not - // overwrite previousCols with phone dims + // Renderer reports 45x20 — under the new design, this overwrites baseline + // (previously it was skipped because the subscriber was phone-fitted). runtime.onExternalPtyResize('pty-1', 45, 20) - // Toggle to desktop — should still restore to original 150x40 + // Toggle to desktop — restore lands on what the renderer reported (45x20), + // not the original 150x40. runtime.setMobileDisplayMode('pty-1', 'desktop') - runtime.applyMobileDisplayMode('pty-1') - expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + await runtime.applyMobileDisplayMode('pty-1') + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) }) describe('backward compatibility', () => { - it('old resizeForClient still works alongside new system', () => { + it('old resizeForClient still works alongside new system', async () => { const { runtime, ptySizes } = createRuntime() // Old flow: explicit resizeForClient - const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20) + const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20) expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) expect(fitResult.mode).toBe('mobile-fit') // Old flow: restore - const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old') + const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) expect(restoreResult.mode).toBe('desktop-fit') }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a61d0498ef2..0866b4b4f4e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -344,6 +344,39 @@ export type DriverState = | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } +// Why: per-PTY layout target — what the PTY *should* be at right now. +// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive +// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at +// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. +// See docs/mobile-terminal-layout-state-machine.md. +export type PtyLayoutTarget = + | { kind: 'desktop'; cols: number; rows: number } + | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + +// Why: authoritative layout state with monotonic seq. Bumped on every +// applyLayout success; emitted on mobile subscribe-stream events so clients +// drop stale events that arrive after a newer transition. +export type PtyLayoutState = PtyLayoutTarget & { + seq: number + appliedAt: number +} + +// Why: applyLayout result discriminator. Callers (especially RPC handlers) +// need to distinguish "shipped a new state at seq N" from "no-op — caller +// should not claim a seq it didn't produce." `pty-exited` is terminal; +// `resize-failed` is transient and the caller may retry. +export type ApplyLayoutResult = + | { ok: true; state: PtyLayoutState } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } + +type LayoutQueueEntry = { + running: Promise | null + pending: { + target: PtyLayoutTarget + waiters: ((r: ApplyLayoutResult) => void)[] + }[] +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -413,10 +446,13 @@ export class OrcaRuntimeService { } >() - // Why: server-authoritative display mode per terminal. 'auto' (default) means - // phone-fit when mobile subscribes, desktop otherwise. 'phone'/'desktop' lock - // the mode regardless of subscriber state. In-memory only — modes reset on restart. - private mobileDisplayModes = new Map() + // Why: server-authoritative display mode per terminal. 'auto' (default) + // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' + // locks to no-resize regardless of subscriber state. The third historical + // value ('phone' = sticky phone-fit after unsubscribe) was removed since + // the toggle UI never produced it and nothing in product depended on it. + // In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() // Why: tracks active mobile subscribers per PTY so the runtime can restore // desktop dimensions on unsubscribe and prevent orphaned overrides during @@ -514,11 +550,46 @@ export class OrcaRuntimeService { // Why: inline resize events replace the unsubscribe→resubscribe pattern. // Listeners are notified when mode changes or desktop restores, allowing // the subscribe stream to emit a 'resized' event with fresh scrollback. + // `seq` is the layout state-machine sequence number bumped on every + // applyLayout success; mobile clients use it to drop stale events that + // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. private resizeListeners = new Map< string, - Set<(event: { cols: number; rows: number; displayMode: string; reason: string }) => void> + Set< + (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + > >() + // Why: per-PTY layout state machine. `applyLayout` is the sole writer of + // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every + // trigger method routes through `enqueueLayout`. The monotonic `seq` is + // emitted on the mobile subscribe stream so clients can drop stale events. + // See docs/mobile-terminal-layout-state-machine.md. + private layouts = new Map() + + // Why: per-PTY async serialization queue for applyLayout. Without + // serialization, two concurrent triggers can interleave around the + // ptyController.resize await and bump seq in the wrong order, defeating + // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the + // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, + // take-floor, and different-owner targets always append (preserves + // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md + // "enqueueLayout coalescing". + private layoutQueues = new Map() + + // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't + // fire on the very first transition for a PTY (where the entry doesn't + // exist yet *because* we're about to create it). `handleMobileSubscribe` + // adds the ptyId before calling enqueueLayout and removes it after the + // call resolves. + private freshSubscribeGuard = new Set() + private stats: StatsCollector | null = null // Why (§3.3 + §7.1): the renderer-create path and coordinator // `probeWorktreeDrift` share this cache so a create that already fetched @@ -1312,19 +1383,23 @@ export class OrcaRuntimeService { // ─── Mobile Fit Override Management ───────────────────────── - resizeForClient( + // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this + // is a thin shim that computes a `PtyLayoutTarget` and routes through + // `enqueueLayout`. Keeps the same observable return shape so older mobile + // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. + async resizeForClient( ptyId: string, mode: 'mobile-fit' | 'restore', clientId: string, cols?: number, rows?: number - ): { + ): Promise<{ cols: number rows: number previousCols: number | null previousRows: number | null mode: 'mobile-fit' | 'desktop-fit' - } { + }> { if (mode === 'mobile-fit') { if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { throw new Error('invalid_dimensions') @@ -1334,34 +1409,44 @@ export class OrcaRuntimeService { const currentSize = this.getTerminalSize(ptyId) const existing = this.terminalFitOverrides.get(ptyId) - // Why: preserve the original desktop size from before any mobile-fit, - // so restore returns to the right dimensions even after multiple re-fits. + // Capture baseline cols/rows for the return value (existing override's + // baseline wins over current size to preserve original desktop dims + // across multiple re-fits). const previousCols = existing?.previousCols ?? currentSize?.cols ?? null const previousRows = existing?.previousRows ?? currentSize?.rows ?? null - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', - cols: clampedCols, - rows: clampedRows, - previousCols, - previousRows, - updatedAt: Date.now(), - clientId - }) + // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so + // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 + // (per-subscriber baseline) never matches. Stash the pre-fit PTY size + // into lastRendererSizes so restore lands on step 2 (renderer geometry) + // instead of step 3 (current phone-fit dims = no-op restore). + if (currentSize && !existing) { + this.lastRendererSizes.set(ptyId, { + cols: currentSize.cols, + rows: currentSize.rows + }) + } - const resized = this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - if (!resized) { - this.terminalFitOverrides.delete(ptyId) + this.freshSubscribeGuard.add(ptyId) + let result: ApplyLayoutResult + try { + result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + if (!result.ok) { throw new Error('resize_failed') } - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) // Why: mobile-fit via resizeForClient is a deliberate mobile action; - // the actor takes the floor. mobileTookFloor updates the actor's - // lastActedAt and re-applies phone-fit if previously in desktop mode. - this.mobileTookFloor(ptyId, clientId) + // the actor takes the floor (updates lastActedAt; mode-flip case is + // already handled by enqueueLayout above). + await this.mobileTookFloor(ptyId, clientId) return { cols: clampedCols, @@ -1377,39 +1462,30 @@ export class OrcaRuntimeService { if (!override) { throw new Error('no_active_override') } - // Why: only the owning client can restore, preventing one phone from - // undoing another phone's active fit. + // Only the owning client can restore — prevents one phone from undoing + // another phone's active fit. if (override.clientId !== clientId) { throw new Error('not_override_owner') } - const { previousCols: prevCols, previousRows: prevRows } = override - this.terminalFitOverrides.delete(ptyId) - - // Why: always resize the PTY back to pre-fit dimensions immediately, - // even for mounted leaves. Relying solely on the renderer chain - // (IPC notification → safeFit → fitAddon.fit → onResize → transport.resize) - // is fragile — any async gap leaves the PTY at phone dims while xterm - // looks correct, causing text to wrap at the wrong column. The renderer - // will still run safeFit and may send a second resize with the exact - // current pane geometry, which is harmless (SIGWINCH is idempotent). - if (prevCols != null && prevRows != null) { - this.ptyController?.resize?.(ptyId, prevCols, prevRows) - this.resizeHeadlessTerminal(ptyId, prevCols, prevRows) + const restore = this.resolveDesktopRestoreTarget(ptyId) + const result = await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + if (!result.ok) { + throw new Error('resize_failed') } - // Why: send the restored dimensions so the renderer can fall back to a - // direct terminal.resize() if fitAddon.fit() silently fails. The renderer - // normally computes desktop dims from the container, but passing them here - // provides a guaranteed fallback to avoid leaving xterm at phone dims. - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) - // Why: mobile clients subscribed to this terminal need to know the desktop - // restored, so they can update their UI (clear fitted state, resubscribe). - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + // Why: legacy mobile clients on the resizeForClient path also need a + // fit-override-listener notification (the renderer-side terminalFitOverrideChanged + // is already emitted by applyLayout's mode-flip path). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) return { - cols: prevCols ?? 0, - rows: prevRows ?? 0, + cols: restore.cols, + rows: restore.rows, previousCols: null, previousRows: null, mode: 'desktop-fit' @@ -1429,23 +1505,24 @@ export class OrcaRuntimeService { } onClientDisconnected(clientId: string): void { - // Cancel all pending restore timers for this client — the client is gone, - // so the debounce is meaningless and could fire against a stale PTY state. + // (1) Cancel pending restore-debounce timers owned by this client. for (const [ptyId, entry] of this.pendingRestoreTimers) { if (entry.clientId === clientId) { clearTimeout(entry.timer) this.pendingRestoreTimers.delete(ptyId) } } - // Why: if the disconnecting client was in soft-leave grace, the grace - // is meaningless now (the client is gone for real). Promote each - // matching grace into immediate finalization: restore PTY dims to the - // captured baseline, drop driver to idle, and clear fit overrides. - // Without this, a phone that exited the screen (router.back → WS - // close) would leave the PTY stuck at phone dims forever — the soft - // grace held the inner-map empty so the mobileSubscribers loop below - // can't see it, and the 300ms restore timer could mis-fire after the - // grace if the PTY had already been mutated. + + // (2) Promote any soft-leave grace owned by this client into immediate + // finalization. Grace existed to absorb a quick re-subscribe; a real + // disconnect kills any chance of re-subscribe. + // + // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md + // sub-case 2). Today's pre-rewrite code only restored when + // `mode === 'auto' && wasResizedToPhone`; the new design restores + // whenever the layout is currently `phone`. This is an intentional + // behavior fix — `mode === 'phone'` with no subscribers is a degenerate + // state nothing in product depends on. for (const [ptyId, soft] of this.pendingSoftLeavers) { if (soft.clientId !== clientId) { continue @@ -1453,94 +1530,112 @@ export class OrcaRuntimeService { clearTimeout(soft.timer) this.pendingSoftLeavers.delete(ptyId) - // Cancel any in-flight 300ms restore timer too — we'll do it now. + // Cancel any in-flight 300ms restore timer too — we'll handle it inline. const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { clearTimeout(pending.timer) this.pendingRestoreTimers.delete(ptyId) } - const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' - const { previousCols, previousRows, wasResizedToPhone } = soft.record - if (mode === 'auto' && wasResizedToPhone) { - const fallback = this.lastRendererSizes.get(ptyId) - const cols = previousCols ?? fallback?.cols ?? null - const rows = previousRows ?? fallback?.rows ?? null - if (cols != null && rows != null) { - this.ptyController?.resize?.(ptyId, cols, rows) - this.resizeHeadlessTerminal(ptyId, cols, rows) - } - this.lastRendererSizes.delete(ptyId) - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + // Use the soft-leaver's snapshot baseline as a hint, falling + // through to resolveDesktopRestoreTarget for missing values. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = soft.record.previousCols ?? fallback.cols + const rows = soft.record.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } - // Immediately restore PTYs that this client had phone-fitted (no debounce — - // client is gone, no point waiting for a re-subscribe that won't come). - // With the multi-mobile rekey, only the disconnecting client's record is - // removed from the inner map; peer mobile clients keep the floor and the - // banner stays mounted. + // (3) Immediate restore for PTYs where this client was the last + // mobile subscriber. With multi-mobile, peer subscribers keep the + // floor; only when the inner map empties do we transition to desktop. const ptysWithSurvivingPeers: string[] = [] + const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] for (const [ptyId, inner] of this.mobileSubscribers) { const subscriber = inner.get(clientId) if (!subscriber) { continue } - const wasResizedToPhone = subscriber.wasResizedToPhone - const { previousCols, previousRows } = subscriber + // Snapshot baseline before deleting — needed once mobileSubscribers + // entry is gone for the resolveDesktopRestoreTarget chain. + const baseline = + subscriber.previousCols != null && subscriber.previousRows != null + ? { cols: subscriber.previousCols, rows: subscriber.previousRows } + : null inner.delete(clientId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) - continue + } else { + this.mobileSubscribers.delete(ptyId) + ptysToRestore.push({ ptyId, baseline }) } - this.mobileSubscribers.delete(ptyId) - if (wasResizedToPhone) { - if (previousCols != null && previousRows != null) { - this.ptyController?.resize?.(ptyId, previousCols, previousRows) - this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) - } - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - previousCols ?? 0, - previousRows ?? 0 - ) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + } + for (const { ptyId, baseline } of ptysToRestore) { + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = baseline?.cols ?? fallback.cols + const rows = baseline?.rows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } - // Why: if peers survived but the disconnecting client was the active - // driver, re-elect the most-recent surviving subscriber as the driver - // and re-fit if needed. This keeps the lock/dim-selection invariant. + + // (4) Driver re-election where peers survived. If the disconnecting + // client was the active driver, the most-recent surviving actor takes + // the floor. for (const ptyId of ptysWithSurvivingPeers) { const driver = this.getDriver(ptyId) - if (driver.kind === 'mobile' && driver.clientId === clientId) { - const inner = this.mobileSubscribers.get(ptyId) - const next = inner ? this.pickMostRecentActor(inner) : null - if (next) { - this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - this.applyMobileDisplayMode(ptyId) - } + if (driver.kind !== 'mobile' || driver.clientId !== clientId) { + continue } + const inner = this.mobileSubscribers.get(ptyId) + const next = inner ? this.pickMostRecentActor(inner) : null + if (!next) { + continue + } + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') { + continue + } + const nextSub = inner!.get(next.clientId) + const nextViewport = nextSub?.viewport + if (!nextViewport) { + continue + } + void this.enqueueLayout(ptyId, { + kind: 'phone', + cols: nextViewport.cols, + rows: nextViewport.rows, + ownerClientId: next.clientId + }) } - // Legacy cleanup for any terminalFitOverrides not covered by mobileSubscribers + // (5) Legacy-callers fallback. Older mobile builds use resizeForClient + // directly and never populate mobileSubscribers. For those PTYs the + // override carries the owning clientId; restore the layout when the + // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes + // (which the legacy mobile-fit branch stashes the pre-fit size into). for (const [ptyId, override] of this.terminalFitOverrides) { if (override.clientId !== clientId) { continue } - try { - this.resizeForClient(ptyId, 'restore', clientId) - } catch { - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + if (this.mobileSubscribers.has(ptyId)) { + continue } + const cur = this.layouts.get(ptyId) + if (cur?.kind !== 'phone') { + continue + } + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = override.previousCols ?? fallback.cols + const rows = override.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } } @@ -1550,6 +1645,13 @@ export class OrcaRuntimeService { this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) + // Layout state machine: clear `layouts` and `layoutQueues`. Any + // already-queued applyLayout work for this ptyId will run, but every + // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and + // short-circuits with `pty-exited`. + this.layouts.delete(ptyId) + this.layoutQueues.delete(ptyId) + this.freshSubscribeGuard.delete(ptyId) const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) @@ -1621,11 +1723,11 @@ export class OrcaRuntimeService { } // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / - // resizeForClient / fresh subscribe with auto/phone). Records the actor as - // the most recent mobile driver and re-applies phone-fit if we were previously + // resizeForClient / fresh subscribe with auto). Records the actor as the + // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - mobileTookFloor(ptyId: string, clientId: string): void { + async mobileTookFloor(ptyId: string, clientId: string): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (sub) { @@ -1635,17 +1737,13 @@ export class OrcaRuntimeService { const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier - // take-back), flip it back to 'auto' and re-apply so phone-fit takes - // hold again. Without flipping the mode, applyMobileDisplayMode would - // take the desktop branch and leave the PTY at desktop dims while the - // driver says `mobile`. The same path also covers the case where the - // driver flipped to `desktop` and we're returning to mobile control. - // See docs/mobile-presence-lock.md. + // take-back), flip it back to 'auto' (= map absence) and re-apply so + // phone-fit takes hold again. See docs/mobile-presence-lock.md. if (prev.kind === 'desktop' || currentMode === 'desktop') { - if (currentMode === 'desktop' || currentMode === undefined) { - this.mobileDisplayModes.set(ptyId, 'auto') + if (currentMode === 'desktop') { + this.mobileDisplayModes.delete(ptyId) } - this.applyMobileDisplayMode(ptyId) + await this.applyMobileDisplayMode(ptyId) } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -1660,11 +1758,11 @@ export class OrcaRuntimeService { // subscribe to capture the already-phone-fitted PTY size as its // restore baseline (stuck-dim bug on later disconnect). // No-op when the client isn't actually subscribed to this PTY. - updateMobileViewport( + async updateMobileViewport( ptyId: string, clientId: string, viewport: { cols: number; rows: number } - ): boolean { + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (!sub) { @@ -1688,39 +1786,17 @@ export class OrcaRuntimeService { const clampedCols = Math.max(20, Math.min(240, Math.round(driveViewport.cols))) const clampedRows = Math.max(8, Math.min(120, Math.round(driveViewport.rows))) - const currentSize = this.getTerminalSize(ptyId) - const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows - if (!alreadyAtTarget) { - this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - } - sub.wasResizedToPhone = true - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', - cols: clampedCols, - rows: clampedRows, - previousCols: sub.previousCols, - previousRows: sub.previousRows, - updatedAt: Date.now(), - clientId - }) - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) - - // Why: emit a 'resized' event on the mobile subscription stream so the - // mobile xterm reinits inline at the new dims — same shape as a - // setDisplayMode-triggered resize, so the existing client-side handler - // path applies without changes. - this.notifyTerminalResize(ptyId, { - cols: clampedCols, - rows: clampedRows, - displayMode: mode, - reason: 'viewport-update' - }) - - // The driver is already mobile{this client} when we got here; refresh it + // The driver is already mobile{this client} when we got here; refresh // to update lastActedAt-based ordering on later actor selection. this.setDriver(ptyId, { kind: 'mobile', clientId }) + + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: winner.clientId + }) return true } @@ -1728,12 +1804,12 @@ export class OrcaRuntimeService { // back" button). Forces the PTY back to desktop dims and flips the driver // to `desktop`, suppressing further mobile-driven dim changes until a // mobile actor takes the floor again. - reclaimTerminalForDesktop(ptyId: string): boolean { + async reclaimTerminalForDesktop(ptyId: string): Promise { if (!this.isMobileSubscriberActive(ptyId)) { return false } this.setMobileDisplayMode(ptyId, 'desktop') - this.applyMobileDisplayMode(ptyId) + await this.applyMobileDisplayMode(ptyId) this.setDriver(ptyId, { kind: 'desktop' }) return true } @@ -1779,9 +1855,249 @@ export class OrcaRuntimeService { return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null } + // ─── Layout state machine ───────────────────────────────────────── + // + // See docs/mobile-terminal-layout-state-machine.md. + // + // applyLayout is the SOLE writer of: + // - this.layouts + // - this.terminalFitOverrides + // - this.ptyController.resize (i.e. the actual PTY dims) + // + // Every trigger that wants to change PTY dims or flip mode goes through + // enqueueLayout, which serializes calls behind a per-PTY async queue + // (the await on ptyController.resize would otherwise let seq bumps reach + // the wire out of order). + + getLayout(ptyId: string): PtyLayoutState | null { + return this.layouts.get(ptyId) ?? null + } + + // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire + // on the very first transition for a PTY (where the entry doesn't exist + // yet *because* we're about to create it). handleMobileSubscribe adds + // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and + // removes it in a finally block. + private isFreshSubscribe(ptyId: string): boolean { + return this.freshSubscribeGuard.has(ptyId) + } + + // Why: four-step fallback chain for desktop-restore targets. Always + // returns a value; the terminal {80,24} branch is reached only under + // bug. Wrapping the chain as a single helper prevents callsite drift. + private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { + // 1. Earliest-by-subscribedAt subscriber with non-null baseline. + const inner = this.mobileSubscribers.get(ptyId) + if (inner) { + const earliest = this.pickEarliestRestoreTarget(inner) + if (earliest) { + return { cols: earliest.previousCols, rows: earliest.previousRows } + } + } + // 2. Most-recent desktop renderer geometry report. + const renderer = this.lastRendererSizes.get(ptyId) + if (renderer) { + return { cols: renderer.cols, rows: renderer.rows } + } + // 3. Current PTY size. + const size = this.getTerminalSize(ptyId) + if (size) { + return { cols: size.cols, rows: size.rows } + } + // 4. Hard default. + return { cols: 80, rows: 24 } + } + + // Why: a new viewport-only update from the same owner supersedes a + // queued same-shape tail. Mode flips, owner changes, and take-back + // append (losing a take-floor to a viewport tick would be a fairness + // hole — see "enqueueLayout coalescing" in the design doc). + private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { + if (prev.kind !== next.kind) { + return false + } + if (prev.kind === 'phone' && next.kind === 'phone') { + return prev.ownerClientId === next.ownerClientId + } + return true + } + + private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first + // transition through even though `layouts` has no entry yet. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return Promise.resolve({ ok: false, reason: 'pty-exited' }) + } + + let entry = this.layoutQueues.get(ptyId) + if (!entry) { + entry = { running: null, pending: [] } + this.layoutQueues.set(ptyId, entry) + } + const queue = entry + + return new Promise((resolve) => { + if (!queue.running) { + queue.running = this.runLayoutSlot(ptyId, target, [resolve]) + return + } + const tail = queue.pending.at(-1) + if (tail && this.coalescesWith(tail.target, target)) { + tail.target = target + tail.waiters.push(resolve) + return + } + queue.pending.push({ target, waiters: [resolve] }) + }) + } + + private async runLayoutSlot( + ptyId: string, + target: PtyLayoutTarget, + waiters: ((r: ApplyLayoutResult) => void)[] + ): Promise { + let result: ApplyLayoutResult + try { + result = await this.applyLayout(ptyId, target) + } catch (err) { + // Why: defensive — applyLayout itself catches resize errors, but a + // throw from one of the synchronous map writes (e.g. notifier hook) + // must not jam the queue forever. + console.error('[layout] applyLayout threw', { ptyId, err }) + result = { ok: false, reason: 'resize-failed' } + } + for (const w of waiters) { + w(result) + } + + const queue = this.layoutQueues.get(ptyId) + if (!queue) { + return result + } + const next = queue.pending.shift() + if (next) { + queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + } else { + queue.running = null + // Why: drop the entry once empty so the map doesn't grow without bound + // across short-lived PTYs. + this.layoutQueues.delete(ptyId) + } + return result + } + + private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: re-check pty-exit at the head of the slot — the queue may have + // accepted this target before onPtyExit ran. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return { ok: false, reason: 'pty-exited' } + } + + const prev = this.layouts.get(ptyId) ?? null + const seq = (prev?.seq ?? 0) + 1 + const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } + + const currentSize = this.getTerminalSize(ptyId) + const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows + const modeChanged = (prev?.kind ?? 'desktop') !== target.kind + + // Snapshot for rollback. + const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null + + // Tentative writes — the resize is the point of no return. + this.layouts.set(ptyId, next) + if (target.kind === 'phone') { + // Why: pull baseline cols+rows atomically from the same subscriber so + // they can't desync. + const baseline = (() => { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return null + } + return this.pickEarliestRestoreTarget(inner) + })() + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: target.cols, + rows: target.rows, + previousCols: baseline?.previousCols ?? null, + previousRows: baseline?.previousRows ?? null, + updatedAt: next.appliedAt, + clientId: target.ownerClientId + }) + } else { + this.terminalFitOverrides.delete(ptyId) + } + + if (dimsChanged) { + let ok = false + try { + const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) + ok = r ?? true + } catch (err) { + console.error('[layout] ptyController.resize threw', { ptyId, err }) + ok = false + } + if (!ok) { + // Roll back to pre-call snapshot. seq is NOT bumped on the wire + // because we never emit below. + if (prev) { + this.layouts.set(ptyId, prev) + } else { + this.layouts.delete(ptyId) + } + if (prevFitOverride) { + this.terminalFitOverrides.set(ptyId, prevFitOverride) + } else { + this.terminalFitOverrides.delete(ptyId) + } + return { ok: false, reason: 'resize-failed' } + } + this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) + } + + // Why: emit fit-override-changed only when the *mode* flips. Layouts + // can change dims without flipping mode (keyboard show/hide while + // phone), and waking the renderer on every viewport tick is wasteful + // churn. + if (modeChanged) { + // Why: phone→desktop arms the renderer-cascade suppress window + // before the collateral safeFit IPCs arrive. See "Renderer cascade + // suppression". + if (target.kind === 'desktop') { + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + } + this.notifier?.terminalFitOverrideChanged( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + this.notifyFitOverrideListeners( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + } + + // Mobile-facing event always fires (phone clients need to re-fit on + // every dim change, not just mode flips). + this.notifyTerminalResize(ptyId, { + cols: target.cols, + rows: target.rows, + displayMode: target.kind === 'phone' ? 'phone' : 'desktop', + reason: 'apply-layout', + seq + }) + + return { ok: true, state: next } + } + // ─── Server-Authoritative Mobile Display Mode ───────────────────── - setMobileDisplayMode(ptyId: string, mode: 'auto' | 'phone' | 'desktop'): void { + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { if (mode === 'auto') { this.mobileDisplayModes.delete(ptyId) } else { @@ -1789,7 +2105,7 @@ export class OrcaRuntimeService { } } - getMobileDisplayMode(ptyId: string): 'auto' | 'phone' | 'desktop' { + getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { return this.mobileDisplayModes.get(ptyId) ?? 'auto' } @@ -1798,6 +2114,33 @@ export class OrcaRuntimeService { return inner !== undefined && inner.size > 0 } + // Why: late-bind viewport on an existing subscriber record. Subscribers + // that registered before the mobile side measured (e.g. terminal first + // mounted while the WebView was still loading) have null viewport, and + // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. + // The setDisplayMode RPC carries the latest viewport so we can patch it + // here just before applyMobileDisplayMode runs. + updateMobileSubscriberViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): void { + const inner = this.mobileSubscribers.get(ptyId) + const record = inner?.get(clientId) + console.log('[fit][server] updateMobileSubscriberViewport', { + ptyId: ptyId.slice(-8), + clientId: clientId.slice(-8), + viewport, + hasInner: !!inner, + innerSize: inner?.size ?? 0, + hasRecord: !!record + }) + if (!record) { + return + } + record.viewport = viewport + } + // Why: server-side auto-fit on mobile subscribe. The runtime is the single // source of truth — the mobile client just passes its viewport and the runtime // decides whether to resize. This eliminates the measure→RPC→resubscribe @@ -1811,32 +2154,40 @@ export class OrcaRuntimeService { // a passive watch; it does NOT take the floor. The driver remains // `idle`/`desktop`. The lock banner is reserved for actual mobile // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). - handleMobileSubscribe( + async handleMobileSubscribe( ptyId: string, clientId: string, viewport?: { cols: number; rows: number } - ): boolean { + ): Promise { const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const currentSize0 = this.getTerminalSize(ptyId) + console.log('[fit][server] handleMobileSubscribe', { + ptyId: ptyId.slice(-8), + clientId: clientId.slice(-8), + viewport, + mode, + currentSize: currentSize0, + hadInner: this.mobileSubscribers.has(ptyId) + }) if (!viewport) { + console.log('[fit][server] handleMobileSubscribe NO_VIEWPORT — skipping fit') return false } - // Why: cancel ALL pending restore timers for this ptyId on any new - // subscribe — the timer is keyed by ptyId+old-clientId but with the - // multi-mobile rekey, "any new subscriber" supersedes "any old client's - // restore". Without this, A unsub → B sub within 300ms could fire A's - // timer and snap PTY to desktop dims while B is meant to drive. + // Cancel pending restore timer for this ptyId — any new subscriber + // supersedes any old client's pending restore. const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) this.pendingRestoreTimers.delete(ptyId) } - // Why: resubscribe-grace honor. If the same client just unsubscribed - // within the soft-leave window, restore its prior record (preserving - // previousCols/Rows so we don't capture an already-phone-fitted PTY - // size as the new baseline). The driver state was kept at - // mobile{clientId} during the window, so no banner flash occurred. + const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + + // Resubscribe-grace honor: same client returning within soft-leave + // window restores prior record (preserving baseline so we don't capture + // phone-fitted dims as the new baseline). const softLeaver = this.pendingSoftLeavers.get(ptyId) if (softLeaver && softLeaver.clientId === clientId) { clearTimeout(softLeaver.timer) @@ -1848,20 +2199,22 @@ export class OrcaRuntimeService { } inner.set(clientId, { ...softLeaver.record, - // Refresh viewport from the new subscribe payload — the keyboard - // state may have changed during the window. viewport, lastActedAt: Date.now() }) - // The driver was already mobile{clientId}; refresh to update - // listener wiring (and re-emit, harmless if unchanged). this.setDriver(ptyId, { kind: 'mobile', clientId }) - // If display-mode is auto/phone, reapply the fit at the new viewport - // so a keyboard show/hide that resubscribes (older clients) still - // updates dims correctly. updateMobileViewport is the preferred path - // and avoids the unsubscribe → subscribe cycle entirely. if (mode !== 'desktop') { - this.applyMobileDisplayMode(ptyId) + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } } return true } @@ -1872,18 +2225,9 @@ export class OrcaRuntimeService { this.mobileSubscribers.set(ptyId, inner) } - // Why: prefer lastRendererSizes (the actual pane geometry reported by the - // desktop renderer's safeFit via pty:resize IPC) over getTerminalSize (the - // server-side PTY size, which may be stale — e.g. 214 full-width when the - // pane is actually in a split at ~105). Fall back to existing subscriber's - // previousCols (re-subscribe case) then currentSize (first subscribe). - // - // Multi-mobile: if an existing subscriber on this PTY is already - // phone-fitted, the current PTY size is NOT a valid restore baseline for - // a *new* subscriber — it would point to a phone-fit dim, not the - // pre-mobile desktop size. Set previousCols/Rows to null so the new - // joiner is skipped from earliest-restore selection; the original - // subscriber's captured baseline remains the source of truth. See + // Capture restore baseline BEFORE applyLayout writes the override. + // Multi-mobile: peer joiner against an already-fitted PTY captures null + // — the existing baseline-holder's snapshot remains canonical. See // docs/mobile-presence-lock.md. const existing = inner.get(clientId) const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) @@ -1899,11 +2243,9 @@ export class OrcaRuntimeService { const subscribedAt = existing?.subscribedAt ?? now if (mode === 'desktop') { - // Why: set previousCols/Rows to null so we don't capture a stale PTY - // size that may not match the actual pane geometry (e.g. 214 when the - // pane is in a split at 105). When the user later toggles to auto/phone, - // handleMobileSubscribe will capture currentSize at that point, which - // will be correct because safeFit has had time to adjust the PTY. + // Passive watch — null baseline (we'll capture later if user toggles + // to auto/phone, since safeFit will have converged by then). Do not + // flip driver. inner.set(clientId, { clientId, viewport, @@ -1913,8 +2255,6 @@ export class OrcaRuntimeService { subscribedAt, lastActedAt: now }) - // Subscribe-in-desktop-mode is passive: leave driver at idle/desktop. - // Do not transition to mobile{clientId}. return false } @@ -1928,32 +2268,34 @@ export class OrcaRuntimeService { lastActedAt: now }) - const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) - const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + // Subscribe-fresh with auto/phone counts as "take the floor". + this.setDriver(ptyId, { kind: 'mobile', clientId }) - // Why: skip the PTY resize if already at the target dims. Re-subscribing - // to a terminal that was left at phone dims (no restore on tab switch) - // should not trigger another SIGWINCH → shell prompt redraw. - const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows - if (!alreadyAtTarget) { - this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - } - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) - - // Update terminalFitOverrides for desktop safeFit compatibility - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', + // Route the actual resize through the state machine. The fresh-subscribe + // gate lets enqueueLayout's "no layouts entry" short-circuit pass on + // the very first transition for this PTY. + console.log('[fit][server] handleMobileSubscribe enqueueing phone fit', { + ptyId: ptyId.slice(-8), cols: clampedCols, rows: clampedRows, - previousCols, - previousRows, - updatedAt: Date.now(), - clientId + mode }) - - // Subscribe-fresh with auto/phone mode counts as "take the floor". - this.setDriver(ptyId, { kind: 'mobile', clientId }) + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + console.log('[fit][server] handleMobileSubscribe enqueue result', { + ptyId: ptyId.slice(-8), + result, + sizeAfter: this.getTerminalSize(ptyId) + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } return true } @@ -1977,12 +2319,6 @@ export class OrcaRuntimeService { } const wasResizedToPhone = subscriber.wasResizedToPhone - // Why: snapshot the earliest-by-subscribe-time restore target BEFORE - // mutating the inner map. If the disconnecting client is the original - // baseline-holder, that information must survive into the last-leaver - // restore path even after their record is deleted. See - // docs/mobile-presence-lock.md "Restore-target selection". - const restoreTargetSnapshot = this.pickEarliestRestoreTarget(inner) inner.delete(clientId) if (inner.size > 0) { @@ -1990,9 +2326,7 @@ export class OrcaRuntimeService { // baseline (typical when peer joiners subscribed against an // already-phone-fitted PTY and got null prevCols), donate the baseline // to the earliest surviving subscriber so a future last-leaver can - // still restore correctly. Without this, A leaves first, B leaves - // last with null prevCols → no restore fires. See - // docs/mobile-presence-lock.md. + // still restore correctly. See docs/mobile-presence-lock.md. if ( subscriber.previousCols != null && subscriber.previousRows != null && @@ -2020,7 +2354,9 @@ export class OrcaRuntimeService { const next = this.pickMostRecentActor(inner) if (next) { this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - this.applyMobileDisplayMode(ptyId) + // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout + // failures self-recover on the next gesture. + void this.applyMobileDisplayMode(ptyId) } } return @@ -2030,13 +2366,9 @@ export class OrcaRuntimeService { this.mobileSubscribers.delete(ptyId) const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' - // Why: resubscribe-grace. Hold the driver=mobile{clientId} state and - // the leaving subscriber's record for ~250ms. If the same client - // re-subscribes in that window, handleMobileSubscribe cancels the - // pending-soft-leaver and re-inserts the record (preserving - // previousCols and avoiding a desktop-banner flash). Otherwise the - // grace timer fires, sets driver=idle, and lets the existing 300ms - // restore debounce (kept below) run as before. + // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick + // re-subscribe (older clients without updateViewport) doesn't flash the + // desktop banner. See docs/mobile-presence-lock.md. const SOFT_LEAVE_GRACE_MS = 250 const existingSoft = this.pendingSoftLeavers.get(ptyId) if (existingSoft) { @@ -2045,7 +2377,6 @@ export class OrcaRuntimeService { } const softTimer = setTimeout(() => { this.pendingSoftLeavers.delete(ptyId) - // Why: only flip to idle if no peer has reclaimed in the meantime. if (!this.mobileSubscribers.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) } @@ -2065,45 +2396,36 @@ export class OrcaRuntimeService { }) if (mode === 'auto' && wasResizedToPhone) { - const existing = this.pendingRestoreTimers.get(ptyId) - if (existing) { - clearTimeout(existing.timer) + const existingTimer = this.pendingRestoreTimers.get(ptyId) + if (existingTimer) { + clearTimeout(existingTimer.timer) } - - // Restore target: earliest-by-subscribe-time among non-null - // previousCols/Rows captured BEFORE deletion. Falls back to the - // disconnecting subscriber's own dims and finally lastRendererSizes - // (matches the existing first-insert capture path). + // Snapshot the disconnecting subscriber's baseline NOW, before the + // timer fires. By the time the timer runs (300ms later), the + // subscriber map has been deleted; resolveDesktopRestoreTarget would + // fall through to lastRendererSizes → current PTY size (which is at + // phone dims, wrong). The disconnecting subscriber's baseline is the + // correct restore target. const fallback = this.lastRendererSizes.get(ptyId) - const previousCols = - restoreTargetSnapshot?.previousCols ?? subscriber.previousCols ?? fallback?.cols ?? null - const previousRows = - restoreTargetSnapshot?.previousRows ?? subscriber.previousRows ?? fallback?.rows ?? null + const restoreCols = + subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 + const restoreRows = + subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 const timer = setTimeout(() => { this.pendingRestoreTimers.delete(ptyId) if (this.isMobileSubscriberActive(ptyId)) { return } - if (previousCols != null && previousRows != null) { - this.ptyController?.resize?.(ptyId, previousCols, previousRows) - this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) - } - this.lastRendererSizes.delete(ptyId) - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - previousCols ?? 0, - previousRows ?? 0 - ) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restoreCols, + rows: restoreRows + }) }, 300) this.pendingRestoreTimers.set(ptyId, { timer, clientId }) } - // 'phone' mode: keep phone dims (no restore needed) - // 'desktop' mode: was never resized, nothing to restore + // 'desktop' mode: was never resized, nothing to restore. } // Why: called when mode changes via terminal.setDisplayMode. Applies the @@ -2113,70 +2435,76 @@ export class OrcaRuntimeService { // Multi-mobile: the most recent mobile actor's viewport drives the active // phone-fit dims. The earliest-by-subscribe-time subscriber's // previousCols/Rows drive the desktop-restore target. - applyMobileDisplayMode(ptyId: string): void { + async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null + console.log('[fit][server] applyMobileDisplayMode', { + ptyId: ptyId.slice(-8), + mode, + hasInner: !!inner, + innerSize: inner?.size ?? 0, + subscriberId: subscriber?.clientId.slice(-8), + hasRecord: !!subscriberRecord, + recordViewport: subscriberRecord?.viewport, + wasResizedToPhone: subscriberRecord?.wasResizedToPhone + }) if (mode === 'desktop') { - // Find the first subscriber (any clientId) that was previously - // phone-fitted, and reset its flag. The desktop-restore target uses - // earliest-by-subscribe-time among non-null prevCols/Rows. + // Reset wasResizedToPhone on every fitted subscriber so a future + // toggle back to auto re-issues the resize. applyLayout owns the + // actual PTY resize + override delete + renderer notify. + let anyWasResized = false if (inner) { - const restore = this.pickEarliestRestoreTarget(inner) - let anyWasResized = false for (const sub of inner.values()) { if (sub.wasResizedToPhone) { anyWasResized = true sub.wasResizedToPhone = false } } - if (anyWasResized && restore) { - this.ptyController?.resize?.(ptyId, restore.previousCols, restore.previousRows) - this.resizeHeadlessTerminal(ptyId, restore.previousCols, restore.previousRows) - // Why: clear stale renderer size so the next mobile subscribe falls - // through to currentSize (which is correct after the server restore). - // Without this, a polluted 214 from a prior collateral safeFit cascade - // persists in lastRendererSizes and gets used as previousCols. - this.lastRendererSizes.delete(ptyId) - // Why: 500ms not 200ms — the desktop renderer's collateral safeFit - // cascade (IPC → React re-render → rAF → DOM measure → IPC back) - // takes ~360ms to propagate to background-tab terminals. - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - restore.previousCols, - restore.previousRows - ) - } } - const size = this.getTerminalSize(ptyId) - this.notifyTerminalResize(ptyId, { - cols: size?.cols ?? 0, - rows: size?.rows ?? 0, - displayMode: 'desktop', - reason: 'mode-change' - }) - } else if (mode === 'phone' || mode === 'auto') { + if (anyWasResized) { + const restore = this.resolveDesktopRestoreTarget(ptyId) + await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + } else { + // No subscriber was fitted — emit a mode-change resize event so + // the mobile client still learns the toggle landed. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + } else { + // mode === 'auto' — the only non-desktop mode after the 'phone' + // (sticky-fit) collapse. Phone-fit if the active subscriber has a + // viewport and we haven't already applied it. if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { const viewport = subscriberRecord.viewport if (viewport) { - this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + return } } - // Why: always emit the mode change even when no resize occurred (e.g. - // subscriber missing, wasResizedToPhone already true, or no viewport). - // Without this the mobile client never learns the mode changed and its - // toggle button gets stuck showing the old state. + // Why: always emit the mode change even when no resize occurred — the + // mobile client needs to learn the toggle landed even if dims didn't + // actually change. Carry the current seq (or undefined if no layout + // entry yet) so the mobile-side stale-event filter behaves correctly. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { cols: size?.cols ?? 0, rows: size?.rows ?? 0, - displayMode: mode, - reason: 'mode-change' + displayMode: 'auto', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq }) } } @@ -2185,18 +2513,25 @@ export class OrcaRuntimeService { // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode // restore). Stores the renderer-reported size so handleMobileSubscribe can use // the actual pane geometry instead of a stale PTY size for previousCols. + // This is a passive geometry report — it does NOT call applyLayout; the + // PTY is already at the reported size. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` + // before calling here, but defend against callers that don't. + if (this.isResizeSuppressed()) { + return + } this.lastRendererSizes.set(ptyId, { cols, rows }) const inner = this.mobileSubscribers.get(ptyId) if (!inner) { return } - // Capture the renderer-reported size as the next-restore target on any - // subscriber that hasn't yet been phone-fitted. Subscribers in - // wasResizedToPhone state already have a captured pre-fit baseline. + // Refresh the renderer-current size as the next-restore target on every + // subscriber that already has a non-null baseline. Subscribers with null + // baselines (joined while a peer had already phone-fitted) stay null. for (const sub of inner.values()) { - if (!sub.wasResizedToPhone) { + if (sub.previousCols != null && sub.previousRows != null) { sub.previousCols = cols sub.previousRows = rows } @@ -2216,7 +2551,13 @@ export class OrcaRuntimeService { subscribeToTerminalResize( ptyId: string, - listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void + listener: (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void ): () => void { let listeners = this.resizeListeners.get(ptyId) if (!listeners) { @@ -2234,7 +2575,7 @@ export class OrcaRuntimeService { private notifyTerminalResize( ptyId: string, - event: { cols: number; rows: number; displayMode: string; reason: string } + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } ): void { const listeners = this.resizeListeners.get(ptyId) if (!listeners) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 97c5224cc90..da0cd8a9dcb 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -128,7 +128,12 @@ const TerminalSubscribe = TerminalHandle.extend({ }) const TerminalSetDisplayMode = TerminalHandle.extend({ - mode: z.enum(['auto', 'phone', 'desktop']), + // Why: 'phone' was previously a "stay at phone dims after unsubscribe" + // mode that the toggle UI never produced and nothing in product + // depended on. Removed in favor of two clean modes: 'auto' (mobile + // drives dims while subscribed, desktop restores on last-leave) and + // 'desktop' (no resize, mobile scales the wide canvas down to fit). + mode: z.enum(['auto', 'desktop']), // Why: identifies the caller for the driver state machine. Optional for // backward compatibility with older mobile clients. client: z @@ -136,6 +141,17 @@ const TerminalSetDisplayMode = TerminalHandle.extend({ id: requiredString('Missing client ID'), type: z.enum(['mobile', 'desktop']).default('desktop').optional() }) + .optional(), + // Why: subscribers that registered before viewport was measured have + // a null viewport on their record. Toggling to 'auto' would no-op + // because applyMobileDisplayMode skips phone-fit when viewport is + // missing. Allow the toggle to carry the latest measured viewport so + // the server can store it on the subscriber record before fitting. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) .optional() }) @@ -217,14 +233,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow - // the most recent actor. Only mobile-typed callers take the floor; - // desktop callers (CLI / agents) do not. Older mobile builds without - // a `client` field continue to work — the runtime then keeps the - // current driver state. + // the most recent actor. Only mobile-typed callers take the floor. if (params.client && params.client.type === 'mobile') { const leaf = runtime.resolveLeafForHandle(params.terminal) if (leaf?.ptyId) { - runtime.mobileTookFloor(leaf.ptyId, params.client.id) + await runtime.mobileTookFloor(leaf.ptyId, params.client.id) } } return { send: result } @@ -273,7 +286,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId) { throw new Error('no_connected_pty') } - const result = runtime.resizeForClient( + const result = await runtime.resizeForClient( leaf.ptyId, params.mode, params.clientId, @@ -310,16 +323,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId) { throw new Error('no_connected_pty') } + // Why: late-bind viewport for callers that subscribed in desktop + // mode (no viewport stored). Without this, a 'auto' toggle on a + // viewport-less record skips phone-fit and the user sees no resize. + if (params.viewport && params.client?.id) { + runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) + } runtime.setMobileDisplayMode(leaf.ptyId, params.mode) - runtime.applyMobileDisplayMode(leaf.ptyId) + await runtime.applyMobileDisplayMode(leaf.ptyId) // Why: a deliberate mobile mode change is a take-floor action when // moving to auto/phone (the user explicitly chose to drive at phone // dims). Setting mode to desktop is intentionally NOT a take-floor // action — that's a "watch from desktop dims" gesture. if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { - runtime.mobileTookFloor(leaf.ptyId, params.client.id) + await runtime.mobileTookFloor(leaf.ptyId, params.client.id) } - return { mode: params.mode } + return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), defineMethod({ @@ -340,8 +359,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId) { throw new Error('no_connected_pty') } - const updated = runtime.updateMobileViewport(leaf.ptyId, params.client.id, params.viewport) - return { updated } + const updated = await runtime.updateMobileViewport( + leaf.ptyId, + params.client.id, + params.viewport + ) + return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq } } }), // Why: terminal.subscribe streams live terminal output over WebSocket. @@ -384,9 +407,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + console.log('[fit][server] terminal.subscribe', { + ptyId: ptyId.slice(-8), + clientId: clientId?.slice(-8), + isMobile, + viewport: params.viewport + }) + // Server-side auto-fit: resize PTY to phone dims before serializing scrollback if (isMobile && clientId) { - runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) } const read = await runtime.readTerminal(params.terminal) @@ -395,6 +425,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: emit the current layout seq with the initial scrollback so + // the mobile client's stale-event filter knows the high-water mark. + // Undefined when the PTY has never transitioned (filter is fail-open). + // See docs/mobile-terminal-layout-state-machine.md. + const seq = runtime.getLayout(ptyId)?.seq emit({ type: 'scrollback', lines: read.tail, @@ -402,7 +437,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ serialized: serialized?.data, cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, - displayMode + displayMode, + seq }) await new Promise((resolve) => { @@ -426,7 +462,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: event.rows, serialized: fresh?.data, displayMode: event.displayMode, - reason: event.reason + reason: event.reason, + seq: event.seq }) })