Allow restoring all terminals to desktop size from mobile overlay (#5228)

Provides a "Resize all terminals" button to bulk-restore all
mobile-held terminals to desktop dimensions, avoiding the need to
reclaim each pane individually.

- Extract local and remote PTY restore logic to a shared helper
- Query all active drivers to identify mobile-controlled terminals
- Update localization bundles with the new resize-all label
This commit is contained in:
Jinjing 2026-06-11 19:08:41 -07:00 committed by GitHub
parent 74b61881e4
commit 73593e5c3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 292 additions and 52 deletions

View File

@ -4,7 +4,10 @@ import { MobileDriverOverlay } from './MobileDriverOverlay'
type OverlayProps = {
actionPending: boolean
allActionLabel?: string
allActionPending?: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
rootRef: (node: HTMLDivElement | null) => void
}
@ -53,13 +56,17 @@ vi.mock('react', async () => {
}
})
function renderOverlay(onAction: () => void | Promise<void>): OverlayElement {
function renderOverlay(
onAction: () => void | Promise<void>,
onAllAction?: () => void | Promise<void>
): OverlayElement {
hookRuntime.stateIndex = 0
hookRuntime.refIndex = 0
return MobileDriverOverlay({
driver: { kind: 'mobile', clientId: 'phone-1' } as never,
hasFitOverride: false,
onAction
onAction,
onAllAction
}) as OverlayElement
}
@ -93,4 +100,18 @@ describe('MobileDriverOverlay', () => {
expect(overlay.props.actionPending).toBe(false)
})
it('exposes an all-terminals restore action when provided', async () => {
const onAction = vi.fn()
const onAllAction = vi.fn()
const overlay = renderOverlay(onAction, onAllAction)
expect(overlay.props.allActionLabel).toBe('Resize all terminals')
expect(overlay.props.allActionPending).toBe(false)
await overlay.props.onAllAction?.()
expect(onAction).not.toHaveBeenCalled()
expect(onAllAction).toHaveBeenCalledOnce()
})
})

View File

