* 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:
parent
31f643ca42
commit
8e17e75a3d
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue