diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8b032c3ad99..05fff5e7cb0 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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, diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index cc92c6b3aca..9b751c5af75 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -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({ diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index 387fe9c3580..d96ced2a6f6 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -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) +} diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index de63099155b..5312e64d188 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -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') diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 6194fabc675..3091ac8c301 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -23,6 +23,8 @@ type TerminalViewportRefitOptions = { terminalFrameHeightRef: RefObject viewportRef: RefObject viewportMeasuredRef: RefObject + // 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 clientRef: RefObject deviceTokenRef: RefObject initializedHandlesRef: RefObject> @@ -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 @@ -171,6 +178,7 @@ export function useTerminalViewportRefit( terminalFrameHeightRef, viewportRef, viewportMeasuredRef, + nativeChatCoveredRef, clientRef, deviceTokenRef, initializedHandlesRef,