diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5cc9ac912..a1446d9d3 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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) => ( diff --git a/mobile/src/terminal/terminal-viewport-refit-state.ts b/mobile/src/terminal/terminal-viewport-refit-state.ts index e5787139f..96995fd8e 100644 --- a/mobile/src/terminal/terminal-viewport-refit-state.ts +++ b/mobile/src/terminal/terminal-viewport-refit-state.ts @@ -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( diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 54798a517..de6309915 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -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 diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index e8b6aafd8..e54c7c477 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -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 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('unknown') + const frameHeightRefitStateRef = useRef({ + 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 } }