@ -13,6 +13,7 @@ type Props = {
driver: DriverState
hasFitOverride: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
/** Identifier class on the rendered root, used by e2e selectors. */
rootClassName?: string
}
@ -24,6 +25,7 @@ export function MobileDriverOverlay({
driver,
hasFitOverride,
onAction,
onAllAction,
rootClassName
}: Props): ReactElement | null {
const isMobileDriving = driver.kind === 'mobile'
@ -34,14 +36,16 @@ export function MobileDriverOverlay({
createMobileDriverOverlayCollapseState(driverClientId)
)
const [actionPending, setActionPending] = useState(false)
const [allActionPending, setAllActionPending] = useState(false)
const mountedRef = useRef(false)
const setOverlayRootRef = useCallback((node: HTMLDivElement | null): void => {
mountedRef.current = node !== null
if (node) {
// Why: take-back can resolve after the overlay renders null; a later
// mobile session must not inherit the stale disabled state.
// Why: take-back/restore can resolve after the overlay renders null; a
// later mobile session must not inherit stale disabled state.
setActionPending(false)
setAllActionPending(false)
}
}, [])
@ -57,7 +61,7 @@ export function MobileDriverOverlay({
}
const handleAction = async (): Promise<void> => {
if (actionPending) {
if (actionPending || allActionPending) {
return
}
setActionPending(true)
@ -70,6 +74,20 @@ export function MobileDriverOverlay({
}
}
const handleAllAction = async (): Promise<void> => {
if (!onAllAction || actionPending || allActionPending) {
return
}
setAllActionPending(true)
try {
await onAllAction()
} finally {
if (mountedRef.current) {
setAllActionPending(false)
}
}
}
if (isHeldAtPhoneFit) {
return (
<LoudOverlay
@ -81,7 +99,13 @@ export function MobileDriverOverlay({
body="The session is still being held at the dimensions your phone last reported. Restore to use it on your desktop."
actionLabel="Restore desktop size"
actionPending={actionPending}
allActionLabel={translate(
'auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d',
'Resize all terminals'
)}
allActionPending={allActionPending}
onAction={handleAction}
onAllAction={onAllAction ? handleAllAction : undefined}
tone="held"
rootRef={setOverlayRootRef}
rootClassName={rootClassName}
@ -111,7 +135,13 @@ export function MobileDriverOverlay({
body="Output below is being typed from your phone. Take back to resume typing on the desktop, or collapse to keep watching."
actionLabel="Take back"
actionPending={actionPending}
allActionLabel={translate(
'auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d',
'Resize all terminals'
)}
allActionPending={allActionPending}
onAction={handleAction}
onAllAction={onAllAction ? handleAllAction : undefined}
onCollapse={() => setCollapseState({ driverClientId, collapsed: true })}
tone="driving"
rootRef={setOverlayRootRef}
@ -126,7 +156,10 @@ type LoudOverlayProps = {
body: string
actionLabel: string
actionPending: boolean
allActionLabel?: string
allActionPending?: boolean
onAction: () => void | Promise<void>
onAllAction?: () => void | Promise<void>
onCollapse?: () => void
tone: 'driving' | 'held'
rootRef?: (node: HTMLDivElement | null) => void
@ -139,7 +172,10 @@ function LoudOverlay({
body,
actionLabel,
actionPending,
allActionLabel,
allActionPending = false,
onAction,
onAllAction,
onCollapse,
tone,
rootRef: outerRootRef,
@ -197,7 +233,7 @@ function LoudOverlay({
<div id={bodyId} className="text-sm leading-relaxed text-muted-foreground">
{body}
</div>
<div className="mt-1 flex justify-end gap-2">
<div className="mt-1 flex flex-wrap justify-end gap-2">
{onCollapse && (
<Button type="button" variant="outline" size="sm" onClick={onCollapse}>
{translate(
@ -206,6 +242,17 @@ function LoudOverlay({
)}
</Button>
)}
{onAllAction && allActionLabel ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAllAction}
disabled={actionPending || allActionPending}
>
{allActionLabel}
</Button>
) : null}
{/* Focus is moved to this button only when no user input is active; see effect above. */}
<Button
ref={actionRef}
@ -213,7 +260,7 @@ function LoudOverlay({
variant="default"
size="sm"
onClick={onAction}
disabled={actionPending}
disabled={actionPending || allActionPending}
>
{actionLabel}
</Button>

View File

@ -46,8 +46,13 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides'
import {
getAllOverrides,
getFitOverrideForPty,
onOverrideChange
} from '@/lib/pane-manager/mobile-fit-overrides'
import {
getAllDrivers,
getDriverForPty,
isPtyLocked,
onDriverChange
@ -56,11 +61,6 @@ import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { isPrimarySelectionEnabled, readPrimarySelectionText } from '@/lib/primary-selection'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
@ -85,6 +85,7 @@ import {
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@ -1687,6 +1688,46 @@ export default function TerminalPane({
rightClickToPaste
})
const getMobileOwnedTerminalPtyIds = useCallback((): string[] => {
const ptyIds = new Set(getAllOverrides().keys())
for (const [ptyId, driver] of getAllDrivers()) {
if (driver.kind === 'mobile') {
ptyIds.add(ptyId)
}
}
return [...ptyIds]
}, [])
const restorePaneTerminalFit = useCallback(async (pane: ManagedPane): Promise<void> => {
// Why: local and remote runtime PTYs use different transports, but the
// desktop reclaim button should have one visible recovery behavior.
const id = paneTransportsRef.current.get(pane.id)?.getPtyId()
if (!id) {
return
}
const restored = await restoreTerminalFitToDesktop(id, settingsRef.current)
if (restored) {
// Why: after the overlay unmounts, focus would otherwise stay on the
// removed button/body instead of the terminal the user just reclaimed.
pane.terminal.focus()
}
}, [])
const restoreAllTerminalFits = useCallback(
async (focusPane: ManagedPane): Promise<void> => {
// Why: a mobile session can leave multiple PTYs held at phone size; bulk
// restore follows the same reclaim path as the per-pane button.
const restored = await restoreTerminalFitsToDesktop(
getMobileOwnedTerminalPtyIds(),
settingsRef.current
)
if (restored) {
focusPane.terminal.focus()
}
},
[getMobileOwnedTerminalPtyIds]
)
const terminalShouldHandleMiddleClick = useCallback(
(target: EventTarget | null): target is Node => {
if (!(target instanceof Element)) {
@ -2028,8 +2069,8 @@ export default function TerminalPane({
// input paused (docs/mobile-presence-lock.md). (2) No mobile driver
// but a phone-fit override is still in place → indefinite hold
// (docs/mobile-fit-hold.md). MobileDriverOverlay owns the visual
// treatment and collapse-to-chip state; both branches share a
// single IPC route through restoreTerminalFit.
// treatment and collapse-to-chip state; both branches share the
// same local/remote desktop-restore route.
const driver = getDriverForPty(ptyId)
const isMobileDriving = driver.kind === 'mobile'
const hasFitOverride = getFitOverrideForPty(ptyId) !== null
@ -2042,38 +2083,8 @@ export default function TerminalPane({
driver={driver}
hasFitOverride={hasFitOverride}
rootClassName="mobile-driver-banner"
onAction={async () => {
// Why: same restore intent has two transports. Remote-runtime PTYs
// must call the environment RPC; local PTYs use the Electron IPC
// handler. Both resolve active-mobile and held-no-subscriber states.
const transport = paneTransportsRef.current.get(pane.id)
const id = transport?.getPtyId()
if (!id) {
return
}
const remoteHandle = getRemoteRuntimeTerminalHandle(id)
const environmentId =
getRemoteRuntimePtyEnvironmentId(id) ??
settingsRef.current?.activeRuntimeEnvironmentId ??
null
const result =
remoteHandle && environmentId
? await callRuntimeRpc<{ restored: boolean }>(
{ kind: 'environment', environmentId },
'terminal.restoreFit',
{ terminal: remoteHandle },
{ timeoutMs: 15_000 }
).catch(() => ({ restored: false }))
: await window.api.runtime
.restoreTerminalFit(id)
.catch(() => ({ restored: false }))
if (result.restored) {
// Why: after the overlay unmounts, focus would otherwise stay on
// the removed button/body instead of the terminal the user just
// reclaimed.
pane.terminal.focus()
}
}}
onAction={() => restorePaneTerminalFit(pane)}
onAllAction={() => restoreAllTerminalFits(pane)}
/>,
pane.container,
`mobile-driver-banner-${pane.id}`

View File

@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn()
}))
vi.mock('@/runtime/runtime-terminal-stream', () => ({
getRemoteRuntimePtyEnvironmentId: vi.fn(),
getRemoteRuntimeTerminalHandle: vi.fn()
}))
const restoreTerminalFit = vi.fn()
describe('terminal-fit-restore', () => {
beforeEach(() => {
restoreTerminalFit.mockReset()
vi.mocked(callRuntimeRpc).mockReset()
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReset()
vi.mocked(getRemoteRuntimeTerminalHandle).mockReset()
vi.stubGlobal('window', {
api: {
runtime: {
restoreTerminalFit
}
}
})
})
it('restores local terminals through desktop IPC', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockResolvedValue({ restored: true })
await expect(
restoreTerminalFitToDesktop('pty-local', { activeRuntimeEnvironmentId: 'env-unused' })
).resolves.toBe(true)
expect(restoreTerminalFit).toHaveBeenCalledWith('pty-local')
expect(callRuntimeRpc).not.toHaveBeenCalled()
})
it('restores remote terminals through the environment runtime RPC', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-one')
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue('env-one')
vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true })
await expect(restoreTerminalFitToDesktop('remote:pty-1', null)).resolves.toBe(true)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-one' },
'terminal.restoreFit',
{ terminal: 'terminal-one' },
{ timeoutMs: 15_000 }
)
expect(restoreTerminalFit).not.toHaveBeenCalled()
})
it('uses the active runtime environment when the remote PTY has no encoded environment', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue('terminal-two')
vi.mocked(getRemoteRuntimePtyEnvironmentId).mockReturnValue(null)
vi.mocked(callRuntimeRpc).mockResolvedValue({ restored: true })
await expect(
restoreTerminalFitToDesktop('remote:pty-2', { activeRuntimeEnvironmentId: 'env-active' })
).resolves.toBe(true)
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-active' },
'terminal.restoreFit',
{ terminal: 'terminal-two' },
{ timeoutMs: 15_000 }
)
})
it('deduplicates bulk restore PTYs and succeeds when any restore succeeds', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockImplementation(async (ptyId: string) => ({
restored: ptyId === 'pty-2'
}))
await expect(restoreTerminalFitsToDesktop(['pty-1', 'pty-1', 'pty-2'], null)).resolves.toBe(
true
)
expect(restoreTerminalFit).toHaveBeenCalledTimes(2)
expect(restoreTerminalFit).toHaveBeenNthCalledWith(1, 'pty-1')
expect(restoreTerminalFit).toHaveBeenNthCalledWith(2, 'pty-2')
})
it('treats failed restore transports as not restored', async () => {
vi.mocked(getRemoteRuntimeTerminalHandle).mockReturnValue(null)
restoreTerminalFit.mockRejectedValue(new Error('restore failed'))
await expect(restoreTerminalFitToDesktop('pty-local', undefined)).resolves.toBe(false)
})
})

View File

@ -0,0 +1,39 @@
import type { GlobalSettings } from '../../../../shared/types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getRemoteRuntimePtyEnvironmentId,
getRemoteRuntimeTerminalHandle
} from '@/runtime/runtime-terminal-stream'
type TerminalFitRestoreSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
export async function restoreTerminalFitToDesktop(
ptyId: string,
settings: TerminalFitRestoreSettings | undefined
): Promise<boolean> {
const remoteHandle = getRemoteRuntimeTerminalHandle(ptyId)
const environmentId =
getRemoteRuntimePtyEnvironmentId(ptyId) ?? settings?.activeRuntimeEnvironmentId ?? null
const result =
remoteHandle && environmentId
? await callRuntimeRpc<{ restored: boolean }>(
{ kind: 'environment', environmentId },
'terminal.restoreFit',
{ terminal: remoteHandle },
{ timeoutMs: 15_000 }
).catch(() => ({ restored: false }))
: await window.api.runtime.restoreTerminalFit(ptyId).catch(() => ({ restored: false }))
return result.restored
}
export async function restoreTerminalFitsToDesktop(
ptyIds: Iterable<string>,
settings: TerminalFitRestoreSettings | undefined
): Promise<boolean> {
const uniquePtyIds = [...new Set(ptyIds)]
const results = await Promise.all(
uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings))
)
return results.some(Boolean)
}

View File

@ -2124,7 +2124,8 @@
"c44659e09f": "Mobile driving",
"7cffad954c": "Collapse",
"3eed73394f": "Your keyboard is paused",
"faa367dc74": "This terminal is sized for your mobile app"
"faa367dc74": "This terminal is sized for your mobile app",
"54f7d6f69d": "Resize all terminals"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "Copy context",

View File

@ -2124,7 +2124,8 @@
"c44659e09f": "conducción móvil",
"7cffad954c": "Colapsar",
"3eed73394f": "Tu teclado está en pausa",
"faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil"
"faa367dc74": "Este terminal tiene el tamaño adecuado para tu aplicación móvil",
"54f7d6f69d": "Cambiar tamaño de todas las terminales"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "Copiar contexto",

View File

@ -2124,7 +2124,8 @@
"c44659e09f": "モバイルで操作中",
"7cffad954c": "折りたたむ",
"3eed73394f": "キーボードが一時停止しています",
"faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています"
"faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています",
"54f7d6f69d": "すべての terminal のサイズを変更"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "コンテキストをコピーする",

View File

@ -2124,7 +2124,8 @@
"c44659e09f": "모바일 원격 조작",
"7cffad954c": "접기",
"3eed73394f": "키보드가 일시중지되었습니다.",
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다."
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다.",
"54f7d6f69d": "모든 terminal 크기 조정"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "컨텍스트 복사",

View File

@ -2124,7 +2124,8 @@
"c44659e09f": "手机驾驶",
"7cffad954c": "折叠",
"3eed73394f": "您的键盘已暂停",
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序"
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序",
"54f7d6f69d": "Resize all terminals"
},
"TerminalAgentSessionForkDialog": {
"17fc841e59": "复制上下文",

View File

@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getAllDrivers,
getDriverForPty,
hydrateDrivers,
isPtyLocked,
@ -24,6 +25,17 @@ describe('mobile-driver-state', () => {
expect(isPtyLocked('pty-1')).toBe(false)
})
it('returns a defensive snapshot of all non-idle drivers', () => {
setDriverForPty('pty-1', { kind: 'mobile', clientId: 'phone-1' })
setDriverForPty('pty-2', { kind: 'desktop' })
const drivers = getAllDrivers()
drivers.clear()
expect(getDriverForPty('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-1' })
expect(getDriverForPty('pty-2')).toEqual({ kind: 'desktop' })
})
it('hydrates driver snapshots and notifies affected listeners', () => {
setDriverForPty('pty-old', { kind: 'mobile', clientId: 'phone-old' })
const listener = vi.fn()

View File

@ -46,6 +46,10 @@ export function getDriverForPty(ptyId: string): DriverState {
return driverByPtyId.get(ptyId) ?? { kind: 'idle' }
}
export function getAllDrivers(): Map<string, DriverState> {
return new Map(driverByPtyId)
}
export function isPtyLocked(ptyId: string): boolean {
return driverByPtyId.get(ptyId)?.kind === 'mobile'
}