This commit is contained in:
parent
50339fbe82
commit
dbd5064235
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue