fix: refit desktop watcher xterm when mobile-fit override arrives (#6410) (#6596)

This commit is contained in:
Neil 2026-07-04 16:42:50 -07:00 committed by GitHub
parent 50339fbe82
commit dbd5064235
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 184 additions and 11 deletions

View File

@ -93,6 +93,7 @@ import type { AgentType } from '../../../../shared/agent-status-types'
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
import {
inspectRuntimeTerminalProcess,
isRemoteRuntimePtyId
@ -350,8 +351,9 @@ export default function TerminalPane({
const daemonActions = useDaemonActions()
// Why: override state lives in a plain Map for perf (safeFit reads it on
// every resize). This counter forces a re-render when overrides change so
// the mobile-fit banner appears/disappears. When an override is cleared
// (desktop-fit), we also trigger safeFit on affected panes so the terminal
// the mobile-fit banner appears/disappears. On both transitions we also
// trigger safeFit on affected panes: mobile-fit shrinks the watcher's xterm
// to phone dims (matching the live phone-wrapped stream), and desktop-fit
// resizes back to desktop dimensions.
const [, setOverrideTick] = useState(0)
useEffect(() => {
@ -376,17 +378,49 @@ export default function TerminalPane({
const unsubscribe = onOverrideChange((event) => {
setOverrideTick((n) => n + 1)
if (event.mode === 'desktop-fit') {
const manager = managerRef.current
if (!manager) {
const manager = managerRef.current
if (!manager) {
return
}
// Why: pane IDs are per-tab, so resolve the affected PTY through this
// tab's live transport bindings instead of global numeric pane IDs.
const getAffectedPanes = (): ReturnType<typeof manager.getPanes> =>
getOverrideAffectedPanes(
manager.getPanes(),
(paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(),
event.ptyId
)
if (event.mode === 'mobile-fit') {
// Why: when mobile starts driving, the agent re-renders its output at
// phone width and that phone-wrapped byte stream flows live into this
// passive watcher's xterm. xterm must shrink to the phone dims now or
// the wide desktop grid renders the narrow stream as overlapping,
// garbled lines. safeFit honors the active override and parks xterm at
// override.cols/rows, matching the incoming stream. rAF lets the DOM
// settle before the resize; no loud fallback is needed because the
// override branch of safeFit is itself the authoritative resize.
// Why: override events fan out to every terminal tab; skip the rAF
// unless this tab has a pane still parked at the wrong grid.
const panesNeedingFit = getPanesNeedingOverrideFit(
getAffectedPanes(),
event.cols,
event.rows
)
if (panesNeedingFit.length === 0) {
return
}
// Why: pane IDs are per-tab, so resolve the affected PTY through this
// tab's live transport bindings instead of global numeric pane IDs.
const getAffectedPanes = (): ReturnType<typeof manager.getPanes> =>
manager
.getPanes()
.filter((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() === event.ptyId)
scheduleFitFrame(() => {
for (const pane of getPanesNeedingOverrideFit(
getAffectedPanes(),
event.cols,
event.rows
)) {
safeFit(pane)
}
})
return
}
if (event.mode === 'desktop-fit') {
// Why: fitAddon.fit() measures DOM dimensions, so it must run after
// the browser has settled layout. Running synchronously inside the
// IPC callback can produce stale measurements. rAF ensures the DOM

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
function makePane(id: number, cols = 120, rows = 40): ManagedPane {
return { id, terminal: { cols, rows } } as ManagedPane
}
describe('getOverrideAffectedPanes', () => {
it('returns only panes bound to the event PTY in this tab', () => {
const panes = [makePane(1), makePane(2), makePane(3)]
const bindings = new Map<number, string>([
[1, 'pty-a'],
[2, 'pty-b'],
[3, 'pty-a']
])
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
expect(affected.map((pane) => pane.id)).toEqual([1, 3])
})
it('returns nothing for a watcher whose panes are bound to other PTYs', () => {
const panes = [makePane(10), makePane(11)]
const bindings = new Map<number, string>([
[10, 'pty-x'],
[11, 'pty-y']
])
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-z')
expect(affected).toEqual([])
})
it('ignores unbound panes (resolver returns undefined)', () => {
const panes = [makePane(1), makePane(2)]
const bindings = new Map<number, string>([[1, 'pty-a']])
const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a')
expect(affected.map((pane) => pane.id)).toEqual([1])
})
})
describe('getPanesNeedingOverrideFit', () => {
it('returns only affected panes whose grid does not already match the override', () => {
const panes = [makePane(1, 49, 20), makePane(2, 120, 40), makePane(3, 49, 21)]
const panesNeedingFit = getPanesNeedingOverrideFit(panes, 49, 20)
expect(panesNeedingFit.map((pane) => pane.id)).toEqual([2, 3])
})
it('returns nothing when every affected pane already matches the override', () => {
const panes = [makePane(1, 49, 20), makePane(2, 49, 20)]
expect(getPanesNeedingOverrideFit(panes, 49, 20)).toEqual([])
})
})

View File

@ -0,0 +1,36 @@
// Why: a fit-override event names a PTY, but pane IDs are per-tab and the
// override stream reaches every subscriber (including passive desktop
// watchers). This resolves which of this tab's panes are bound to the
// event's PTY via the tab's live transport bindings, so both the
// mobile-fit and desktop-fit branches of the override listener refit the
// same set of panes without colliding on global numeric pane IDs.
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
// Why: transports return string | null and the binding lookup yields
// undefined for unbound panes, so the resolver tolerates both absent cases.
export type PanePtyResolver = (paneId: number) => string | null | undefined
export function getOverrideAffectedPanes(
panes: readonly ManagedPane[],
resolvePtyId: PanePtyResolver,
ptyId: string
): ManagedPane[] {
return panes.filter((pane) => resolvePtyId(pane.id) === ptyId)
}
function paneNeedsOverrideFit(
pane: Pick<ManagedPane, 'terminal'>,
cols: number,
rows: number
): boolean {
return pane.terminal.cols !== cols || pane.terminal.rows !== rows
}
export function getPanesNeedingOverrideFit(
panes: readonly ManagedPane[],
cols: number,
rows: number
): ManagedPane[] {
return panes.filter((pane) => paneNeedsOverrideFit(pane, cols, rows))
}

View File

@ -3120,6 +3120,45 @@ describe('connectPanePty', () => {
expect(pane.container.dataset.ptyId).toBeUndefined()
})
it('refits immediately when binding to a PTY with an active mobile-fit override', async () => {
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const pane = createPane(1)
Object.defineProperty(pane.container, 'getBoundingClientRect', {
configurable: true,
value: () =>
({
width: 800,
height: 400,
top: 0,
left: 0,
right: 800,
bottom: 400
}) as DOMRect
})
;(
pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } }
).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
pane.terminal.resize.mockImplementation((cols: number, rows: number) => {
pane.terminal.cols = cols
pane.terminal.rows = rows
})
setFitOverride('pty-fit', 'mobile-fit', 49, 20)
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
expect(onPtySpawn).toBeTypeOf('function')
onPtySpawn?.('pty-fit')
expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20)
expect(pane.terminal.cols).toBe(49)
expect(pane.terminal.rows).toBe(20)
})
it('continues post-spawn size reconcile after a transient mobile presence lock', async () => {
const frameCallbacks: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {

View File

@ -1847,6 +1847,11 @@ export function connectPanePty(
const setPanePtyFitBinding = (ptyId: string): void => {
bindPanePtyId(pane.id, ptyId, deps.tabId)
pane.container.dataset.ptyId = ptyId
// Why: override hydration can arrive before this pane knows its PTY. Once
// data-pty-id is bound, safeFit can park xterm at the held phone grid.
if (getFitOverrideForPty(ptyId)) {
safeFit(pane)
}
}
let activePanePtyBinding: string | null = null
// Why: bind time lets async liveness reconcile ignore a request started