Fix remote client connection and terminal split parity (#2295)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 00:51:08 -04:00 committed by GitHub
parent f8db257a17
commit 0b29bfd9d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1808 additions and 77 deletions

View File

@ -109,7 +109,7 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
return
}
if (!timer) {
// Why: Paseo coalesces terminal stream output before crossing the
// Why: terminal stream output should be coalesced before crossing the
// network. Desktop runtime subscribers need the same burst boundary.
timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS)
if (typeof timer.unref === 'function') {
@ -673,9 +673,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
}),
// Why: desktop remote sessions can have dozens of panes. One streaming RPC
// owns the binary socket and routes terminal slots by streamId, mirroring
// Paseo's slot-based terminal data plane while keeping legacy subscribe as
// the compatibility fallback.
// owns the binary socket and routes terminal slots by streamId while keeping
// legacy subscribe as the compatibility fallback.
defineStreamingMethod({
name: 'terminal.multiplex',
params: TerminalMultiplex,

View File

@ -292,6 +292,173 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it('preserves LF input frames before writing to the multiplexed PTY', async () => {
const messages: string[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-byte-preserving',
sendBinary: vi.fn(),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 9,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' }
})
})
)!
)
await vi.waitFor(() => expect(handlers.has(9)).toBe(true))
handlers.get(9)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId: 9,
seq: 2,
payload: encodeTerminalStreamText('echo one\necho two\r\n')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'echo one\necho two\r\n',
enter: false,
interrupt: false
})
)
runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving')
await dispatchPromise
})
it('preserves LF input frames before writing to the subscribed PTY', async () => {
const messages: string[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-subscribe-byte-preserving',
sendBinary: vi.fn(),
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
const streamId = JSON.parse(
messages.find((msg) => JSON.parse(msg).result?.type === 'subscribed')!
).result.streamId as number
handlers.get(streamId)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId,
seq: 1,
payload: encodeTerminalStreamText('printf a\nprintf b\r\n')
})
)!
)
await vi.waitFor(() =>
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
text: 'printf a\nprintf b\r\n',
enter: false,
interrupt: false
})
)
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
})
it('bounds live output queued while a multiplex snapshot is loading', async () => {
vi.useFakeTimers()
try {

View File

@ -6,6 +6,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { RuntimeAccessGrantList } from './RuntimeAccessGrantList'
const LOOPBACK_ADDRESS = '127.0.0.1'
@ -105,9 +106,11 @@ export function RuntimePairingUrlGenerator({
)
const [runtimeAccessGrants, setRuntimeAccessGrants] = useState<RuntimeAccessGrant[]>([])
const [isLoadingAccessGrants, setIsLoadingAccessGrants] = useState(false)
const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false)
const [revokingGrantId, setRevokingGrantId] = useState<string | null>(null)
const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
const networkInterfaceLoadIdRef = useRef(0)
const accessGrantLoadIdRef = useRef(0)
const loadRuntimeAccessGrants = useCallback(
@ -135,26 +138,35 @@ export function RuntimePairingUrlGenerator({
[]
)
useEffect(() => {
let stale = false
const loadNetworkInterfaces = async (): Promise<void> => {
const loadNetworkInterfaces = useCallback(
async (options: { showToastOnError?: boolean } = {}): Promise<void> => {
const loadId = networkInterfaceLoadIdRef.current + 1
networkInterfaceLoadIdRef.current = loadId
setRefreshingNetworkInterfaces(true)
try {
const result = await window.api.mobile.listNetworkInterfaces()
if (!stale) {
if (loadId === networkInterfaceLoadIdRef.current) {
setNetworkInterfaces(result.interfaces)
}
} catch {
// Keep the loopback option available even if interface enumeration fails.
if (loadId === networkInterfaceLoadIdRef.current && options.showToastOnError) {
toast.error('Failed to refresh network interfaces.')
}
} finally {
if (loadId === networkInterfaceLoadIdRef.current) {
setRefreshingNetworkInterfaces(false)
}
}
}
},
[]
)
useEffect(() => {
void loadNetworkInterfaces()
return () => {
stale = true
networkInterfaceLoadIdRef.current += 1
}
}, [])
}, [loadNetworkInterfaces])
useEffect(() => {
void loadRuntimeAccessGrants()
@ -269,29 +281,51 @@ export function RuntimePairingUrlGenerator({
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
Connection address
</Label>
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>
This computer ({LOOPBACK_ADDRESS})
</SelectItem>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
>
{networkInterface.name} ({networkInterface.address})
<div className="flex items-center gap-2">
<Select value={selectedAddress} onValueChange={updateSelectedAddress}>
<SelectTrigger
id="runtime-pairing-address"
size="sm"
className="min-w-[220px]"
aria-labelledby="runtime-pairing-address-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LOOPBACK_ADDRESS}>
This computer ({LOOPBACK_ADDRESS})
</SelectItem>
))}
</SelectContent>
</Select>
{networkInterfaces.map((networkInterface, index) => (
<SelectItem
key={`${networkInterface.name}:${networkInterface.address}:${index}`}
value={networkInterface.address}
>
{networkInterface.name} ({networkInterface.address})
</SelectItem>
))}
</SelectContent>
</Select>
{/* Why: server sharing uses the same interface list as Mobile,
and VPN/tailnet addresses can appear after Settings opens. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => void loadNetworkInterfaces({ showToastOnError: true })}
disabled={refreshingNetworkInterfaces}
aria-label="Refresh connection addresses"
className="text-muted-foreground"
>
<RefreshCw className={refreshingNetworkInterfaces ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh connection addresses
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="min-w-0 space-y-1">
<Label htmlFor="runtime-pairing-custom-address">Custom address</Label>

View File

@ -53,6 +53,7 @@ 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'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
@ -60,6 +61,7 @@ import {
isSyntheticSinglePaneTitle,
sanitizeTerminalLayoutPaneTitles
} from '@/lib/terminal-pane-title-sanitization'
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
import type { TerminalQuickCommand, TerminalQuickCommandScope } from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
@ -587,6 +589,8 @@ export default function TerminalPane({
// so the sidebar doesn't show a stale countdown for a pane that no
// longer exists. The closeTab path handles bulk cleanup, but closing
// a single split pane doesn't go through closeTab.
const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId() ?? null
closeWebRuntimeTerminal(ptyId)
const leafId = manager.getLeafId(paneId)
if (leafId) {
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
@ -699,6 +703,69 @@ export default function TerminalPane({
setPaneCount
})
useEffect(() => {
if (!(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) {
return
}
const manager = managerRef.current
if (!manager || !restoredLayout.root) {
return
}
const insertions = planTerminalLiveLayoutInsertions(
restoredLayout.root,
manager.getPanes().map((pane) => pane.leafId)
)
if (insertions.length === 0) {
return
}
let appliedInsertion = false
for (const insertion of insertions) {
const ptyId = restoredLayout.ptyIdsByLeafId?.[insertion.newLeafId]
const sourcePaneId = manager.getNumericIdForLeaf(insertion.sourceLeafId)
if (!ptyId || sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId)) {
continue
}
// Why: paired web terminals receive host split-pane snapshots after the
// pane manager is already mounted. Adopt the host leaf + PTY instead of
// spawning a local-only web pane.
// Before-placement swaps [source, new] after splitPane, so invert the
// host first-child ratio before applying it to the temporary order.
const splitRatio =
insertion.ratio === undefined
? undefined
: insertion.placement === 'before'
? 1 - insertion.ratio
: insertion.ratio
const createdPane = manager.splitPaneAroundLeafIds(
insertion.sourceLeafIds,
sourcePaneId,
insertion.direction,
{
...(splitRatio !== undefined && { ratio: splitRatio }),
leafId: insertion.newLeafId,
ptyId,
placement: insertion.placement
}
)
if (!createdPane) {
continue
}
appliedInsertion = true
}
if (appliedInsertion) {
persistLayoutSnapshot()
}
if (restoredLayout.activeLeafId) {
const activePaneId = manager.getNumericIdForLeaf(restoredLayout.activeLeafId)
if (activePaneId !== null) {
manager.setActivePane(activePaneId, { focus: isActive })
}
}
}, [isActive, paneCount, persistLayoutSnapshot, restoredLayout])
// Why (Activity-only pane isolation): when this TerminalPane is being
// portaled into the Activity page for a specific agent pane, hide the
// other split siblings so the user only sees that agent's pane. Uses

View File

@ -376,6 +376,26 @@ describe('connectPanePty', () => {
logSpy.mockRestore()
})
it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps({
consumeSuppressedPtyExit: vi.fn(() => true)
})
connectPanePty(createPane(2) as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
expect(onPtyExit).toBeTypeOf('function')
onPtyExit?.('pty-pane-2')
expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2')
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
expect(manager.closePane).not.toHaveBeenCalled()
})
it('does not send startup command via sendInput for local connections', async () => {
// Why: the local PTY provider already writes the command via
// writeStartupCommandWhenShellReady — sending it again from the renderer

View File

@ -661,7 +661,7 @@ describe('createRemoteRuntimePtyTransport', () => {
}
})
it('normalizes bare LF input to carriage returns before writing to the remote PTY', async () => {
it('preserves literal LF input when sending remote PTY binary frames', async () => {
vi.useFakeTimers()
try {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
@ -673,15 +673,18 @@ describe('createRemoteRuntimePtyTransport', () => {
await transport.connect({ url: '', callbacks: {} })
const { streamId } = latestSubscribePayload()
runtimeCall.mockClear()
subscriptionSendBinary.mockClear()
expect(transport.sendInput('echo one\necho two\r\n')).toBe(true)
await vi.runOnlyPendingTimersAsync()
expect(runtimeCall).not.toHaveBeenCalled()
expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
expect(frame?.opcode).toBe(TerminalStreamOpcode.Input)
expect(frame?.streamId).toBe(streamId)
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\recho two\r')
expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('echo one\necho two\r\n')
} finally {
vi.useRealTimers()
}

View File

@ -30,12 +30,6 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
function normalizeRemoteTerminalInput(data: string): string {
// Why: PTYs expect the Enter key as carriage return. Some browser/mobile
// input paths can emit bare LF, which zsh renders with PROMPT_SP `%` marks.
return data.replace(/\r\n/g, '\r').replace(/\n/g, '\r')
}
function isRemoteTerminalGoneMessage(message: string): boolean {
return (
message.includes('terminal_handle_stale') ||
@ -455,13 +449,12 @@ export function createRemoteRuntimePtyTransport(
if (!connected || !handle) {
return false
}
const normalized = normalizeRemoteTerminalInput(data)
if (!normalized) {
if (!data) {
return true
}
// Why: remote terminal input currently crosses the runtime RPC boundary;
// coalescing same-frame key bursts avoids a per-keystroke remote round-trip.
inputBatcher.push(normalized)
// Why: callers use \r or terminal.send's enter flag for semantic Enter;
// literal LF bytes from paste/programmatic input must survive the stream.
inputBatcher.push(data)
return true
},

View File

@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
describe('planTerminalLiveLayoutInsertions', () => {
it('plans a host-added split leaf from an already-mounted source leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'vertical',
placement: 'after'
}
])
})
it('plans nested missing leaves in the order splitPane can apply them', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'vertical',
placement: 'after'
},
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b'],
newLeafId: 'leaf-c',
direction: 'horizontal',
placement: 'after'
}
])
})
it('bridges a missing parent second subtree before filling the first subtree', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
},
second: { type: 'leaf', leafId: 'leaf-c' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a'])).toEqual([
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-c',
direction: 'vertical',
placement: 'after'
},
{
sourceLeafId: 'leaf-a',
sourceLeafIds: ['leaf-a'],
newLeafId: 'leaf-b',
direction: 'horizontal',
placement: 'after'
}
])
})
it('plans a parent sibling after an already-mounted first-side split with host ratio', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
ratio: 0.35,
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
},
second: { type: 'leaf', leafId: 'leaf-c' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-a', 'leaf-b'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-a', 'leaf-b'],
newLeafId: 'leaf-c',
direction: 'vertical',
placement: 'after',
ratio: 0.35
}
])
})
it('plans a missing first subtree before an already-mounted second leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before'
}
])
})
it('plans nested missing first subtrees from an anchor in the second subtree', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-c'])).toEqual([
{
sourceLeafId: 'leaf-c',
sourceLeafIds: ['leaf-c'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before'
},
{
sourceLeafId: 'leaf-c',
sourceLeafIds: ['leaf-c'],
newLeafId: 'leaf-b',
direction: 'horizontal',
placement: 'before'
}
])
})
it('plans a parent sibling before an already-mounted second-side split with host ratio', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'vertical',
ratio: 0.25,
first: { type: 'leaf', leafId: 'leaf-a' },
second: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-b' },
second: { type: 'leaf', leafId: 'leaf-c' }
}
}
expect(planTerminalLiveLayoutInsertions(layout, ['leaf-b', 'leaf-c'])).toEqual([
{
sourceLeafId: 'leaf-b',
sourceLeafIds: ['leaf-b', 'leaf-c'],
newLeafId: 'leaf-a',
direction: 'vertical',
placement: 'before',
ratio: 0.25
}
])
})
it('does not plan insertions when the layout has no mounted anchor leaf', () => {
const layout: TerminalPaneLayoutNode = {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
}
expect(planTerminalLiveLayoutInsertions(layout, [])).toEqual([])
})
})

View File

@ -0,0 +1,135 @@
import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/types'
export type TerminalLiveLayoutInsertion = {
sourceLeafId: string
sourceLeafIds: string[]
newLeafId: string
direction: TerminalPaneSplitDirection
placement: 'before' | 'after'
ratio?: number
}
function leftmostLeafId(node: TerminalPaneLayoutNode): string {
return node.type === 'leaf' ? node.leafId : leftmostLeafId(node.first)
}
function rightmostMountedLeafId(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string | null {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? node.leafId : null
}
return (
rightmostMountedLeafId(node.second, mountedLeafIds) ??
rightmostMountedLeafId(node.first, mountedLeafIds)
)
}
function leftmostMountedLeafId(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string | null {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? node.leafId : null
}
return (
leftmostMountedLeafId(node.first, mountedLeafIds) ??
leftmostMountedLeafId(node.second, mountedLeafIds)
)
}
function hasMountedLeaf(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): boolean {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId)
}
return hasMountedLeaf(node.first, mountedLeafIds) || hasMountedLeaf(node.second, mountedLeafIds)
}
function mountedLeafIdsIn(
node: TerminalPaneLayoutNode,
mountedLeafIds: ReadonlySet<string>
): string[] {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId) ? [node.leafId] : []
}
return [
...mountedLeafIdsIn(node.first, mountedLeafIds),
...mountedLeafIdsIn(node.second, mountedLeafIds)
]
}
export function planTerminalLiveLayoutInsertions(
root: TerminalPaneLayoutNode | null | undefined,
currentLeafIds: Iterable<string>
): TerminalLiveLayoutInsertion[] {
if (!root) {
return []
}
const mountedLeafIds = new Set(currentLeafIds)
const insertions: TerminalLiveLayoutInsertion[] = []
const ensureSubtree = (node: TerminalPaneLayoutNode): boolean => {
if (node.type === 'leaf') {
return mountedLeafIds.has(node.leafId)
}
const firstHasMounted = hasMountedLeaf(node.first, mountedLeafIds)
const secondHasMounted = hasMountedLeaf(node.second, mountedLeafIds)
if (!firstHasMounted && !secondHasMounted) {
return false
}
// Why: bridge the current split before filling nested descendants; once a
// child subtree is split internally, PaneManager can no longer wrap it as
// this split's sibling using a leaf-only splitPane call.
if (firstHasMounted && !secondHasMounted) {
const sourceLeafId = rightmostMountedLeafId(node.first, mountedLeafIds)
const newLeafId = leftmostLeafId(node.second)
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
insertions.push({
sourceLeafId,
sourceLeafIds: mountedLeafIdsIn(node.first, mountedLeafIds),
newLeafId,
direction: node.direction,
placement: 'after',
ratio: node.ratio
})
mountedLeafIds.add(newLeafId)
}
ensureSubtree(node.second)
ensureSubtree(node.first)
return true
}
if (!firstHasMounted && secondHasMounted) {
const sourceLeafId = leftmostMountedLeafId(node.second, mountedLeafIds)
const newLeafId = leftmostLeafId(node.first)
if (sourceLeafId && !mountedLeafIds.has(newLeafId)) {
insertions.push({
sourceLeafId,
sourceLeafIds: mountedLeafIdsIn(node.second, mountedLeafIds),
newLeafId,
direction: node.direction,
placement: 'before',
ratio: node.ratio
})
mountedLeafIds.add(newLeafId)
}
ensureSubtree(node.first)
ensureSubtree(node.second)
return true
}
ensureSubtree(node.first)
ensureSubtree(node.second)
return true
}
ensureSubtree(root)
return insertions
}

View File

@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import {
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
} from './use-terminal-pane-lifecycle'
describe('splitPaneWithOneShotStartup', () => {
@ -123,3 +124,25 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
).toBe(false)
})
})
describe('suppressIntentionalPaneCloseExit', () => {
it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => {
const suppressPtyExit = vi.fn()
const transport = {
getPtyId: vi.fn(() => 'pty-pane-2')
}
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBe('pty-pane-2')
expect(suppressPtyExit).toHaveBeenCalledWith('pty-pane-2')
})
it('does not suppress natural PTY exits that already cleared the transport id', () => {
const suppressPtyExit = vi.fn()
const transport = {
getPtyId: vi.fn(() => null)
}
expect(suppressIntentionalPaneCloseExit(transport, suppressPtyExit)).toBeNull()
expect(suppressPtyExit).not.toHaveBeenCalled()
})
})

View File

@ -141,6 +141,17 @@ type UseTerminalPaneLifecycleDeps = {
setPaneCount: React.Dispatch<React.SetStateAction<number>>
}
export function suppressIntentionalPaneCloseExit(
transport: Pick<PtyTransport, 'getPtyId'> | null | undefined,
suppressPtyExit: (ptyId: string) => void
): string | null {
const ptyId = transport?.getPtyId() ?? null
if (ptyId) {
suppressPtyExit(ptyId)
}
return ptyId
}
function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean {
const range = terminal.getSelectionPosition()
if (!range) {
@ -681,8 +692,14 @@ export function useTerminalPaneLifecycle({
panePtyBindings.delete(paneId)
}
if (transport) {
const ptyId = transport.getPtyId()
const ptyId = suppressIntentionalPaneCloseExit(
transport,
useAppStore.getState().suppressPtyExit
)
if (ptyId) {
// Why: user/CLI pane closes intentionally tear down this PTY after
// PaneManager has already promoted the sibling. Suppress that exit
// so the last-surviving pane is not mistaken for an exited tab.
syncPanePtyLayoutBinding(paneId, null)
clearTabPtyId(tabId, ptyId)
}

View File

@ -27,7 +27,11 @@ vi.mock('@/runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock
}))
import { createNewTerminalTab } from './terminal-tab-actions'
import {
closeOtherTerminalTabs,
closeTerminalTabsToRight,
createNewTerminalTab
} from './terminal-tab-actions'
describe('createNewTerminalTab', () => {
beforeEach(() => {
@ -83,3 +87,82 @@ describe('createNewTerminalTab', () => {
expect(setActiveTabType).not.toHaveBeenCalled()
})
})
describe('closeOtherTerminalTabs', () => {
beforeEach(() => {
vi.clearAllMocks()
isWebRuntimeSessionActiveMock.mockReturnValue(false)
})
it('delegates other terminal closes to the host runtime in paired web clients', () => {
const setActiveTab = vi.fn()
const closeTab = vi.fn()
isWebRuntimeSessionActiveMock.mockReturnValue(true)
getStateMock.mockReturnValue({
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
tabsByWorktree: {
'wt-1': [{ id: 'keep' }, { id: 'close-a' }, { id: 'close-b' }]
},
setActiveTab,
closeTab
})
closeOtherTerminalTabs('keep', 'wt-1')
expect(setActiveTab).toHaveBeenCalledWith('keep')
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'close-a',
environmentId: 'web-runtime'
})
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'close-b',
environmentId: 'web-runtime'
})
expect(closeTab).not.toHaveBeenCalled()
})
})
describe('closeTerminalTabsToRight', () => {
beforeEach(() => {
vi.clearAllMocks()
isWebRuntimeSessionActiveMock.mockReturnValue(false)
})
it('delegates terminal tabs to the host while still closing local editor tabs to the right', () => {
const closeTab = vi.fn()
const closeFile = vi.fn()
isWebRuntimeSessionActiveMock.mockReturnValue(true)
getStateMock
.mockReturnValueOnce({
settings: { activeRuntimeEnvironmentId: 'web-runtime' },
tabsByWorktree: {
'wt-1': [{ id: 'term-a' }, { id: 'term-b' }, { id: 'term-c' }]
},
openFiles: [{ id: 'file-b', worktreeId: 'wt-1' }],
tabBarOrderByWorktree: { 'wt-1': ['term-a', 'file-b', 'term-b', 'term-c'] },
closeTab
})
.mockReturnValue({
closeFile
})
closeTerminalTabsToRight('term-a', 'wt-1')
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(2)
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'term-b',
environmentId: 'web-runtime'
})
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'term-c',
environmentId: 'web-runtime'
})
expect(closeFile).toHaveBeenCalledWith('file-b')
expect(closeTab).not.toHaveBeenCalled()
})
})

View File

@ -109,9 +109,21 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string |
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? []
state.setActiveTab(tabId)
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
for (const tab of currentTabs) {
if (tab.id !== tabId) {
state.closeTab(tab.id)
if (closeHostTerminalTabs) {
// Why: paired web tabs are host-owned; local-only bulk close leaves
// the host to re-publish the supposedly closed terminal tabs.
void closeWebRuntimeSessionTab({
worktreeId: activeWorktreeId,
tabId: tab.id,
environmentId: runtimeEnvironmentId
})
} else {
state.closeTab(tab.id)
}
}
}
}
@ -124,6 +136,8 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
const state = useAppStore.getState()
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
const terminalIds = currentTerminalTabs.map((t) => t.id)
const terminalIdSet = new Set(terminalIds)
const orderedIds = reconcileTabOrder(
@ -139,7 +153,17 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
const rightIds = orderedIds.slice(index + 1)
for (const id of rightIds) {
if (terminalIdSet.has(id)) {
state.closeTab(id)
if (closeHostTerminalTabs) {
// Why: paired web tabs are host-owned; local-only bulk close leaves
// the host to re-publish the supposedly closed terminal tabs.
void closeWebRuntimeSessionTab({
worktreeId: activeWorktreeId,
tabId: id,
environmentId: runtimeEnvironmentId
})
} else {
state.closeTab(id)
}
} else {
useAppStore.getState().closeFile(id)
}

View File

@ -5,6 +5,7 @@ import type {
ManagedPaneInternal,
DropZone
} from './pane-manager-types'
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
import {
createDivider,
applyDividerStyles,
@ -32,6 +33,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { PaneIdentityRegistry } from './pane-identity-registry'
import { closeManagedPane, splitManagedPane } from './pane-split-close'
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
@ -100,6 +102,33 @@ export class PaneManager {
})
}
splitPaneAroundLeafIds(
sourceLeafIds: readonly string[],
fallbackPaneId: number,
direction: 'vertical' | 'horizontal',
opts?: SplitPaneAroundLeafIdsOptions
): ManagedPane | null {
return splitPaneAroundMountedSubtree({
sourceLeafIds,
fallbackPaneId,
direction,
opts,
panes: this.panes,
root: this.root,
styleOptions: this.styleOptions,
managerOptions: this.options,
getNumericIdForLeaf: (leafId) => this.identities.getNumericIdForLeaf(leafId),
createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint),
createDivider: (isVertical) => this.createDividerWrapped(isVertical),
publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints),
getDragCallbacks: () => this.getDragCallbacks(),
setActivePaneId: (id) => {
this.activePaneId = id
},
isDestroyed: () => this.destroyed
})
}
closePane(paneId: number): void {
closeManagedPane({
paneId,

View File

@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
const captureScrollState = vi.hoisted(() => vi.fn())
const wrapInSplit = vi.hoisted(() => vi.fn())
const openTerminal = vi.hoisted(() => vi.fn())
const disposeWebgl = vi.hoisted(() => vi.fn())
const scheduleSplitScrollRestore = vi.hoisted(() => vi.fn())
const updateMultiPaneState = vi.hoisted(() => vi.fn())
const applyPaneOpacity = vi.hoisted(() => vi.fn())
const applyDividerStyles = vi.hoisted(() => vi.fn())
vi.mock('./pane-tree-ops', () => ({
captureScrollState,
findPaneChildren: vi.fn(),
promoteSibling: vi.fn(),
removeDividers: vi.fn(),
safeFit: vi.fn(),
wrapInSplit
}))
vi.mock('./pane-lifecycle', () => ({
disposePane: vi.fn(),
openTerminal
}))
vi.mock('./pane-webgl-renderer', () => ({
disposeWebgl
}))
vi.mock('./pane-split-scroll', () => ({
scheduleSplitScrollRestore
}))
vi.mock('./pane-drag-reorder', () => ({
updateMultiPaneState
}))
vi.mock('./pane-divider', () => ({
applyDividerStyles,
applyPaneOpacity
}))
import { splitManagedPane } from './pane-split-close'
const TEST_LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId
class MockElement {
classList: { contains: (className: string) => boolean }
dataset: Record<string, string> = {}
parentElement: MockElement | null = null
style: Record<string, string> = {}
private descendants: MockElement[] = []
constructor(private readonly classNames: string[]) {
this.classList = {
contains: (className: string) => this.classNames.includes(className)
}
}
setQuerySelectorAllResult(descendants: MockElement[]): void {
this.descendants = descendants
}
querySelectorAll(): MockElement[] {
return this.descendants
}
}
function createScrollState(viewportY: number): ScrollState {
return {
bufferType: 'normal',
wasAtBottom: false,
firstVisibleLineContent: `line-${viewportY}`,
viewportY,
totalLines: 100
}
}
function createPane(id: number, webglAddon: unknown): ManagedPaneInternal {
const container = new MockElement(['pane'])
container.dataset.paneId = String(id)
container.dataset.leafId = TEST_LEAF_ID
return {
id,
leafId: TEST_LEAF_ID,
stablePaneId: TEST_LEAF_ID,
terminal: {
focus: vi.fn()
} as never,
container: container as unknown as HTMLElement,
xtermContainer: {} as never,
linkTooltip: {} as never,
terminalGpuAcceleration: 'auto',
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
webglAddon: webglAddon as never,
ligaturesAddon: null,
fitResizeObserver: null,
pendingObservedFitRafId: null,
fitAddon: {} as never,
searchAddon: {} as never,
serializeAddon: {} as never,
unicode11Addon: {} as never,
webLinksAddon: {} as never,
compositionHandler: null,
pendingSplitScrollState: null,
debugLabel: null
}
}
describe('splitManagedPane', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('prepares every pane under a moved mounted subtree for split reparenting', () => {
const fallbackPane = createPane(1, { dispose: vi.fn() })
const siblingPane = createPane(2, { dispose: vi.fn() })
const newPane = createPane(3, null)
const panes = new Map<number, ManagedPaneInternal>([
[fallbackPane.id, fallbackPane],
[siblingPane.id, siblingPane]
])
const root = new MockElement(['root'])
const sourceContainer = new MockElement(['pane-split'])
sourceContainer.parentElement = root
sourceContainer.setQuerySelectorAllResult([
fallbackPane.container as unknown as MockElement,
siblingPane.container as unknown as MockElement
])
const fallbackScrollState = createScrollState(11)
const siblingScrollState = createScrollState(22)
captureScrollState
.mockReturnValueOnce(fallbackScrollState)
.mockReturnValueOnce(siblingScrollState)
const result = splitManagedPane({
paneId: fallbackPane.id,
direction: 'vertical',
sourceContainer: sourceContainer as unknown as HTMLElement,
panes,
root: root as unknown as HTMLElement,
styleOptions: {},
managerOptions: {},
createPaneInternal: () => {
panes.set(newPane.id, newPane)
return newPane
},
createDivider: () => new MockElement(['pane-divider']) as unknown as HTMLElement,
publishPaneCreated: vi.fn(),
getDragCallbacks: () => ({}) as never,
setActivePaneId: vi.fn(),
isDestroyed: () => false
})
expect(result?.id).toBe(newPane.id)
expect(captureScrollState).toHaveBeenCalledWith(fallbackPane.terminal)
expect(captureScrollState).toHaveBeenCalledWith(siblingPane.terminal)
expect(fallbackPane.pendingSplitScrollState).toBe(fallbackScrollState)
expect(siblingPane.pendingSplitScrollState).toBe(siblingScrollState)
expect(disposeWebgl).toHaveBeenCalledWith(fallbackPane)
expect(disposeWebgl).toHaveBeenCalledWith(siblingPane)
expect(wrapInSplit).toHaveBeenCalledWith(
sourceContainer,
newPane.container,
true,
expect.anything(),
undefined
)
expect(scheduleSplitScrollRestore).toHaveBeenCalledTimes(2)
expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith(
1,
expect.any(Function),
fallbackPane.id,
fallbackScrollState,
expect.any(Function),
expect.any(Function)
)
expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith(
2,
expect.any(Function),
siblingPane.id,
siblingScrollState,
expect.any(Function),
expect.any(Function)
)
})
})

View File

@ -21,10 +21,17 @@ import { scheduleSplitScrollRestore } from './pane-split-scroll'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import { toPublicPane } from './pane-public-view'
type MovedPaneSplitState = {
pane: ManagedPaneInternal
scrollState: ReturnType<typeof captureScrollState>
hadWebgl: boolean
}
type SplitManagedPaneArgs = {
paneId: number
direction: 'vertical' | 'horizontal'
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
sourceContainer?: HTMLElement
panes: Map<number, ManagedPaneInternal>
root: HTMLElement
styleOptions: PaneStyleOptions
@ -45,7 +52,8 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null
if (!existing) {
return null
}
const parent = existing.container.parentElement
const existingContainer = args.sourceContainer ?? existing.container
const parent = existingContainer.parentElement
if (!parent) {
return null
}
@ -53,32 +61,78 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null
const isVertical = args.direction === 'vertical'
const divider = args.createDivider(isVertical)
// Why: wrapInSplit reparents the existing container, resetting scrollTop.
const scrollState = captureScrollState(existing.terminal)
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during the
// async settle window; scheduleSplitScrollRestore owns the restore.
existing.pendingSplitScrollState = scrollState
const movedPaneStates = prepareMovedPanesForSplit(existingContainer, existing, args.panes)
// Why: DOM reparenting can silently invalidate a WebGL context without
// firing contextlost, so dispose before the move and reattach after settle.
const hadWebgl = !!existing.webglAddon
disposeWebgl(existing)
wrapInSplit(existing.container, newPane.container, isVertical, divider, args.opts)
wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts)
args.setActivePaneId(newPane.id)
openSplitPane(args, newPane, args.opts?.cwd)
scheduleSplitScrollRestore(
(id) => args.panes.get(id),
existing.id,
scrollState,
args.isDestroyed,
hadWebgl ? reattachWebglIfNeeded : undefined
)
for (const movedPaneState of movedPaneStates) {
scheduleSplitScrollRestore(
(id) => args.panes.get(id),
movedPaneState.pane.id,
movedPaneState.scrollState,
args.isDestroyed,
movedPaneState.hadWebgl ? reattachWebglIfNeeded : undefined
)
}
return toPublicPane(newPane)
}
function prepareMovedPanesForSplit(
sourceContainer: HTMLElement,
fallbackPane: ManagedPaneInternal,
panes: Map<number, ManagedPaneInternal>
): MovedPaneSplitState[] {
const movedPanes = findManagedPanesInContainer(sourceContainer, panes)
if (movedPanes.length === 0) {
movedPanes.push(fallbackPane)
}
return movedPanes.map((pane) => {
// Why: wrapInSplit reparents moved containers, resetting browser scrollTop.
const scrollState = captureScrollState(pane.terminal)
// Why: lock prevents safeFit/fitAllPanes from restoring scroll during the
// async settle window; scheduleSplitScrollRestore owns the restore.
pane.pendingSplitScrollState = scrollState
// Why: DOM reparenting can silently invalidate a WebGL context without
// firing contextlost, so dispose before the move and reattach after settle.
const hadWebgl = !!pane.webglAddon
disposeWebgl(pane)
return { pane, scrollState, hadWebgl }
})
}
function findManagedPanesInContainer(
sourceContainer: HTMLElement,
panes: Map<number, ManagedPaneInternal>
): ManagedPaneInternal[] {
const movedPanes: ManagedPaneInternal[] = []
const appendPaneById = (paneIdValue: string | undefined): void => {
if (!paneIdValue) {
return
}
const paneId = Number(paneIdValue)
if (!Number.isFinite(paneId)) {
return
}
const pane = panes.get(paneId)
if (pane && !movedPanes.includes(pane)) {
movedPanes.push(pane)
}
}
if (sourceContainer.classList.contains('pane')) {
appendPaneById(sourceContainer.dataset.paneId)
}
for (const paneElement of sourceContainer.querySelectorAll<HTMLElement>('.pane[data-pane-id]')) {
appendPaneById(paneElement.dataset.paneId)
}
return movedPanes
}
function openSplitPane(
args: SplitManagedPaneArgs,
newPane: ManagedPaneInternal,

View File

@ -0,0 +1,147 @@
import type {
ManagedPane,
ManagedPaneInternal,
PaneManagerOptions,
PaneStyleOptions
} from './pane-manager-types'
import type { DragReorderCallbacks } from './pane-drag-reorder'
import { splitManagedPane } from './pane-split-close'
export type SplitPaneAroundLeafIdsOptions = {
ratio?: number
cwd?: string
leafId?: string
ptyId?: string
placement?: 'before' | 'after'
}
type SplitPaneAroundLeafIdsArgs = {
sourceLeafIds: readonly string[]
fallbackPaneId: number
direction: 'vertical' | 'horizontal'
opts?: SplitPaneAroundLeafIdsOptions
panes: Map<number, ManagedPaneInternal>
root: HTMLElement
styleOptions: PaneStyleOptions
managerOptions: PaneManagerOptions
getNumericIdForLeaf: (leafId: string) => number | null
createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal
createDivider: (isVertical: boolean) => HTMLElement
publishPaneCreated: (
pane: ManagedPaneInternal,
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[1]
) => void
getDragCallbacks: () => DragReorderCallbacks
setActivePaneId: (paneId: number | null) => void
isDestroyed: () => boolean
}
export function splitPaneAroundMountedSubtree(
args: SplitPaneAroundLeafIdsArgs
): ManagedPane | null {
// Why: live host reconciliation may need to add a sibling to an already
// mounted split subtree; splitting only the anchor leaf corrupts the shape.
const sourceContainer =
findMountedSubtreeContainer(args.sourceLeafIds, args) ??
args.panes.get(args.fallbackPaneId)?.container
if (!sourceContainer) {
return null
}
const createdPane = splitManagedPane({
paneId: args.fallbackPaneId,
direction: args.direction,
opts: args.opts,
sourceContainer,
panes: args.panes,
root: args.root,
styleOptions: args.styleOptions,
managerOptions: args.managerOptions,
createPaneInternal: args.createPaneInternal,
createDivider: args.createDivider,
publishPaneCreated: args.publishPaneCreated,
getDragCallbacks: args.getDragCallbacks,
setActivePaneId: args.setActivePaneId,
isDestroyed: args.isDestroyed
})
if (!createdPane || args.opts?.placement !== 'before') {
return createdPane
}
const createdInternal = args.panes.get(createdPane.id)
if (createdInternal) {
placeCreatedPaneBeforeSource(sourceContainer, createdInternal.container)
}
return createdPane
}
function findMountedSubtreeContainer(
sourceLeafIds: readonly string[],
args: Pick<SplitPaneAroundLeafIdsArgs, 'getNumericIdForLeaf' | 'panes' | 'root'>
): HTMLElement | null {
if (sourceLeafIds.length === 0) {
return null
}
const expectedLeafIds = new Set(sourceLeafIds)
const firstLeafId = sourceLeafIds[0]
if (!firstLeafId) {
return null
}
const firstPaneId = args.getNumericIdForLeaf(firstLeafId)
const firstPane = firstPaneId === null ? null : args.panes.get(firstPaneId)
let candidate: HTMLElement | null = firstPane?.container ?? null
while (candidate && candidate !== args.root) {
if (
(candidate.classList.contains('pane') || candidate.classList.contains('pane-split')) &&
setsEqual(leafIdsInContainer(candidate), expectedLeafIds)
) {
return candidate
}
candidate = candidate.parentElement
}
return null
}
function leafIdsInContainer(container: HTMLElement): Set<string> {
const leafIds = new Set<string>()
if (container.classList.contains('pane') && container.dataset.leafId) {
leafIds.add(container.dataset.leafId)
}
for (const pane of container.querySelectorAll<HTMLElement>('.pane[data-leaf-id]')) {
if (pane.dataset.leafId) {
leafIds.add(pane.dataset.leafId)
}
}
return leafIds
}
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const value of left) {
if (!right.has(value)) {
return false
}
}
return true
}
function placeCreatedPaneBeforeSource(
sourceContainer: HTMLElement,
createdContainer: HTMLElement
): boolean {
const split = createdContainer.parentElement
if (!split || sourceContainer.parentElement !== split) {
return false
}
const divider = Array.from(split.children).find(
(child): child is HTMLElement =>
child instanceof HTMLElement && child.classList.contains('pane-divider')
)
if (!divider) {
return false
}
split.replaceChildren(createdContainer, divider, sourceContainer)
return true
}

View File

@ -41,6 +41,7 @@ const runtimeEnvironmentSubscribe = vi.fn()
const runtimeCall = vi.fn()
beforeEach(() => {
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
clearRuntimeCompatibilityCacheForTests()
fsReadFile.mockReset()
fsOnChanged.mockReset()
@ -1183,4 +1184,76 @@ describe('runtime file client', () => {
})
)
})
it('delegates stopped pre-ready web shared file watch cleanup to the subscription handle', async () => {
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
const onPayload = vi.fn()
const unsubscribe = vi.fn()
let onResponse: ((response: unknown) => void) | undefined
runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => {
onResponse = callbacks.onResponse
return Promise.resolve({ unsubscribe, sendBinary: vi.fn() })
})
const stop = await subscribeRuntimeFileChanges(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/remote/repo'
},
onPayload
)
stop()
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.unwatch' })
)
onResponse?.({
id: 'ready',
ok: true,
result: { type: 'ready', subscriptionId: 'files-watch-late' },
_meta: { runtimeId: 'remote-runtime' }
})
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.unwatch' })
)
})
it('delegates stopped ready web shared file watch cleanup to the subscription handle', async () => {
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
const onPayload = vi.fn()
const unsubscribe = vi.fn()
let onResponse: ((response: unknown) => void) | undefined
runtimeEnvironmentSubscribe.mockImplementation((_args, callbacks) => {
onResponse = callbacks.onResponse
return Promise.resolve({ unsubscribe, sendBinary: vi.fn() })
})
const stop = await subscribeRuntimeFileChanges(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/remote/repo'
},
onPayload
)
onResponse?.({
id: 'ready',
ok: true,
result: { type: 'ready', subscriptionId: 'files-watch-ready' },
_meta: { runtimeId: 'remote-runtime' }
})
stop()
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'files.unwatch' })
)
})
})

View File

@ -101,6 +101,7 @@ type SharedRuntimeFileWatch = {
start: Promise<void>
unsubscribe: (() => void) | null
remoteSubscriptionId: string | null
keepStreamUntilReady: boolean
closed: boolean
}
@ -635,6 +636,7 @@ function createSharedRuntimeFileWatch(
start: Promise.resolve(),
unsubscribe: null,
remoteSubscriptionId: null,
keepStreamUntilReady: isWebRuntimeFileWatchSharedSocket(),
closed: false
}
// Why: editor reloads and the Explorer can watch the same remote worktree.
@ -664,12 +666,14 @@ function createSharedRuntimeFileWatch(
}
)
.then((subscription) => {
shared.unsubscribe = subscription.unsubscribe
if (shared.closed || sharedRuntimeFileWatches.get(key) !== shared) {
subscription.unsubscribe()
unwatchSharedRuntimeFileWatch(shared)
return
shared.unsubscribe = null
if (!shared.keepStreamUntilReady) {
unwatchSharedRuntimeFileWatch(shared)
}
}
shared.unsubscribe = subscription.unsubscribe
})
.catch((err) => {
if (sharedRuntimeFileWatches.get(key) === shared) {
@ -693,6 +697,13 @@ function handleSharedRuntimeFileWatchResponse(
)
if (event.type === 'ready') {
shared.remoteSubscriptionId = event.subscriptionId
if (shared.closed) {
shared.unsubscribe?.()
shared.unsubscribe = null
if (!shared.keepStreamUntilReady) {
unwatchSharedRuntimeFileWatch(shared)
}
}
} else if (event.type === 'changed') {
for (const listener of Array.from(shared.listeners)) {
listener.onPayload({ worktreePath, events: event.events })
@ -715,11 +726,22 @@ function closeSharedRuntimeFileWatch(key: string, shared: SharedRuntimeFileWatch
}
shared.closed = true
sharedRuntimeFileWatches.delete(key)
if (shared.keepStreamUntilReady) {
// Why: WebRuntimeClient owns shared-socket file-watch cleanup, including
// pre-ready fallback timers and late-ready files.unwatch.
shared.unsubscribe?.()
shared.unsubscribe = null
return
}
shared.unsubscribe?.()
shared.unsubscribe = null
unwatchSharedRuntimeFileWatch(shared)
}
function isWebRuntimeFileWatchSharedSocket(): boolean {
return Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}
function unwatchSharedRuntimeFileWatch(shared: SharedRuntimeFileWatch): void {
if (!shared.remoteSubscriptionId) {
return

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
activateWebRuntimeSessionTab,
closeWebRuntimeTerminal,
closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal,
@ -667,3 +668,64 @@ describe('web runtime session tab actions', () => {
})
})
})
describe('closeWebRuntimeTerminal', () => {
beforeEach(() => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('delegates remote pane close to the host runtime', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'close',
ok: true,
result: {
close: {
handle: 'terminal-1',
tabId: 'tab-1',
ptyKilled: true
}
}
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true)
await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1))
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'web-env-1',
method: 'terminal.close',
params: {
terminal: 'terminal-1'
},
timeoutMs: 15_000
})
})
it('ignores local panes and inactive web sessions', () => {
const runtimeCall = vi.fn()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeCall
}
}
})
expect(closeWebRuntimeTerminal('pty-local-1')).toBe(false)
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(false)
expect(runtimeCall).not.toHaveBeenCalled()
})
})

View File

@ -6,6 +6,7 @@ import type {
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult,
RuntimeMobileSessionTabsResult,
RuntimeTerminalClose,
RuntimeTerminalSplit
} from '../../../shared/runtime-types'
import type { AppState } from '../store/types'
@ -455,3 +456,37 @@ export function splitWebRuntimeTerminal(
})
return true
}
export function closeWebRuntimeTerminal(ptyId: string | null | undefined): boolean {
if (!ptyId) {
return false
}
const remote = parseRemoteRuntimePtyId(ptyId)
const environmentId = remote?.environmentId?.trim()
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
// Why: host-session mirror panes are detached locally in the browser, but
// the host owns the real pane graph. Close the host terminal first so later
// session snapshots cannot resurrect the locally removed pane.
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'terminal.close',
params: {
terminal: remote.handle
},
timeoutMs: 15_000
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ close: RuntimeTerminalClose }>)
})
.catch((error) => {
console.warn(
'[web-runtime-session] failed to close terminal pane:',
error instanceof Error ? error.message : String(error)
)
})
return true
}

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: these tests share one mocked browser
WebSocket/E2EE transport fixture, and splitting them would obscure the
subscription lifecycle regressions they cover. */
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'
import WebSocket, { WebSocketServer } from 'ws'
import { WebRuntimeClient } from './web-runtime-client'
@ -128,6 +131,227 @@ describe('WebRuntimeClient', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('keeps file watches on the owning WebSocket instead of opening child clients', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const handle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
const internals = client as unknown as {
childClients: Set<WebRuntimeClient>
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
}
const subscribeOnCurrentConnection = vi
.spyOn(internals, 'subscribeOnCurrentConnection')
.mockResolvedValue(handle)
const subscription = await client.subscribe(
'files.watch',
{ worktree: 'wt-1' },
{ onResponse: vi.fn() }
)
expect(subscribeOnCurrentConnection).toHaveBeenCalledWith(
'files.watch',
{ worktree: 'wt-1' },
expect.objectContaining({ onResponse: expect.any(Function) }),
undefined
)
expect(internals.childClients.size).toBe(0)
const frame = new Uint8Array([1])
subscription.sendBinary(frame)
expect(handle.sendBinary).toHaveBeenCalledWith(frame)
client.close()
})
it('unwatches a direct file watch before removing the shared local subscription', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
const internals = client as unknown as {
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
}
const subscribeOnCurrentConnection = vi
.spyOn(internals, 'subscribeOnCurrentConnection')
.mockResolvedValue(localHandle)
const unwatch = vi.spyOn(client, 'call').mockImplementation(() => {
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
return Promise.resolve({
id: 'unwatch',
ok: true,
result: { unsubscribed: true },
_meta: { runtimeId: 'runtime-web-test' }
})
})
const onResponse = vi.fn()
const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse })
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
wrappedCallbacks?.onResponse({
id: 'watch',
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: 'files-watch-1' },
_meta: { runtimeId: 'runtime-web-test' }
} as RuntimeRpcResponse<unknown> & { streaming: true })
subscription.unsubscribe()
expect(onResponse).toHaveBeenCalledTimes(1)
expect(unwatch).toHaveBeenCalledWith(
'files.unwatch',
{ subscriptionId: 'files-watch-1' },
{ timeoutMs: 5_000 }
)
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
client.close()
})
it('removes the shared local subscription when remote unwatch fails', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
const internals = client as unknown as {
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
}
const subscribeOnCurrentConnection = vi
.spyOn(internals, 'subscribeOnCurrentConnection')
.mockResolvedValue(localHandle)
const unwatchError = new Error('remote unwatch failed')
const unwatch = vi.spyOn(client, 'call').mockRejectedValue(unwatchError)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const subscription = await client.subscribe(
'files.watch',
{ worktree: 'wt-1' },
{ onResponse: vi.fn() }
)
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
wrappedCallbacks?.onResponse({
id: 'watch',
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: 'files-watch-failing-unwatch' },
_meta: { runtimeId: 'runtime-web-test' }
} as RuntimeRpcResponse<unknown> & { streaming: true })
subscription.unsubscribe()
expect(unwatch).toHaveBeenCalledWith(
'files.unwatch',
{ subscriptionId: 'files-watch-failing-unwatch' },
{ timeoutMs: 5_000 }
)
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
expect(warn).toHaveBeenCalledWith('Failed to unwatch remote file subscription:', unwatchError)
} finally {
client.close()
warn.mockRestore()
}
})
it('keeps a stopped direct file watch alive until ready so it can unwatch', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
const internals = client as unknown as {
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
}
const subscribeOnCurrentConnection = vi
.spyOn(internals, 'subscribeOnCurrentConnection')
.mockResolvedValue(localHandle)
const unwatch = vi.spyOn(client, 'call').mockResolvedValue({
id: 'unwatch',
ok: true,
result: { unsubscribed: true },
_meta: { runtimeId: 'runtime-web-test' }
})
const onResponse = vi.fn()
const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse })
const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2]
subscription.unsubscribe()
expect(unwatch).not.toHaveBeenCalled()
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
wrappedCallbacks?.onResponse({
id: 'watch',
ok: true,
streaming: true,
result: { type: 'ready', subscriptionId: 'files-watch-late' },
_meta: { runtimeId: 'runtime-web-test' }
} as RuntimeRpcResponse<unknown> & { streaming: true })
expect(onResponse).not.toHaveBeenCalled()
expect(unwatch).toHaveBeenCalledWith(
'files.unwatch',
{ subscriptionId: 'files-watch-late' },
{ timeoutMs: 5_000 }
)
await vi.waitFor(() => expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1))
client.close()
})
it('cleans up a stopped pre-ready shared file watch if ready never arrives', async () => {
vi.useFakeTimers()
const timerWindow = window as unknown as {
setTimeout: typeof setTimeout
clearTimeout: typeof clearTimeout
}
timerWindow.setTimeout = setTimeout
timerWindow.clearTimeout = clearTimeout
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() }
const internals = client as unknown as {
subscribeOnCurrentConnection: WebRuntimeClient['subscribe']
}
vi.spyOn(internals, 'subscribeOnCurrentConnection').mockResolvedValue(localHandle)
const unwatch = vi.spyOn(client, 'call')
try {
const subscription = await client.subscribe(
'files.watch',
{ worktree: 'wt-1' },
{ onResponse: vi.fn() }
)
subscription.unsubscribe()
expect(unwatch).not.toHaveBeenCalled()
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(4_999)
expect(localHandle.unsubscribe).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1)
expect(unwatch).not.toHaveBeenCalled()
} finally {
client.close()
vi.useRealTimers()
}
})
it('decrypts binary WebSocket frames into subscription callbacks', async () => {
const client = new WebRuntimeClient({
v: 2,

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: this browser runtime client owns the E2EE
WebSocket state machine, JSON-RPC request routing, streaming callbacks, and
binary frame forwarding as one transport boundary. */
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope'
import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
import type { WebPairingOffer } from './web-pairing'
import {
@ -50,7 +50,9 @@ export type WebRuntimeSubscriptionHandle = {
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 10_000
const FILE_WATCH_READY_CLEANUP_TIMEOUT_MS = 5_000
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 15_000]
const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch'])
export class WebRuntimeClient {
private ws: WebSocket | null = null
@ -101,6 +103,12 @@ export class WebRuntimeClient {
callbacks: SubscriptionCallbacks,
options?: { timeoutMs?: number }
): Promise<WebRuntimeSubscriptionHandle> {
if (SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method)) {
// Why: file watches are text-only and already have an explicit
// files.unwatch RPC, so sharing the main socket avoids exhausting the
// server's WebSocket connection cap in large browser sessions.
return this.subscribeSharedFileWatch(params, callbacks, options)
}
const client = new WebRuntimeClient(this.pairing)
this.childClients.add(client)
const closeChild = (notifySubscriptions = false): void => {
@ -138,6 +146,112 @@ export class WebRuntimeClient {
}
}
private async subscribeSharedFileWatch(
params: unknown,
callbacks: SubscriptionCallbacks,
options?: { timeoutMs?: number }
): Promise<WebRuntimeSubscriptionHandle> {
let stopped = false
let remoteSubscriptionId: string | null = null
let unwatchStarted = false
let handle: WebRuntimeSubscriptionHandle | null = null
let readyCleanupTimer: number | null = null
const clearReadyCleanupTimer = (): void => {
if (readyCleanupTimer === null) {
return
}
window.clearTimeout(readyCleanupTimer)
readyCleanupTimer = null
}
const dropLocalSubscription = (): void => {
clearReadyCleanupTimer()
handle?.unsubscribe()
}
const schedulePreReadyCleanup = (): void => {
if (readyCleanupTimer !== null) {
return
}
// Why: if a stopped watch never reaches ready/error, no remote id exists
// to unwatch; bound the lifetime of the local callback on this socket.
readyCleanupTimer = window.setTimeout(() => {
readyCleanupTimer = null
handle?.unsubscribe()
}, FILE_WATCH_READY_CLEANUP_TIMEOUT_MS)
}
const unwatchAndDropLocalSubscription = (): void => {
if (unwatchStarted) {
return
}
unwatchStarted = true
if (!remoteSubscriptionId) {
dropLocalSubscription()
return
}
clearReadyCleanupTimer()
// Why: shared files.watch streams stay on this socket, so stop the
// server watcher before removing the local callback that receives ready.
void this.call(
'files.unwatch',
{ subscriptionId: remoteSubscriptionId },
{ timeoutMs: 5_000 }
)
.catch((error) => {
console.warn('Failed to unwatch remote file subscription:', error)
})
.finally(() => {
dropLocalSubscription()
})
}
const wrappedCallbacks: SubscriptionCallbacks = {
...callbacks,
onResponse: (response) => {
if (isFileWatchReadyResponse(response)) {
remoteSubscriptionId = response.result.subscriptionId
if (stopped) {
unwatchAndDropLocalSubscription()
return
}
}
if (!stopped) {
callbacks.onResponse(response)
} else if (response.ok === false) {
dropLocalSubscription()
}
},
onError: (error) => {
if (!stopped) {
callbacks.onError?.(error)
}
},
onClose: () => {
if (!stopped) {
callbacks.onClose?.()
}
}
}
handle = await this.subscribeOnCurrentConnection(
'files.watch',
params,
wrappedCallbacks,
options
)
return {
unsubscribe: () => {
if (stopped) {
return
}
stopped = true
if (remoteSubscriptionId) {
unwatchAndDropLocalSubscription()
} else {
schedulePreReadyCleanup()
}
},
sendBinary: (bytes) => handle?.sendBinary(bytes)
}
}
private async subscribeOnCurrentConnection(
method: string,
params: unknown,
@ -529,6 +643,21 @@ function isRuntimeFailureResponse(
)
}
function isFileWatchReadyResponse(
response: RuntimeRpcResponse<unknown>
): response is RuntimeRpcSuccess<{ type: 'ready'; subscriptionId: string }> {
if (!response.ok) {
return false
}
const result = response.result
return (
!!result &&
typeof result === 'object' &&
(result as { type?: unknown }).type === 'ready' &&
typeof (result as { subscriptionId?: unknown }).subscriptionId === 'string'
)
}
function isEndResult(value: unknown): value is { type: 'end' } {
return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end'
}