mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Fix mobile "phone size" Restore buttons doing nothing (#7749)
This commit is contained in:
@@ -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<string, { cols: number; rows: number }>([
|
||||
['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<void> {
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
//
|
||||
// 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<boolean> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user