diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 56581919edc..a38084c5199 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -74,13 +74,21 @@ const store = { }) } -function createRuntime() { - const runtime = new OrcaRuntimeService(store) +// Why (#7588): the held-modal repro needs indefinite hold (null) while the +// legacy tests rely on the finite 5s default. Wrap getSettings per-test so a +// caller can pick the hold without mutating the shared stub. +function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { + const effectiveStore = { + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs }) + } + const runtime = new OrcaRuntimeService(effectiveStore) const ptySizes = new Map([ ['pty-1', { cols: 150, rows: 40 }] ]) const resizes: { ptyId: string; cols: number; rows: number }[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] let resizeSucceeds = true runtime.setPtyController({ @@ -107,7 +115,9 @@ function createRuntime() { focusTerminal: vi.fn(), closeTerminal: vi.fn(), sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + fitOverrideEvents.push({ ptyId, mode, cols, rows }) + }, terminalDriverChanged: (ptyId, driver) => { driverEvents.push({ ptyId, driver: { ...driver } }) } @@ -118,6 +128,7 @@ function createRuntime() { ptySizes, resizes, driverEvents, + fitOverrideEvents, setResizeSucceeds: (next: boolean) => { resizeSucceeds = next } @@ -314,11 +325,12 @@ describe('mobile presence lock — multi-mobile semantics', () => { }) it('updateMobileViewport re-fits PTY without flipping the driver', async () => { - const { runtime, ptySizes, driverEvents } = createRuntime() + const { runtime, ptySizes, driverEvents, fitOverrideEvents } = createRuntime() 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 + const fitEventsBefore = fitOverrideEvents.length // Keyboard opens — viewport shrinks. await expect( @@ -330,6 +342,10 @@ describe('mobile presence lock — multi-mobile semantics', () => { // Why: a viewport update may re-emit driver to refresh listener // wiring, but it must never go through `idle` (no banner flash). expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) + // Why: phone→phone dim ticks (keyboard show/hide) are the hottest layout + // path and must not wake the renderer's fit-override listeners — the + // emit gate opens only when layout kind or override presence changes. + expect(fitOverrideEvents.length).toBe(fitEventsBefore) }) it('updateMobileViewport late-binds a viewport-less mobile subscriber', async () => { @@ -467,3 +483,252 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) }) + +// Why (#7588): drive the runtime into the reported held-modal state — a phone +// fit that an indefinite hold left behind, followed by a null-viewport +// resubscribe (app update / WebView reload) that re-registers an active +// subscriber with wasResizedToPhone=false while the override is still held. +// This is the state where the desktop "Your phone left this at phone size" +// modal's Restore buttons used to silently no-op. +async function reachHeldModalWithNullViewportResubscribe( + runtime: OrcaRuntimeService +): Promise { + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + // Production RPC passes `params.viewport` straight through, so a client + // that hasn't measured yet arrives here as `undefined`. + await runtime.handleMobileSubscribe('pty-1', 'phone-A', undefined) +} + +describe('mobile presence lock — issue #7588 held-modal restore convergence', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + // Scenario 1: the reported repro end-to-end. Restore must converge and + // notify BOTH the renderer notifier and a runtime listener (paired), since + // remote/web viewers ride the listener channel. + it('reclaim after a null-viewport resubscribe restores dims, clears override, notifies both channels', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + // Held state: driver idle, override still present, phone-sized PTY. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 2: a second click after success is an idempotent no-op. The + // persistent null-viewport subscriber keeps reclaim in the active-subscriber + // branch; assert only "returns true, no new PTY resize, no new fit-override + // event" — a benign mobile-facing mode-change notify is acceptable. + it('second Restore click after success returns true with no new resize or fit-override event', async () => { + const { runtime, resizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + + const resizeCount = resizes.length + const notifierCount = fitOverrideEvents.length + const listenerCount = listenerEvents.length + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(resizes.length).toBe(resizeCount) + expect(fitOverrideEvents.length).toBe(notifierCount) + expect(listenerEvents.length).toBe(listenerCount) + }) + + // Scenario 3: the existing driving take-back is unregressed — a phone that is + // actively driving (wasResizedToPhone=true) still flips to desktop, clears + // the override, and notifies both channels. + it('driving take-back still converges: driver → desktop, override cleared, both channels notified', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1').kind).toBe('mobile') + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 4: a failing resize during a HELD (no-subscriber) restore returns + // false and keeps the override — no lying, no phantom desktop-fit event. + it('held restore with a failing resize returns false and keeps the override', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // Last leaver under indefinite hold → override held, no active subscriber. + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5: a failing resize during an ACTIVE-SUBSCRIBER take-back returns + // false, keeps the override, leaves the driver on its mobile lock, and + // restores the prior display mode ('auto') — the fix #3 P1 correction. + it('active-subscriber take-back with a failing resize returns false and preserves the mobile lock', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + // Driver stays mobile (lock retained) and mode is not left lying at 'desktop'. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5b: after a FAILED active-subscriber take-back, wasResizedToPhone + // must be re-armed so a later unsubscribe under a finite auto-restore setting + // still schedules its timer and eventually clears the override. Without the + // re-arm the flag would be stuck false and the phone-fit would strand. + it('failed take-back re-arms wasResizedToPhone so a later unsubscribe still auto-restores', async () => { + // Finite auto-restore (5s default from the rig store). + const { runtime, ptySizes, setResizeSucceeds } = createRuntime() + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Take-back fails → false, flag re-armed, mode rolled back to 'auto'. + setResizeSucceeds(false) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + // Phone then leaves the terminal. Resize works again for the auto-restore. + setResizeSucceeds(true) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + // Soft-leave grace, then the finite auto-restore timer. + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(5_000) + + // The scheduled auto-restore fired: override cleared, PTY back to desktop. + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale + // held override converges through the shared applyMobileDisplayMode seam. + it('phone-initiated setDisplayMode(desktop) against a stale held override converges', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + + runtime.setMobileDisplayMode('pty-1', 'desktop') + const converged = await runtime.applyMobileDisplayMode('pty-1') + + expect(converged).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 7 (white-box): a held override whose `layouts` entry is gone is + // unreachable via public APIs (onPtyExit deletes both in lockstep), so seed + // it directly. Reclaim must still delete the override and emit a paired + // desktop-fit 0×0 rather than stranding the modal on the next hydrate. + it('orphan cleanup: reclaim on a held override with no layout entry converges', async () => { + const { runtime, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + const internal = runtime as unknown as { + terminalFitOverrides: Map< + string, + { + mode: string + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + > + } + internal.terminalFitOverrides.set('pty-1', { + mode: 'mobile-fit', + cols: 45, + rows: 20, + previousCols: 150, + previousRows: 40, + updatedAt: Date.now(), + clientId: 'phone-A' + }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + ptyId: 'pty-1', + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + expect(listenerEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6463423241e..25038763e17 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7285,13 +7285,30 @@ export class OrcaRuntimeService { // the existing 'resized' event reaches the phone. // 2. Held with no mobile subscriber (post-indefinite-hold): no inner // subscriber to notify; resolve restore target and enqueueLayout - // directly. applyLayout is the SOLE writer of terminalFitOverrides; - // the held branch must not duplicate that mutation. See - // docs/mobile-fit-hold.md. + // directly. applyLayout is the SOLE writer of terminalFitOverrides on + // the normal path, so the held branch defers the mutation to it — the + // one exception is the dead-pty orphan cleanup below, which mirrors + // onPtyExit's sanctioned delete. See docs/mobile-fit-hold.md. + // + // Returns `true` only when the pty ends converged (no fit-override held); + // `false` when a restore was attempted but the resize failed (#7588), or + // when there was nothing to reclaim. On a failed-restore `false`, driver/mode + // are left unchanged so a driving phone keeps its lock and the modal + // truthfully stays — the user can retry. async reclaimTerminalForDesktop(ptyId: string): Promise { if (this.isMobileSubscriberActive(ptyId)) { + // Why (#7588): capture the prior mode so a failed restore can roll the + // routing-only 'desktop' write back — driver/mode transitions must be + // gated on convergence, not committed before we know the resize took. + const priorMode = this.getMobileDisplayMode(ptyId) this.setMobileDisplayMode(ptyId, 'desktop') - await this.applyMobileDisplayMode(ptyId) + const converged = await this.applyMobileDisplayMode(ptyId) + if (!converged) { + // Resize failed — override still held. Leave the driver on its mobile + // lock and undo the mode write so nothing is left lying at 'desktop'. + this.setMobileDisplayMode(ptyId, priorMode) + return false + } this.setDriver(ptyId, { kind: 'desktop' }) // Why: a desktop-initiated reclaim is "I'm taking over right now", // not a sticky preference. The next mobile subscribe (e.g. user @@ -7315,15 +7332,32 @@ export class OrcaRuntimeService { const renderer = this.lastRendererSizes.get(ptyId) const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows - await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - this.setDriver(ptyId, { kind: 'desktop' }) - // Why: a desktop-initiated reclaim is "I'm taking over right now", - // not a sticky preference. Reset to auto so the next mobile subscribe - // re-enters phone-fit. (Held-PTY branch may not have an entry, but - // calling setMobileDisplayMode('auto') is a no-op deletion in that - // case — safe and idempotent.) - this.setMobileDisplayMode(ptyId, 'auto') - return true + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.setDriver(ptyId, { kind: 'desktop' }) + // Why: a desktop-initiated reclaim is "I'm taking over right now", + // not a sticky preference. Reset to auto so the next mobile subscribe + // re-enters phone-fit. (Held-PTY branch may not have an entry, but + // calling setMobileDisplayMode('auto') is a no-op deletion in that + // case — safe and idempotent.) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + // Why (#7588): the layout entry is gone (pty exited) but the override is + // still held — unreachable via public APIs today (onPtyExit deletes both + // in lockstep), but reporting success while stranding the override would + // re-show the modal on the next hydrate. Run the same cleanup onPtyExit + // does (delete override + paired desktop-fit 0×0) so the renderer + // converges; nothing to resize on a dead pty. + if (result.reason === 'pty-exited' && this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + return true + } + // resize-failed: the override remains held; report the truth so the + // modal correctly stays and the user can retry. Driver/mode untouched. + return false } return false } @@ -7439,7 +7473,8 @@ export class OrcaRuntimeService { // // applyLayout is the SOLE writer of: // - this.layouts - // - this.terminalFitOverrides + // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in + // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) // - this.ptyController.resize (i.e. the actual PTY dims) // // Every trigger that wants to change PTY dims or flip mode goes through @@ -7634,11 +7669,17 @@ export class OrcaRuntimeService { 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: emit fit-override-changed 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. + // Defense-in-depth (#7588): also emit when the override's presence + // changed even without a kind flip. applyLayout is the sole writer and + // keeps override presence in lockstep with layout kind, so overrideChanged + // ≡ modeChanged in every reachable state today; the extra clause fires + // only if that invariant is ever violated, repairing the renderer instead + // of stranding the held modal. + const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') + if (modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". @@ -8036,7 +8077,13 @@ 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. - async applyMobileDisplayMode(ptyId: string): Promise { + // + // Returns the post-condition "no fit-override remains held" (#7588): `true` + // when it cleared a held override OR nothing was held to begin with, `false` + // only when a restore was attempted and the resize failed (override rolled + // back, still held). reclaimTerminalForDesktop gates its driver/mode + // transitions on this; other callers ignore it. + async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.getMobileDisplayMode(ptyId) const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null @@ -8045,25 +8092,39 @@ export class OrcaRuntimeService { if (mode === 'desktop') { // 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) { - for (const sub of inner.values()) { - if (sub.wasResizedToPhone) { - anyWasResized = true - sub.wasResizedToPhone = false - } - } + // actual PTY resize + override delete + renderer notify. Track which + // subscribers we cleared so a failed resize can re-arm them. + const clearedFitSubscribers = inner + ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) + : [] + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = false } - if (anyWasResized) { + const anyWasResized = clearedFitSubscribers.length > 0 + // Why (#7588): also restore when a fit-override is still held but no + // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe + // after an indefinite hold resets the flag yet leaves the override, + // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget + // (the same resolver the anyWasResized branch uses) so the two adjacent + // restore paths can never resolve to different dims for the same state. + if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { const restore = this.resolveDesktopRestoreTarget(ptyId) - await this.enqueueLayout(ptyId, { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols: restore.cols, rows: restore.rows }) + // Why (#7588): a failed resize rolls the override back (still held), so + // re-arm the flags we cleared. Otherwise a later unsubscribe under a + // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip + // scheduling its auto-restore timer, and strand the held phone-fit. + if (!result.ok) { + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = true + } + } } else { - // No subscriber was fitted — emit a mode-change resize event so + // Nothing was fitted or held — emit a mode-change resize event so // the mobile client still learns the toggle landed. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { @@ -8082,7 +8143,10 @@ export class OrcaRuntimeService { const viewport = subscriberRecord.viewport if (viewport) { await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) - return + // After a phone-fit an override IS held, so this reports false. The + // auto branch is never reached from reclaim (it sets 'desktop' + // first); computed here only to keep the post-condition uniform. + return !this.terminalFitOverrides.has(ptyId) } } // Why: always emit the mode change even when no resize occurred — the @@ -8098,6 +8162,7 @@ export class OrcaRuntimeService { seq: this.layouts.get(ptyId)?.seq }) } + return !this.terminalFitOverrides.has(ptyId) } // Why: called after a desktop renderer path has successfully resized the