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:
parent
5b7fdd7ef5
commit
4fce2de494
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue