mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Fix mobile display-mode toggle corrupting split-pane terminal sizes
When toggling a terminal to desktop mode on mobile, the server restores the PTY to its correct size and sends a terminalFitOverrideChanged IPC. This triggers the desktop renderer's React re-render cascade, which runs safeFit on ALL terminal panes — including background tabs measured at incorrect widths. Those stale pty:resize IPCs would overwrite the correct PTY size and corrupt previousCols for future toggles. Key changes: - Add global 500ms suppress window that blocks all desktop renderer pty:resize events after a mode change (the cascade takes ~360ms to propagate through IPC → React → rAF → DOM → IPC) - Track desktop renderer pane geometry in lastRendererSizes map so handleMobileSubscribe uses actual split-pane width instead of stale full-width PTY size - Clear lastRendererSizes on desktop restore to prevent stale values from persisting across subscribe cycles - Register mobile subscriber even in desktop mode so toggling to auto/phone can find the viewport - Add onExternalPtyResize to update previousCols live when the desktop renderer corrects pane geometry after restore - Add comprehensive debug logging to /tmp/mobile-fit-debug.log - Guard toggleDisplayMode against concurrent taps - Add integration tests for onExternalPtyResize scenarios Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -312,16 +312,21 @@ export default function SessionScreen() {
|
||||
// Why: toggles between phone and desktop mode via server RPC. The server
|
||||
// handles the actual resize and emits a 'resized' event on the existing
|
||||
// subscription stream — no client-side state tracking needed.
|
||||
const toggleInFlightRef = useRef<Set<string>>(new Set())
|
||||
const toggleDisplayMode = useCallback(
|
||||
async (handle: string) => {
|
||||
if (!client) return
|
||||
if (toggleInFlightRef.current.has(handle)) return
|
||||
const current = terminalModes.get(handle) ?? 'auto'
|
||||
const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
|
||||
console.log(`[mobile-fit] toggleDisplayMode handle=${handle} current=${current} next=${next}`)
|
||||
toggleInFlightRef.current.add(handle)
|
||||
try {
|
||||
await client.sendRequest('terminal.setDisplayMode', { terminal: handle, mode: next })
|
||||
} catch {
|
||||
// Mode change failed — server state unchanged, UI stays in sync.
|
||||
} catch (err) {
|
||||
console.log(`[mobile-fit] toggleDisplayMode FAILED handle=${handle}`, err)
|
||||
} finally {
|
||||
toggleInFlightRef.current.delete(handle)
|
||||
}
|
||||
},
|
||||
[client, terminalModes]
|
||||
|
||||
@@ -5,6 +5,15 @@ boundary. Splitting it by line count would scatter tightly coupled terminal
|
||||
process behavior across files without a cleaner ownership seam. */
|
||||
import { join, delimiter } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { appendFileSync } from 'fs'
|
||||
|
||||
function mfLog(msg: string): void {
|
||||
try {
|
||||
appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [pty-ipc] ${msg}\n`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
import { type BrowserWindow, ipcMain, app } from 'electron'
|
||||
export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
@@ -825,8 +834,21 @@ export function registerPtyHandlers(
|
||||
// empty acknowledgement message back to the renderer.
|
||||
ipcMain.removeAllListeners('pty:resize')
|
||||
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
|
||||
// Why: after a desktop-fit override change, the desktop renderer's
|
||||
// re-render cascade runs safeFit on ALL panes (not just the affected
|
||||
// one). Background-tab panes get measured at full-width (214) instead
|
||||
// of their correct split width. Suppressing ALL pty:resize during
|
||||
// this window prevents the cascade from corrupting PTY dimensions.
|
||||
if (runtime?.isResizeSuppressed()) {
|
||||
mfLog(
|
||||
`pty:resize SUPPRESSED (global window) id=${args.id} cols=${args.cols} rows=${args.rows}`
|
||||
)
|
||||
return
|
||||
}
|
||||
mfLog(`pty:resize id=${args.id} cols=${args.cols} rows=${args.rows}`)
|
||||
ptySizes.set(args.id, { cols: args.cols, rows: args.rows })
|
||||
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
|
||||
runtime?.onExternalPtyResize(args.id, args.cols, args.rows)
|
||||
})
|
||||
|
||||
// Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */
|
||||
/**
|
||||
* Integration tests for the server-authoritative mobile subscribe lifecycle.
|
||||
* Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode,
|
||||
@@ -396,6 +397,62 @@ describe('mobile subscribe integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExternalPtyResize', () => {
|
||||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
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')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
// 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')
|
||||
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')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('uses lastRendererSize for previousCols on first subscribe', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
|
||||
ptySizes.set('pty-1', { cols: 214, rows: 72 })
|
||||
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 })
|
||||
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')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not update previousCols when PTY is phone-fitted', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
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
|
||||
runtime.onExternalPtyResize('pty-1', 45, 20)
|
||||
|
||||
// Toggle to desktop — should still restore to original 150x40
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old resizeForClient still works alongside new system', () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
@@ -11,6 +11,18 @@ import { gitExecFileAsync, gitExecFileSync } from '../git/runner'
|
||||
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import { appendFileSync } from 'fs'
|
||||
|
||||
const MOBILE_FIT_LOG = '/tmp/mobile-fit-debug.log'
|
||||
function mfLog(msg: string): void {
|
||||
const ts = new Date().toISOString()
|
||||
const line = `[${ts}] ${msg}\n`
|
||||
try {
|
||||
appendFileSync(MOBILE_FIT_LOG, line)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
import { rm } from 'fs/promises'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { formatMessagesForInjection } from './orchestration/formatter'
|
||||
@@ -350,6 +362,24 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
>()
|
||||
|
||||
// Why: tracks the last PTY size set by the desktop renderer (via pty:resize
|
||||
// IPC). Unlike ptySizes (which is overwritten by server-side phone-fit
|
||||
// resizes), this map preserves the actual pane geometry. Used as the
|
||||
// preferred source for previousCols so desktop restore uses the correct
|
||||
// split-pane width instead of a stale full-width value.
|
||||
private lastRendererSizes = new Map<string, { cols: number; rows: number }>()
|
||||
|
||||
// Why: when a desktop-fit override change fires, the desktop renderer's
|
||||
// re-render cascade (triggered by setOverrideTick) runs safeFit on ALL
|
||||
// panes — not just the affected one. Background tab panes get measured at
|
||||
// full-width (214) instead of their correct split width (105). The stale
|
||||
// pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes.
|
||||
// This global window suppresses ALL pty:resize for 200ms after any
|
||||
// desktop-fit notification. The server has already set the correct PTY
|
||||
// size via ptyController.resize(), so desktop renderer resizes during
|
||||
// this window are redundant (for the restored pane) or wrong (collateral).
|
||||
private resizeSuppressedUntil = 0
|
||||
|
||||
// Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab
|
||||
// switches don't cause unnecessary resize thrashing. Keyed by clientId
|
||||
// Why: keyed by ptyId so each PTY gets its own independent restore timer.
|
||||
@@ -1016,6 +1046,7 @@ export class OrcaRuntimeService {
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
this.mobileDisplayModes.delete(ptyId)
|
||||
this.resizeListeners.delete(ptyId)
|
||||
this.lastRendererSizes.delete(ptyId)
|
||||
const pendingRestore = this.pendingRestoreTimers.get(ptyId)
|
||||
if (pendingRestore) {
|
||||
clearTimeout(pendingRestore.timer)
|
||||
@@ -1075,13 +1106,12 @@ export class OrcaRuntimeService {
|
||||
viewport?: { cols: number; rows: number }
|
||||
): boolean {
|
||||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||||
mfLog(
|
||||
`handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport ? `${viewport.cols}x${viewport.rows}` : 'none'}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] handleMobileSubscribe ptyId=${ptyId} mode=${mode} viewport=${viewport ? `${viewport.cols}x${viewport.rows}` : 'none'}`
|
||||
)
|
||||
if (mode === 'desktop') {
|
||||
console.log('[mobile-fit] → skipped (desktop mode)')
|
||||
return false
|
||||
}
|
||||
if (!viewport) {
|
||||
console.log('[mobile-fit] → skipped (no viewport)')
|
||||
return false
|
||||
@@ -1095,13 +1125,49 @@ export class OrcaRuntimeService {
|
||||
this.pendingRestoreTimers.delete(ptyId)
|
||||
}
|
||||
|
||||
// Preserve existing previous dims if re-subscribing to an already phone-fitted
|
||||
// terminal. getTerminalSize would return phone dims, so we use the existing
|
||||
// subscriber's previousCols/previousRows to avoid losing the original desktop size.
|
||||
// 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).
|
||||
const existing = this.mobileSubscribers.get(ptyId)
|
||||
const currentSize = this.getTerminalSize(ptyId)
|
||||
const previousCols = existing?.previousCols ?? currentSize?.cols ?? null
|
||||
const previousRows = existing?.previousRows ?? currentSize?.rows ?? null
|
||||
const rendererSize = this.lastRendererSizes.get(ptyId)
|
||||
const previousCols = existing?.previousCols ?? rendererSize?.cols ?? currentSize?.cols ?? null
|
||||
const previousRows = existing?.previousRows ?? rendererSize?.rows ?? currentSize?.rows ?? null
|
||||
|
||||
mfLog(
|
||||
` existing.previousCols=${existing?.previousCols ?? 'null'} existing.previousRows=${existing?.previousRows ?? 'null'} existing.wasResized=${existing?.wasResizedToPhone ?? 'N/A'}`
|
||||
)
|
||||
mfLog(
|
||||
` currentSize=${currentSize ? `${currentSize.cols}x${currentSize.rows}` : 'null'} rendererSize=${rendererSize ? `${rendererSize.cols}x${rendererSize.rows}` : 'null'}`
|
||||
)
|
||||
mfLog(` → previousCols=${previousCols} previousRows=${previousRows}`)
|
||||
mfLog(
|
||||
` allRendererSizes: ${JSON.stringify([...this.lastRendererSizes.entries()].map(([k, v]) => [k.slice(-12), `${v.cols}x${v.rows}`]))}`
|
||||
)
|
||||
|
||||
// Why: always register the subscriber so applyMobileDisplayMode can find
|
||||
// the viewport when the user later toggles from desktop to auto/phone.
|
||||
// Without this, toggling to auto after subscribing in desktop mode sees
|
||||
// hasSubscriber=false and can't perform the phone resize.
|
||||
if (mode === 'desktop') {
|
||||
mfLog(` → SKIP (desktop mode), registering with null prev`)
|
||||
console.log('[mobile-fit] → registered subscriber but skipped resize (desktop mode)')
|
||||
// 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.
|
||||
this.mobileSubscribers.set(ptyId, {
|
||||
clientId,
|
||||
viewport,
|
||||
wasResizedToPhone: false,
|
||||
previousCols: null,
|
||||
previousRows: null
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
this.mobileSubscribers.set(ptyId, {
|
||||
clientId,
|
||||
@@ -1118,8 +1184,13 @@ export class OrcaRuntimeService {
|
||||
// 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
|
||||
const prevSource =
|
||||
existing?.previousCols != null ? 'existing' : rendererSize ? 'renderer' : 'pty'
|
||||
mfLog(
|
||||
` → ${alreadyAtTarget ? 'ALREADY AT' : 'RESIZE TO'} ${clampedCols}x${clampedRows} src=${prevSource}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] → ${alreadyAtTarget ? 'already at' : 'resizing PTY to'} ${clampedCols}x${clampedRows} (prev=${previousCols}x${previousRows}) notifier=${!!this.notifier}`
|
||||
`[mobile-fit] → ${alreadyAtTarget ? 'already at' : 'resizing PTY to'} ${clampedCols}x${clampedRows} (prev=${previousCols}x${previousRows} src=${prevSource}) notifier=${!!this.notifier}`
|
||||
)
|
||||
if (!alreadyAtTarget) {
|
||||
this.ptyController?.resize?.(ptyId, clampedCols, clampedRows)
|
||||
@@ -1146,15 +1217,20 @@ export class OrcaRuntimeService {
|
||||
// intermediate terminals keep their current dims harmlessly.
|
||||
handleMobileUnsubscribe(ptyId: string, clientId: string): void {
|
||||
const subscriber = this.mobileSubscribers.get(ptyId)
|
||||
mfLog(
|
||||
`handleMobileUnsubscribe ptyId=${ptyId} hasSub=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone} prev=${subscriber?.previousCols}x${subscriber?.previousRows}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] handleMobileUnsubscribe ptyId=${ptyId} hasSubscriber=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone}`
|
||||
)
|
||||
if (!subscriber || subscriber.clientId !== clientId) {
|
||||
mfLog(` → SKIP (no subscriber or clientId mismatch)`)
|
||||
return
|
||||
}
|
||||
|
||||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
mfLog(` mode=${mode} deleted subscriber`)
|
||||
|
||||
if (mode === 'auto' && subscriber.wasResizedToPhone) {
|
||||
const existing = this.pendingRestoreTimers.get(ptyId)
|
||||
@@ -1163,15 +1239,20 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const { previousCols, previousRows } = subscriber
|
||||
mfLog(` scheduling delayed restore to ${previousCols}x${previousRows} in 300ms`)
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRestoreTimers.delete(ptyId)
|
||||
if (this.mobileSubscribers.has(ptyId)) {
|
||||
mfLog(` delayed restore CANCELLED — new subscriber exists`)
|
||||
return
|
||||
}
|
||||
mfLog(` delayed restore FIRING: ptyId=${ptyId} resize to ${previousCols}x${previousRows}`)
|
||||
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,
|
||||
@@ -1194,6 +1275,11 @@ export class OrcaRuntimeService {
|
||||
applyMobileDisplayMode(ptyId: string): void {
|
||||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||||
const subscriber = this.mobileSubscribers.get(ptyId)
|
||||
const rendererSize = this.lastRendererSizes.get(ptyId)
|
||||
const currentSize = this.getTerminalSize(ptyId)
|
||||
mfLog(
|
||||
`applyMobileDisplayMode ptyId=${ptyId} mode=${mode} hasSub=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone} sub.prev=${subscriber?.previousCols}x${subscriber?.previousRows} rendererSize=${rendererSize ? `${rendererSize.cols}x${rendererSize.rows}` : 'null'} currentSize=${currentSize ? `${currentSize.cols}x${currentSize.rows}` : 'null'}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] applyMobileDisplayMode ptyId=${ptyId} mode=${mode} hasSubscriber=${!!subscriber} wasResized=${subscriber?.wasResizedToPhone}`
|
||||
)
|
||||
@@ -1201,11 +1287,21 @@ export class OrcaRuntimeService {
|
||||
if (mode === 'desktop') {
|
||||
if (subscriber?.wasResizedToPhone) {
|
||||
const { previousCols, previousRows } = subscriber
|
||||
mfLog(` DESKTOP RESTORE: previousCols=${previousCols} previousRows=${previousRows}`)
|
||||
if (previousCols != null && previousRows != null) {
|
||||
this.ptyController?.resize?.(ptyId, previousCols, previousRows)
|
||||
this.resizeHeadlessTerminal(ptyId, previousCols, previousRows)
|
||||
}
|
||||
subscriber.wasResizedToPhone = false
|
||||
// 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,
|
||||
@@ -1215,30 +1311,71 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
mfLog(` NOTIFY desktop resize: ${size?.cols}x${size?.rows}`)
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: size?.cols ?? 0,
|
||||
rows: size?.rows ?? 0,
|
||||
displayMode: 'desktop',
|
||||
reason: 'mode-change'
|
||||
})
|
||||
} else if (
|
||||
(mode === 'phone' || mode === 'auto') &&
|
||||
subscriber &&
|
||||
!subscriber.wasResizedToPhone
|
||||
) {
|
||||
const viewport = subscriber.viewport
|
||||
if (viewport) {
|
||||
this.handleMobileSubscribe(ptyId, subscriber.clientId, viewport)
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: viewport.cols,
|
||||
rows: viewport.rows,
|
||||
displayMode: mode,
|
||||
reason: 'mode-change'
|
||||
})
|
||||
} else if (mode === 'phone' || mode === 'auto') {
|
||||
if (subscriber && !subscriber.wasResizedToPhone) {
|
||||
const viewport = subscriber.viewport
|
||||
if (viewport) {
|
||||
this.handleMobileSubscribe(ptyId, subscriber.clientId, viewport)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
this.notifyTerminalResize(ptyId, {
|
||||
cols: size?.cols ?? 0,
|
||||
rows: size?.rows ?? 0,
|
||||
displayMode: mode,
|
||||
reason: 'mode-change'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: called from the pty:resize IPC handler whenever the desktop renderer
|
||||
// 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.
|
||||
onExternalPtyResize(ptyId: string, cols: number, rows: number): void {
|
||||
this.lastRendererSizes.set(ptyId, { cols, rows })
|
||||
mfLog(`onExternalPtyResize ptyId=${ptyId} cols=${cols} rows=${rows}`)
|
||||
|
||||
const subscriber = this.mobileSubscribers.get(ptyId)
|
||||
if (!subscriber) {
|
||||
mfLog(` no subscriber, stored in lastRendererSizes only`)
|
||||
return
|
||||
}
|
||||
if (!subscriber.wasResizedToPhone) {
|
||||
mfLog(
|
||||
` updated subscriber.previousCols ${subscriber.previousCols}→${cols} previousRows ${subscriber.previousRows}→${rows}`
|
||||
)
|
||||
subscriber.previousCols = cols
|
||||
subscriber.previousRows = rows
|
||||
} else {
|
||||
mfLog(
|
||||
` SKIPPED (wasResizedToPhone=true, keeping prev=${subscriber.previousCols}x${subscriber.previousRows})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the pty:resize IPC handler calls this to check if the global
|
||||
// suppress window is active. During this window, all desktop renderer
|
||||
// pty:resize events are ignored to prevent collateral safeFit corruption.
|
||||
isResizeSuppressed(): boolean {
|
||||
return Date.now() < this.resizeSuppressedUntil
|
||||
}
|
||||
|
||||
private suppressResizesForMs(ms: number): void {
|
||||
this.resizeSuppressedUntil = Date.now() + ms
|
||||
}
|
||||
|
||||
subscribeToTerminalResize(
|
||||
ptyId: string,
|
||||
listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void
|
||||
@@ -1262,6 +1399,9 @@ export class OrcaRuntimeService {
|
||||
event: { cols: number; rows: number; displayMode: string; reason: string }
|
||||
): void {
|
||||
const listeners = this.resizeListeners.get(ptyId)
|
||||
mfLog(
|
||||
`notifyTerminalResize ptyId=${ptyId} cols=${event.cols} rows=${event.rows} mode=${event.displayMode} reason=${event.reason} listenerCount=${listeners?.size ?? 0}`
|
||||
)
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */
|
||||
import { z } from 'zod'
|
||||
import { appendFileSync } from 'fs'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
function mfLog(msg: string): void {
|
||||
try {
|
||||
appendFileSync('/tmp/mobile-fit-debug.log', `[${new Date().toISOString()}] [rpc] ${msg}\n`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const TerminalHandle = z.object({
|
||||
terminal: requiredString('Missing terminal handle')
|
||||
})
|
||||
@@ -238,6 +247,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
params: TerminalSetDisplayMode,
|
||||
handler: async (params, { runtime }) => {
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
mfLog(
|
||||
`setDisplayMode handle=${params.terminal} mode=${params.mode} ptyId=${leaf?.ptyId ?? 'none'}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] setDisplayMode handle=${params.terminal} mode=${params.mode} ptyId=${leaf?.ptyId ?? 'none'}`
|
||||
)
|
||||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
@@ -265,6 +280,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
handler: async (params, { runtime }, emit) => {
|
||||
let leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
const isMobile = params.client?.type === 'mobile'
|
||||
if (isMobile) {
|
||||
mfLog(`\n========== MOBILE SUBSCRIBE ==========`)
|
||||
}
|
||||
mfLog(
|
||||
`terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId ?? 'none'} client=${params.client?.type ?? 'none'} viewport=${params.viewport ? `${params.viewport.cols}x${params.viewport.rows}` : 'none'}`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] terminal.subscribe handle=${params.terminal} ptyId=${leaf?.ptyId ?? 'none'} client=${params.client?.type ?? 'none'} viewport=${params.viewport ? `${params.viewport.cols}x${params.viewport.rows}` : 'none'}`
|
||||
)
|
||||
@@ -277,10 +298,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
try {
|
||||
const ptyId = await runtime.waitForLeafPtyId(params.terminal)
|
||||
leaf = { ptyId }
|
||||
mfLog(
|
||||
` terminal.subscribe handle=${params.terminal} ptyId resolved to ${ptyId} after wait`
|
||||
)
|
||||
console.log(
|
||||
`[mobile-fit] terminal.subscribe handle=${params.terminal} ptyId resolved to ${ptyId} after wait`
|
||||
)
|
||||
} catch {
|
||||
mfLog(` terminal.subscribe handle=${params.terminal} PTY wait timed out`)
|
||||
console.log(
|
||||
`[mobile-fit] terminal.subscribe handle=${params.terminal} PTY wait timed out, falling back`
|
||||
)
|
||||
@@ -288,6 +313,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
|
||||
if (!leaf?.ptyId) {
|
||||
mfLog(` terminal.subscribe handle=${params.terminal} no ptyId, sending scrollback+end`)
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
@@ -303,6 +329,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const clientId = params.client?.id
|
||||
mfLog(
|
||||
` terminal.subscribe resolved ptyId=${ptyId} clientId=${clientId ?? 'none'} isMobile=${isMobile}`
|
||||
)
|
||||
|
||||
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
|
||||
if (isMobile && clientId) {
|
||||
@@ -332,6 +361,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// mobile clients. They include fresh serialized scrollback so the client
|
||||
// can reinitialize xterm without resubscribing.
|
||||
const unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, async (event) => {
|
||||
mfLog(
|
||||
` terminal.subscribe RESIZE event ptyId=${ptyId} cols=${event.cols} rows=${event.rows} displayMode=${event.displayMode} reason=${event.reason}`
|
||||
)
|
||||
const fresh = await runtime.serializeTerminalBuffer(ptyId)
|
||||
emit({
|
||||
type: 'resized',
|
||||
@@ -357,6 +389,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
|
||||
const subscriptionId = params.terminal
|
||||
runtime.registerSubscriptionCleanup(subscriptionId, () => {
|
||||
mfLog(
|
||||
` terminal.subscribe CLEANUP ptyId=${ptyId} isMobile=${isMobile} clientId=${clientId ?? 'none'}`
|
||||
)
|
||||
unsubscribeData()
|
||||
unsubscribeResize()
|
||||
unsubscribeFit()
|
||||
|
||||
Reference in New Issue
Block a user