From 5bc09f4cfa2a5ecf81b30639bec3c502c3715a5e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:31:04 -0700 Subject: [PATCH] Allow restoring all terminals to desktop size from mobile overlay 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 --- .../MobileDriverOverlay.test.tsx | 25 ++++- .../terminal-pane/MobileDriverOverlay.tsx | 57 +++++++++- .../components/terminal-pane/TerminalPane.tsx | 91 +++++++++------- .../terminal-fit-restore.test.ts | 101 ++++++++++++++++++ .../terminal-pane/terminal-fit-restore.ts | 39 +++++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../pane-manager/mobile-driver-state.test.ts | 12 +++ .../lib/pane-manager/mobile-driver-state.ts | 4 + 12 files changed, 292 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx index f9f5ea9ba31..81db8cc0be6 100644 --- a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.test.tsx @@ -4,7 +4,10 @@ import { MobileDriverOverlay } from './MobileDriverOverlay' type OverlayProps = { actionPending: boolean + allActionLabel?: string + allActionPending?: boolean onAction: () => void | Promise + onAllAction?: () => void | Promise rootRef: (node: HTMLDivElement | null) => void } @@ -53,13 +56,17 @@ vi.mock('react', async () => { } }) -function renderOverlay(onAction: () => void | Promise): OverlayElement { +function renderOverlay( + onAction: () => void | Promise, + onAllAction?: () => void | Promise +): 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() + }) }) diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx index 80e950dd863..2fac5e8c65d 100644 --- a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx @@ -13,6 +13,7 @@ type Props = { driver: DriverState hasFitOverride: boolean onAction: () => void | Promise + onAllAction?: () => void | Promise /** 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 => { - if (actionPending) { + if (actionPending || allActionPending) { return } setActionPending(true) @@ -70,6 +74,20 @@ export function MobileDriverOverlay({ } } + const handleAllAction = async (): Promise => { + if (!onAllAction || actionPending || allActionPending) { + return + } + setAllActionPending(true) + try { + await onAllAction() + } finally { + if (mountedRef.current) { + setAllActionPending(false) + } + } + } + if (isHeldAtPhoneFit) { return ( 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 + onAllAction?: () => void | Promise 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({
{body}
-
+
{onCollapse && ( )} + {onAllAction && allActionLabel ? ( + + ) : null} {/* Focus is moved to this button only when no user input is active; see effect above. */} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index fa7a8502de3..1f67f077c8f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 => { + // 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 => { + // 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}` diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts new file mode 100644 index 00000000000..735e964df49 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts new file mode 100644 index 00000000000..4109231fe8b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -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 | null + +export async function restoreTerminalFitToDesktop( + ptyId: string, + settings: TerminalFitRestoreSettings | undefined +): Promise { + 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, + settings: TerminalFitRestoreSettings | undefined +): Promise { + const uniquePtyIds = [...new Set(ptyIds)] + const results = await Promise.all( + uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings)) + ) + return results.some(Boolean) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a28c9ac11d1..a7ea7304273 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9e8ddf91eb8..1a9f9661566 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2df99b6341d..362e402e297 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2124,7 +2124,8 @@ "c44659e09f": "モバイルで操作中", "7cffad954c": "折りたたむ", "3eed73394f": "キーボードが一時停止しています", - "faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています" + "faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています", + "54f7d6f69d": "すべての terminal のサイズを変更" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "コンテキストをコピーする", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b5d964e9cd7..75df9d2d908 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2124,7 +2124,8 @@ "c44659e09f": "모바일 원격 조작", "7cffad954c": "접기", "3eed73394f": "키보드가 일시중지되었습니다.", - "faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다." + "faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다.", + "54f7d6f69d": "모든 terminal 크기 조정" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "컨텍스트 복사", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e40a79a91f6..e25b4fc2e60 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2124,7 +2124,8 @@ "c44659e09f": "手机驾驶", "7cffad954c": "折叠", "3eed73394f": "您的键盘已暂停", - "faa367dc74": "该 terminal 的尺寸适合您的手机应用程序" + "faa367dc74": "该 terminal 的尺寸适合您的手机应用程序", + "54f7d6f69d": "Resize all terminals" }, "TerminalAgentSessionForkDialog": { "17fc841e59": "复制上下文", diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts index 94b6e15c98b..84e5ebd7fdb 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts index 3e97491e7ef..504a4c23a64 100644 --- a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts +++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts @@ -46,6 +46,10 @@ export function getDriverForPty(ptyId: string): DriverState { return driverByPtyId.get(ptyId) ?? { kind: 'idle' } } +export function getAllDrivers(): Map { + return new Map(driverByPtyId) +} + export function isPtyLocked(ptyId: string): boolean { return driverByPtyId.get(ptyId)?.kind === 'mobile' }