fix(mobile): harden terminal height refit (follow-up to #8647) (#8707)

* fix(mobile): harden terminal height refit (follow-up to #8647)

Addresses review feedback on #8647:

- Defer height refits while the keyboard is visible and coalesce every
  skipped layout change into one correction after the keyboard closes,
  via a pure reducer. Prevents an over-fit that settles with the keyboard
  up from surviving (on iOS the edge-to-edge keyboard doesn't change the
  frame height on close, so there was no later event to re-trigger it).
- Drive height layout callbacks imperatively (notifyTerminalFrameHeight)
  instead of setState, so height-only layout bursts no longer re-render
  SessionScreen.
- Cache the updateViewport capability (method_not_found -> unsupported):
  old desktops now get one unsupported probe then legacy resubscribe,
  instead of one probe per refit. Reconnect resets the cache so an
  upgraded desktop is re-detected.

No server schema or subscription-protocol changes; desktop-first stays
compatible.

Tests: 703 mobile terminal/session pass; tsc, oxlint, formatting clean.

* fix(mobile): re-check keyboard when a deferred height refit fires

Close a race in the keyboard-deferral: a height refit deferred at
keyboard-close arms a 150ms debounce timer, and if the keyboard reopens
inside that window the timer still fired and reflowed the PTY mid-keystroke.

The timer callback now re-consults the reducer (new `refit-committed`
event) when the armed refit is height-originated: if the keyboard is
visible again it re-defers (pending) instead of reflowing, and runs on the
next keyboard close. Scoped via a height-originated flag so width/rotation
and the forced reconnect/foreground re-asserts stay unguarded and always run.

Tests: reducer coverage for the reopen-during-debounce re-defer + a wiring
assertion; 705 mobile terminal/session pass; tsc, oxlint clean.
This commit is contained in:
Brennan Benson 2026-07-14 02:52:50 -07:00 committed by GitHub
parent 31f643ca42
commit 8e17e75a3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 368 additions and 191 deletions

View File

@ -1080,14 +1080,6 @@ export default function SessionScreen() {
// the row) without any window-dim change. Tracking the measured width lets the
// refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
// Why: a new agent terminal can fit before the accessory/live-input dock lays
// out, over-fitting the PTY so its bottom-pinned input box hides behind the
// dock; tracking the settled height lets the refit hook correct it.
const [terminalFrameHeight, setTerminalFrameHeight] = useState(0)
// Why: lets the height refit skip keyboard-driven resizes (never reflow the
// PTY per keystroke); mirrors keyboardHeight but readable synchronously.
const keyboardVisibleRef = useRef(false)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
@ -2596,7 +2588,7 @@ export default function SessionScreen() {
// Why: viewport refits for layout changes outside the subscribe path
// (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
// see terminal-viewport-refit.ts for the full rationale.
useTerminalViewportRefit({
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
@ -2609,19 +2601,17 @@ export default function SessionScreen() {
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
terminalFrameWidth,
terminalFrameHeight,
keyboardVisibleRef,
unsubscribeTerminal,
subscribeToTerminal
})
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
keyboardVisibleRef.current = true
notifyKeyboardVisibility(true)
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
keyboardVisibleRef.current = false
notifyKeyboardVisibility(false)
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
@ -2632,7 +2622,7 @@ export default function SessionScreen() {
showSub.remove()
hideSub.remove()
}
}, [])
}, [notifyKeyboardVisibility])
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
@ -4842,12 +4832,12 @@ export default function SessionScreen() {
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
// Track width AND height so the refit hook re-fits on sidebar/
// fold/rotation (width) and on the dock settling (height).
// Why: notify height imperatively so dock settling re-fits the
// PTY without rerendering SessionScreen for layout callbacks.
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))
notifyTerminalFrameHeight(nextHeight)
}}
>
{terminals.map((terminal) => (

View File

@ -1,5 +1,7 @@
import type { RpcResponse } from '../transport/types'
export type TerminalUpdateViewportCapability = 'unknown' | 'supported' | 'unsupported'
export type TerminalViewportRefitTargetState = {
activeHandle: string | null
expectedHandle: string
@ -24,18 +26,68 @@ export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean
return (response.result as { applied?: unknown }).applied === true
}
// Why: re-fit when the flex-bounded frame height settles, but never while the
// keyboard is visible so an IME window resize (Android adjustResize) can't
// reflow the PTY per keystroke — it re-fits again once the keyboard closes.
export function shouldRefitOnFrameHeightChange(state: {
previousHeight: number
nextHeight: number
keyboardVisible: boolean
}): boolean {
if (state.keyboardVisible) {
return false
export function resolveTerminalUpdateViewportCapability(
response: RpcResponse
): TerminalUpdateViewportCapability {
if (response.ok) {
return 'supported'
}
return response.error.code === 'method_not_found' ? 'unsupported' : 'unknown'
}
// Why: defer height refits while typing, then coalesce every skipped layout
// change into one correction after the keyboard closes.
export type TerminalFrameHeightRefitState = {
frameHeight: number
keyboardVisible: boolean
pending: boolean
}
export type TerminalFrameHeightRefitEvent =
| { type: 'frame-height'; height: number }
| { type: 'keyboard-visibility'; visible: boolean }
| { type: 'refit-committed' }
export function reduceTerminalFrameHeightRefit(
state: TerminalFrameHeightRefitState,
event: TerminalFrameHeightRefitEvent
): { state: TerminalFrameHeightRefitState; shouldRefit: boolean } {
if (event.type === 'refit-committed') {
// Why: the debounced height refit is firing. The keyboard can reopen during
// the debounce window, so re-check here and re-defer rather than reflow the
// PTY mid-keystroke; it runs on the next keyboard close.
if (state.keyboardVisible) {
return { state: { ...state, pending: true }, shouldRefit: false }
}
return { state: { ...state, pending: false }, shouldRefit: true }
}
if (event.type === 'keyboard-visibility') {
if (event.visible === state.keyboardVisible) {
return { state, shouldRefit: false }
}
if (event.visible) {
return { state: { ...state, keyboardVisible: true }, shouldRefit: false }
}
return {
state: { ...state, keyboardVisible: false, pending: false },
shouldRefit: state.pending
}
}
if (event.height === state.frameHeight) {
return { state, shouldRefit: false }
}
if (state.keyboardVisible) {
return {
state: { ...state, frameHeight: event.height, pending: true },
shouldRefit: false
}
}
return {
state: { ...state, frameHeight: event.height, pending: false },
shouldRefit: true
}
return state.previousHeight !== state.nextHeight
}
export function isTerminalViewportRefitTargetCurrent(

View File

@ -5,7 +5,11 @@ import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent,
shouldRefitOnFrameHeightChange
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8')
@ -49,44 +53,86 @@ describe('terminal viewport refit', () => {
expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
})
it('refits on a frame-height change only while the keyboard is closed', () => {
// The dock settling after a new agent terminal's first fit changes the frame
// height; refitting then stops the PTY over-fitting behind the dock. An IME
// that resizes the window (Android) must not reflow the PTY while typing.
// Height settled, keyboard closed → refit.
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 600,
nextHeight: 520,
keyboardVisible: false
})
).toBe(true)
// Unchanged height → no refit (don't churn on unrelated re-layouts).
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 520,
nextHeight: 520,
keyboardVisible: false
})
).toBe(false)
// Height changed while the keyboard is up → skip (never reflow while typing).
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 600,
nextHeight: 320,
keyboardVisible: true
})
).toBe(false)
it('coalesces keyboard-visible frame-height churn into one refit after close', () => {
let state: TerminalFrameHeightRefitState = {
frameHeight: 600,
keyboardVisible: false,
pending: false
}
let refitCount = 0
const dispatch = (event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(state, event)
state = transition.state
refitCount += Number(transition.shouldRefit)
}
dispatch({ type: 'keyboard-visibility', visible: true })
dispatch({ type: 'frame-height', height: 540 })
dispatch({ type: 'frame-height', height: 520 })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(0)
expect(state.pending).toBe(true)
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(1)
expect(state.pending).toBe(false)
dispatch({ type: 'frame-height', height: 500 })
expect(refitCount).toBe(2)
})
it('routes the height effect through the keyboard-guarded decision helper', () => {
const start = hookSource.indexOf('const prevFrameHeightRef = useRef(terminalFrameHeight)')
it('routes imperative height notifications through the keyboard-aware reducer', () => {
const start = hookSource.indexOf('const notifyFrameHeightRefitEvent = useCallback(')
expect(start).toBeGreaterThanOrEqual(0)
const heightEffect = hookSource.slice(start, start + 500)
expect(heightEffect).toContain('shouldRefitOnFrameHeightChange({')
expect(heightEffect).toContain('keyboardVisible: keyboardVisibleRef.current')
expect(heightEffect).toContain('viewportMeasuredRef.current = false')
expect(heightEffect).toContain('scheduleViewportRefit()')
const notifier = hookSource.slice(start, start + 1_300)
expect(notifier).toContain('reduceTerminalFrameHeightRefit(')
expect(notifier).toContain("{ type: 'frame-height', height }")
expect(notifier).toContain("{ type: 'keyboard-visibility', visible }")
expect(notifier).toContain('viewportMeasuredRef.current = false')
expect(notifier).toContain('scheduleViewportRefit({ heightOriginated: true })')
})
it('re-defers a height refit if the keyboard reopens before the debounce fires', () => {
// Settle while the keyboard is up -> deferred (pending), no refit.
let r = reduceTerminalFrameHeightRefit(
{ frameHeight: 600, keyboardVisible: true, pending: false },
{ type: 'frame-height', height: 520 }
)
expect(r.shouldRefit).toBe(false)
expect(r.state.pending).toBe(true)
// Keyboard closes -> refit scheduled (the hook arms a 150ms timer here).
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: false })
expect(r.shouldRefit).toBe(true)
// Keyboard reopens inside the debounce window, then the timer fires:
// the committed refit must NOT reflow while typing, and stays owed.
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: true })
const committed = reduceTerminalFrameHeightRefit(r.state, { type: 'refit-committed' })
expect(committed.shouldRefit).toBe(false)
expect(committed.state.pending).toBe(true)
// Keyboard closes again -> rescheduled -> now the committed refit runs.
const rescheduled = reduceTerminalFrameHeightRefit(committed.state, {
type: 'keyboard-visibility',
visible: false
})
expect(rescheduled.shouldRefit).toBe(true)
const ran = reduceTerminalFrameHeightRefit(rescheduled.state, { type: 'refit-committed' })
expect(ran.shouldRefit).toBe(true)
expect(ran.state.pending).toBe(false)
})
it('re-checks the keyboard at fire time only for height-originated refits', () => {
const start = hookSource.indexOf('refitTimerRef.current = setTimeout(')
expect(start).toBeGreaterThanOrEqual(0)
const timerBody = hookSource.slice(start, start + 900)
// The height flag scopes the guard; forced/width refits stay unguarded.
expect(timerBody).toContain('if (heightOriginatedRefitRef.current)')
expect(timerBody).toContain("type: 'refit-committed'")
expect(timerBody).toContain('if (!decision.shouldRefit)')
})
it('is wired into the session screen', () => {
@ -94,13 +140,22 @@ describe('terminal viewport refit', () => {
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
expect(sessionSource).toContain('connState,')
// The session must feed the frame height and the keyboard-visible ref, or the
// guarded height effect never sees the dock settle / keyboard state.
expect(sessionSource).toContain('terminalFrameHeight,')
expect(sessionSource).toContain('keyboardVisibleRef,')
expect(sessionSource).toContain('keyboardVisibleRef.current = true')
expect(sessionSource).toContain(
'setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))'
expect(sessionSource).toContain('notifyTerminalFrameHeight(nextHeight)')
expect(sessionSource).toContain('notifyKeyboardVisibility(true)')
expect(sessionSource).toContain('notifyKeyboardVisibility(false)')
})
it('does not rerender SessionScreen for frame-height-only layout changes', () => {
// The imperative notifier keeps a dock-settling burst off React's render path.
expect(sessionSource).not.toContain('setTerminalFrameHeight')
expect(sessionSource).not.toContain('const [terminalFrameHeight,')
})
it('defers height-only window resizes while the keyboard is visible', () => {
const start = hookSource.indexOf('const prevWindowDimsRef')
const windowEffect = hookSource.slice(start, start + 1_100)
expect(windowEffect).toContain(
'prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible'
)
})
@ -142,6 +197,42 @@ describe('terminal viewport refit', () => {
expect(resubscribeIndex).toBeGreaterThan(rpcIndex)
})
it('falls back to legacy resubscribe when an older desktop lacks updateViewport', () => {
const unsupported = {
id: 'old-host',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: terminal.updateViewport' },
_meta: { runtimeId: 'runtime' }
} satisfies RpcResponse
expect(isTerminalUpdateViewportUpdated(unsupported)).toBe(false)
expect(
resolveTerminalUpdateViewportCapability({
...unsupported,
error: { code: 'temporary_failure', message: 'retryable' }
})
).toBe('unknown')
let capability: TerminalUpdateViewportCapability = 'unknown'
let probeCount = 0
for (let refit = 0; refit < 10; refit += 1) {
if (capability === 'unsupported') {
continue
}
probeCount += 1
capability = resolveTerminalUpdateViewportCapability(unsupported)
}
expect(probeCount).toBe(1)
const responseCheckIndex = hookSource.indexOf('isTerminalUpdateViewportUpdated(response)')
const unsubscribeIndex = hookSource.indexOf('unsubscribeTerminal(handle)', responseCheckIndex)
const subscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)', unsubscribeIndex)
expect(responseCheckIndex).toBeGreaterThanOrEqual(0)
expect(unsubscribeIndex).toBeGreaterThan(responseCheckIndex)
expect(subscribeIndex).toBeGreaterThan(unsubscribeIndex)
expect(hookSource).toContain("updateViewportCapabilityRef.current !== 'unsupported'")
expect(hookSource).toContain("updateViewportCapabilityRef.current = 'unknown'")
})
it('reflows the local xterm scrollback after a successful updateViewport', () => {
// Why: updateViewport may only record an informational mobile viewport in
// desktop mode. Reflow local scrollback only after the server says it

View File

@ -8,7 +8,11 @@ import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent,
shouldRefitOnFrameHeightChange
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
export type TerminalViewportDims = { cols: number; rows: number }
@ -33,23 +37,24 @@ type TerminalViewportRefitOptions = {
// tab-strip change. Carries that measured width so those resizes re-fit the PTY;
// the 150ms debounce coalesces the stream of drag widths into one settle-time refit.
terminalFrameWidth: number
// Why: the frame height settles when the accessory/live-input dock lays out
// after a new agent terminal's first fit; carrying it re-fits the PTY so its
// rows stop overflowing behind the dock. See shouldRefitOnFrameHeightChange.
terminalFrameHeight: number
// Why: gate the height refit so a keyboard-driven resize never reflows the PTY.
keyboardVisibleRef: RefObject<boolean>
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
}
type TerminalViewportRefitNotifications = {
notifyTerminalFrameHeight: (height: number) => void
notifyKeyboardVisibility: (visible: boolean) => void
}
// Why: re-measure the phone viewport when layout-affecting state changes
// outside the subscribe path — the tab strip toggling visibility, and the
// window itself resizing (fold/unfold on foldables, orientation rotation,
// split-screen). Without the resize trigger, a PTY fitted on the folded
// cover screen stays at cover-screen cols after unfolding and the terminal
// renders in only part of the display (#4579's "cut in half" symptom).
export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): void {
export function useTerminalViewportRefit(
options: TerminalViewportRefitOptions
): TerminalViewportRefitNotifications {
const {
activeHandleRef,
terminalRefs,
@ -63,8 +68,6 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
tabStripVisible,
textScale,
terminalFrameWidth,
terminalFrameHeight,
keyboardVisibleRef,
unsubscribeTerminal,
subscribeToTerminal
} = options
@ -73,101 +76,130 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
const refitRunSeqRef = useRef(0)
const forceNextRefitRef = useRef(false)
const disposedRef = useRef(false)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
const updateViewportCapabilityRef = useRef<TerminalUpdateViewportCapability>('unknown')
const frameHeightRefitStateRef = useRef<TerminalFrameHeightRefitState>({
frameHeight: 0,
keyboardVisible: false,
pending: false
})
// Why: marks the currently-armed timer as a height refit so its callback can
// re-check the keyboard at fire time. Non-height refits (width/rotation and
// the forced reconnect/foreground re-asserts) stay unguarded so they always run.
const heightOriginatedRefitRef = useRef(false)
const scheduleViewportRefit = useCallback(
(options?: { heightOriginated?: boolean }) => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
// Why: a height refit deferred at keyboard-close can fire after the keyboard
// reopened within the 150ms debounce; re-check and re-defer so we never
// reflow the PTY mid-keystroke. Scoped via the height-originated flag.
if (heightOriginatedRefitRef.current) {
heightOriginatedRefitRef.current = false
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
type: 'refit-committed'
})
frameHeightRefitStateRef.current = decision.state
if (!decision.shouldRefit) {
return
}
}
if (!isCurrentTarget()) {
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}, [
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
])
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
updateViewportCapabilityRef.current =
resolveTerminalUpdateViewportCapability(response)
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
}
}
if (!isCurrentTarget()) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
},
[
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
]
)
const scheduleForcedViewportRefit = useCallback(() => {
forceNextRefitRef.current = true
scheduleViewportRefit()
@ -203,6 +235,11 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
return
}
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
// Why: adjustResize can change only window height while the IME is open;
// the frame-height notifier schedules one correction after it closes.
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
@ -235,26 +272,28 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
// Why: re-fit when the frame height settles after a new agent terminal's
// first fit so its rows stop overflowing behind the dock; the keyboard guard
// keeps an IME resize from reflowing the PTY. The refit's row-count guard
// makes a same-row height change a no-op.
const prevFrameHeightRef = useRef(terminalFrameHeight)
useEffect(() => {
const previousHeight = prevFrameHeightRef.current
prevFrameHeightRef.current = terminalFrameHeight
if (
!shouldRefitOnFrameHeightChange({
previousHeight,
nextHeight: terminalFrameHeight,
keyboardVisible: keyboardVisibleRef.current
})
) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [terminalFrameHeight, keyboardVisibleRef, viewportMeasuredRef, scheduleViewportRefit])
const notifyFrameHeightRefitEvent = useCallback(
(event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, event)
frameHeightRefitStateRef.current = transition.state
if (!transition.shouldRefit) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit({ heightOriginated: true })
},
[viewportMeasuredRef, scheduleViewportRefit]
)
// Why: notify imperatively so layout churn does not rerender the full session;
// a height change during typing is coalesced into one refit after keyboard close.
const notifyTerminalFrameHeight = useCallback(
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
[notifyFrameHeightRefitEvent]
)
const notifyKeyboardVisibility = useCallback(
(visible: boolean) => notifyFrameHeightRefitEvent({ type: 'keyboard-visibility', visible }),
[notifyFrameHeightRefitEvent]
)
useEffect(() => {
if (Platform.OS !== 'ios') {
@ -286,6 +325,9 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
if (previous === 'connected' || connState !== 'connected') {
return
}
// Why: an in-place desktop upgrade may add updateViewport; reconnect is the
// narrow boundary where an old-host method_not_found cache becomes stale.
updateViewportCapabilityRef.current = 'unknown'
// Why: reconnect can restore a PTY whose host-side size changed while the
// socket was down, so equal cached dimensions still need reassertion.
viewportMeasuredRef.current = false
@ -302,4 +344,6 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
}
}
}, [])
return { notifyTerminalFrameHeight, notifyKeyboardVisibility }
}