fix(mobile): keep native chat from resizing the covered terminal PTY (#9988)

* fix(mobile): keep native chat from resizing the covered terminal PTY

Native chat reads the agent transcript stream and never renders the
terminal grid, but two paths still pushed phone dimensions into the
covered PTY, reflowing the desktop terminal for no benefit:

- The covered lease-only subscribe carried the cached viewport, and
  handleMobileSubscribe phone-fits the PTY whenever a viewport is
  present. The lease now omits the viewport so the host keeps the
  desktop baseline and late-binds on return to the terminal tab.
- useTerminalViewportRefit measured the still-mounted WebView under
  the chat overlay and sent terminal.updateViewport on rotation,
  keyboard, text-scale, reconnect, and iOS-resume triggers. Refits
  are now suppressed while native chat covers the active terminal;
  the triggers already mark the viewport stale, and the
  return-to-terminal resubscribe re-measures.

* fix(mobile): harden native-chat resize suppression
This commit is contained in:
Brennan Benson 2026-07-22 19:36:22 -07:00 committed by GitHub
parent 5b7fdd7ef5
commit 4fce2de494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 56 additions and 3 deletions

View File

@ -1377,7 +1377,10 @@ export default function SessionScreen() {
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: viewportRef.current ?? undefined,
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
),
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
(result) => {
@ -2455,6 +2458,7 @@ export default function SessionScreen() {
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef: showNativeChatRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCoveredByNativeChat,
mobileNativeChatSubscribeViewport,
mobileNativeChatTerminalCapabilities,
resolveMobileNativeChatTerminalStreamAction
} from './mobile-native-chat-terminal-stream'
@ -34,6 +35,17 @@ describe('mobile native-chat terminal stream lifecycle', () => {
expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 })
})
it('omits the viewport from a covered lease subscribe so the host keeps desktop dims', () => {
// Why: handleMobileSubscribe phone-fits the PTY whenever a viewport is present,
// even for a lease-only subscribe — entering chat must not resize the terminal.
expect(mobileNativeChatSubscribeViewport(true, { cols: 40, rows: 60 })).toBeUndefined()
expect(mobileNativeChatSubscribeViewport(false, { cols: 40, rows: 60 })).toEqual({
cols: 40,
rows: 60
})
expect(mobileNativeChatSubscribeViewport(false, null)).toBeUndefined()
})
it('records a cold-start cover before WebView readiness so return refreshes', () => {
expect(
resolveMobileNativeChatTerminalStreamAction({

View File

@ -35,3 +35,11 @@ export function mobileNativeChatTerminalCapabilities(covered: boolean): {
? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
: { terminalBinaryStream: 1 }
}
// Why: a covered subscribe is only an input lease — carrying phone dims would make the host phone-fit a PTY native chat never renders.
export function mobileNativeChatSubscribeViewport(
covered: boolean,
viewport: { cols: number; rows: number } | null
): { cols: number; rows: number } | undefined {
return covered ? undefined : (viewport ?? undefined)
}

View File

@ -7,6 +7,7 @@ export type TerminalViewportRefitTargetState = {
expectedHandle: string
currentRef: unknown
expectedRef: unknown
nativeChatCovered: boolean
disposed: boolean
runSeq: number
currentRunSeq: number
@ -94,6 +95,7 @@ export function isTerminalViewportRefitTargetCurrent(
state: TerminalViewportRefitTargetState
): boolean {
return (
!state.nativeChatCovered &&
!state.disposed &&
state.runSeq === state.currentRunSeq &&
state.activeHandle === state.expectedHandle &&

View File

@ -135,6 +135,18 @@ describe('terminal viewport refit', () => {
expect(timerBody).toContain('if (!decision.shouldRefit)')
})
it('suppresses refits while native chat covers the active terminal', () => {
// Why: native chat renders the transcript, not the grid — a refit there would
// reflow the desktop PTY to phone dims the user never sees.
const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(')
const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart)
const measureIndex = hookSource.indexOf('measureFitDimensions', timerStart)
expect(timerStart).toBeGreaterThanOrEqual(0)
expect(coveredCheck).toBeGreaterThan(timerStart)
expect(measureIndex).toBeGreaterThan(coveredCheck)
expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
@ -301,6 +313,7 @@ describe('terminal viewport refit', () => {
expectedHandle: 'term-1',
currentRef: expectedRef,
expectedRef,
nativeChatCovered: false,
disposed: false,
runSeq: 2,
currentRunSeq: 2
@ -313,5 +326,8 @@ describe('terminal viewport refit', () => {
).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, currentRunSeq: 3 })).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, disposed: true })).toBe(false)
expect(isTerminalViewportRefitTargetCurrent({ ...current, nativeChatCovered: true })).toBe(
false
)
})
})

View File

@ -23,6 +23,8 @@ type TerminalViewportRefitOptions = {
terminalFrameHeightRef: RefObject<number>
viewportRef: RefObject<TerminalViewportDims | null>
viewportMeasuredRef: RefObject<boolean>
// Why: while native chat covers the active terminal, a refit would push phone dims into a PTY nobody on this device is viewing.
nativeChatCoveredRef: RefObject<boolean>
clientRef: RefObject<RpcClient | null>
deviceTokenRef: RefObject<string | null>
initializedHandlesRef: RefObject<Set<string>>
@ -51,6 +53,7 @@ export function useTerminalViewportRefit(
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
@ -99,6 +102,10 @@ export function useTerminalViewportRefit(
if (!handle) {
return
}
// Why: the trigger already marked the viewport stale, and the return-to-terminal resubscribe re-measures — refitting now would resize a covered PTY.
if (nativeChatCoveredRef.current) {
return
}
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
@ -109,6 +116,7 @@ export function useTerminalViewportRefit(
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
nativeChatCovered: nativeChatCoveredRef.current,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
@ -171,6 +179,7 @@ export function useTerminalViewportRefit(
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,

View File

@ -2558,7 +2558,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
.then(() => runtime.cleanupSubscription(subscriptionId))
.catch(() => runtime.cleanupSubscription(subscriptionId))
try {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
// Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY.
await runtime.handleMobileSubscribe(ptyId, clientId, undefined)
if (closed || signal?.aborted) {
// Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it.
runtime.handleMobileUnsubscribe(ptyId, clientId)

View File

@ -12,12 +12,13 @@ const request: RpcRequest = {
params: {
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' },
viewport: { cols: 40, rows: 20 },
capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
}
}
describe('terminal lease-only subscription', () => {
it('keeps mobile input ownership without registering output delivery', async () => {
it('keeps mobile input ownership without viewport resize or output delivery', async () => {
const messages: string[] = []
const cleanups = new Map<string, () => void>()
const runtime = {