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:
parent
74b61881e4
commit
73593e5c3d
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2124,7 +2124,8 @@
|
|||
"c44659e09f": "モバイルで操作中",
|
||||
"7cffad954c": "折りたたむ",
|
||||
"3eed73394f": "キーボードが一時停止しています",
|
||||
"faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています"
|
||||
"faa367dc74": "この terminal はモバイル アプリに合わせたサイズになっています",
|
||||
"54f7d6f69d": "すべての terminal のサイズを変更"
|
||||
},
|
||||
"TerminalAgentSessionForkDialog": {
|
||||
"17fc841e59": "コンテキストをコピーする",
|
||||
|
|
|
|||
|
|
@ -2124,7 +2124,8 @@
|
|||
"c44659e09f": "모바일 원격 조작",
|
||||
"7cffad954c": "접기",
|
||||
"3eed73394f": "키보드가 일시중지되었습니다.",
|
||||
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다."
|
||||
"faa367dc74": "이 terminal은 모바일 앱에 맞게 크기가 조정됩니다.",
|
||||
"54f7d6f69d": "모든 terminal 크기 조정"
|
||||
},
|
||||
"TerminalAgentSessionForkDialog": {
|
||||
"17fc841e59": "컨텍스트 복사",
|
||||
|
|
|
|||
|
|
@ -2124,7 +2124,8 @@
|
|||
"c44659e09f": "手机驾驶",
|
||||
"7cffad954c": "折叠",
|
||||
"3eed73394f": "您的键盘已暂停",
|
||||
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序"
|
||||
"faa367dc74": "该 terminal 的尺寸适合您的手机应用程序",
|
||||
"54f7d6f69d": "Resize all terminals"
|
||||
},
|
||||
"TerminalAgentSessionForkDialog": {
|
||||
"17fc841e59": "复制上下文",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue