mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(mobile): background-mount never-mounted terminals on subscribe so they don't render blank (STA-1840)
A mobile terminal.subscribe to a tab the desktop never mounted this session — a workspace the desktop isn't currently showing, a cold-activation-deferred tab (#8597), or a cold-parked tab — has no attached PTY, so the runtime has no headless emulator and the initial snapshot is empty. The mobile terminal then renders blank (repro: on the phone, scroll to an old workspace whose terminals the desktop unmounted; they stay blank). When a mobile subscribe hits an empty snapshot (or resolves no PTY), the runtime now asks the renderer to background-mount that tab so the PTY attaches and the live data stream — already subscribed on that path — delivers its output. A never-mounted workspace has no renderer-graph leaf, so its terminals are surfaced to mobile via synthetic pty:<ptyId> handles that carry no real UI tabId. The runtime passes the ptyId through the mount request and the renderer resolves the owning tab from the persisted tab model (resolveTerminalTabIdForPtyId), falling back to a whole-worktree background mount if it cannot. The request is idempotent for already-mounted tabs and never unmounts a live pane, so the worst case is the same blank as before — no regression. Validated on an iOS simulator with a two-workspace desktop: opening the workspace the desktop was not showing rendered a fully blank terminal without the fix and the live shell prompt with it. Tests: runtime mount-request gating (real-tab / pty-form / no-ptyId / unknown), subscribe-handler empty-snapshot-vs-content, and the ptyId->tabId resolver; all 608 RPC method tests pass.
This commit is contained in:
@@ -18722,6 +18722,43 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a mobile subscribe can target a tab whose pane the desktop never
|
||||
// mounted this session — a cold-activation-deferred tab (#8597), a cold-parked
|
||||
// tab, or any tab in a workspace the desktop isn't currently showing. With no
|
||||
// mounted pane the PTY is never attached, so the runtime has no headless
|
||||
// emulator and the mobile snapshot is empty → the terminal renders blank
|
||||
// (STA-1840). Ask the renderer to background-mount that tab so it attaches/
|
||||
// cold-restores; the live data stream the subscribe already holds then
|
||||
// delivers output. Idempotent — a request for an already-mounted tab is a
|
||||
// no-op in the renderer.
|
||||
//
|
||||
// Why pass ptyId: a workspace the renderer never mounted has no renderer-graph
|
||||
// leaves, so its terminals are surfaced to mobile via synthetic `pty:<ptyId>`
|
||||
// handles that carry no real UI tabId. The renderer resolves the owning tab
|
||||
// from the ptyId; when the handle already names a real tab we send that
|
||||
// directly.
|
||||
requestRendererTerminalTabMount(handle: string): void {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record?.worktreeId) {
|
||||
return
|
||||
}
|
||||
const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId
|
||||
const ptyId = record.ptyId ?? undefined
|
||||
if (!tabId && !ptyId) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', {
|
||||
worktreeId: record.worktreeId,
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(ptyId ? { ptyId } : {})
|
||||
})
|
||||
} catch {
|
||||
// No authoritative window (shutdown/headless): the subscribe keeps its
|
||||
// existing empty-snapshot fallback.
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a leaf appears in the graph before its PTY spawns. If we issue a
|
||||
// handle while ptyId is null, the next graph sync after PTY spawn will
|
||||
// change ptyId and invalidate the handle. Wait for a connected PTY so
|
||||
|
||||
@@ -2362,6 +2362,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// scrollback+end with no live stream or phone-fit. Wait for the PTY so
|
||||
// the subscribe can proceed normally.
|
||||
if (!leaf?.ptyId && isMobile) {
|
||||
// Why: a tab the desktop never mounted this session (cold-activation
|
||||
// deferral / cold-park / a workspace the desktop isn't showing) has no
|
||||
// leaf in the runtime graph, so no PTY is attached to wait for. Ask the
|
||||
// renderer to background-mount it so one attaches (#8597, STA-1840).
|
||||
runtime.requestRendererTerminalTabMount(params.terminal)
|
||||
try {
|
||||
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
|
||||
leaf = { ptyId }
|
||||
@@ -2772,6 +2777,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
// Why: the handle resolved a ptyId (from the persisted layout graph) but
|
||||
// the runtime has no headless emulator for it — the desktop never mounted
|
||||
// this tab this session, so nothing ever attached the PTY to feed one.
|
||||
// The initial snapshot is therefore empty and the mobile terminal renders
|
||||
// blank (STA-1840). Ask the renderer to background-mount the tab; that
|
||||
// attaches the PTY and the live data stream (already subscribed above)
|
||||
// then delivers the output. Idempotent for already-mounted tabs.
|
||||
if (isMobile && !serialized?.data && read.tail.length === 0) {
|
||||
runtime.requestRendererTerminalTabMount(params.terminal)
|
||||
}
|
||||
let initialOutputOverflowed = false
|
||||
if (pendingOutputOverflowed) {
|
||||
pendingOutput.splice(0)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* STA-1840: a mobile terminal.subscribe to a tab the desktop never mounted this
|
||||
* session resolves a ptyId (from the persisted layout graph) but the runtime
|
||||
* has no headless emulator for it, so the snapshot is empty and the terminal
|
||||
* renders blank. The subscribe now asks the renderer to background-mount the tab
|
||||
* when the snapshot comes back empty. These tests pin that the mount request
|
||||
* fires on an empty snapshot and stays quiet when the snapshot has content.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
|
||||
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeService {
|
||||
return {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
requestRendererTerminalTabMount: () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
const makeRequest = (method: string, params?: unknown): RpcRequest => ({
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method,
|
||||
params
|
||||
})
|
||||
|
||||
describe('terminal.subscribe blank-tab background mount', () => {
|
||||
it('requests a renderer tab mount when a mobile subscribe resolves a ptyId but the snapshot is empty', async () => {
|
||||
// Why: STA-1840 blank path — the handle resolves a ptyId but the desktop
|
||||
// never mounted the tab this session, so there is no headless emulator and
|
||||
// serialize is empty. The subscribe must ask the renderer to background-mount
|
||||
// the tab so the PTY attaches and the live stream fills it.
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const requestRendererTerminalTabMount = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
requestRendererTerminalTabMount,
|
||||
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
// Empty snapshot: no headless emulator for a never-attached PTY.
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
cleanups.get(id)?.()
|
||||
cleanups.delete(id)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
vi.fn(),
|
||||
{
|
||||
connectionId: 'conn-phone',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1')
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('does not request a renderer tab mount when the mobile snapshot has content', async () => {
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const requestRendererTerminalTabMount = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
requestRendererTerminalTabMount,
|
||||
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
|
||||
handleMobileUnsubscribe: vi.fn(),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: 'live content', cols: 80, rows: 24, seq: 4 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
cleanups.get(id)?.()
|
||||
cleanups.delete(id)
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'phone-1', type: 'mobile' },
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}),
|
||||
vi.fn(),
|
||||
{
|
||||
connectionId: 'conn-phone',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: vi.fn(() => vi.fn())
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
|
||||
expect(requestRendererTerminalTabMount).not.toHaveBeenCalled()
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:phone-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,9 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
|
||||
// Why: subscribe streams register as remote view subscribers for Phase-5
|
||||
// query-authority suppression (terminal-query-authority.md).
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
// Why: a no-PTY / empty-snapshot mobile subscribe asks the renderer to
|
||||
// background-mount the tab (STA-1840). No-op by default.
|
||||
requestRendererTerminalTabMount: () => {},
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Mobile blank-terminal regression (STA-1840 / #8597): a mobile terminal.subscribe
|
||||
* to a tab the desktop never mounted this session (cold-activation deferral,
|
||||
* cold-park, or a workspace the desktop isn't showing) has no attached PTY, so
|
||||
* the runtime has no headless emulator and the snapshot is empty → the terminal
|
||||
* renders blank. The runtime now asks the renderer to background-mount that tab
|
||||
* (terminal:requestTabMount) so the PTY attaches and streams. These tests pin
|
||||
* the request's gating: it fires for a known handle whether or not the handle
|
||||
* carries a ptyId (both blank paths), skips pty-form/unknown handles, and
|
||||
* degrades safely when there is no window.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
type HandleSeed = {
|
||||
handle: string
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
type RuntimeInternals = {
|
||||
handles: Map<
|
||||
string,
|
||||
HandleSeed & {
|
||||
runtimeId: string
|
||||
rendererGraphEpoch: number
|
||||
leafId: string
|
||||
ptyGeneration: number
|
||||
}
|
||||
>
|
||||
getAuthoritativeWindow: () => {
|
||||
webContents: { send: (channel: string, payload: unknown) => void }
|
||||
}
|
||||
}
|
||||
|
||||
function seedRuntime(seeds: HandleSeed[]): {
|
||||
runtime: OrcaRuntimeService
|
||||
send: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
for (const seed of seeds) {
|
||||
internals.handles.set(seed.handle, {
|
||||
...seed,
|
||||
runtimeId: 'rt-test',
|
||||
rendererGraphEpoch: 1,
|
||||
leafId: 'leaf-1',
|
||||
ptyGeneration: 1
|
||||
})
|
||||
}
|
||||
const send = vi.fn()
|
||||
internals.getAuthoritativeWindow = () => ({ webContents: { send } })
|
||||
return { runtime, send }
|
||||
}
|
||||
|
||||
describe('requestRendererTerminalTabMount', () => {
|
||||
it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => {
|
||||
const { runtime, send } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null }
|
||||
])
|
||||
|
||||
runtime.requestRendererTerminalTabMount('h1')
|
||||
|
||||
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => {
|
||||
const { runtime, send } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' }
|
||||
])
|
||||
|
||||
runtime.requestRendererTerminalTabMount('h1')
|
||||
|
||||
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
ptyId: 'wt-1@@abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => {
|
||||
// Why: a workspace the renderer never mounted has no renderer-graph leaf, so
|
||||
// its terminals surface via `pty:<ptyId>` handles with no real UI tabId. The
|
||||
// mount must still fire — carried by the ptyId — or the never-mounted tab
|
||||
// stays blank (STA-1840 regression the first fix attempt missed).
|
||||
const { runtime, send } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' }
|
||||
])
|
||||
|
||||
runtime.requestRendererTerminalTabMount('h1')
|
||||
|
||||
expect(send).toHaveBeenCalledWith('terminal:requestTabMount', {
|
||||
worktreeId: 'wt-1',
|
||||
ptyId: 'wt-1@@abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not request a mount when a pty-form handle has no ptyId', () => {
|
||||
const { runtime, send } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null }
|
||||
])
|
||||
|
||||
runtime.requestRendererTerminalTabMount('h1')
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not request a mount for an unknown handle', () => {
|
||||
const { runtime, send } = seedRuntime([])
|
||||
|
||||
runtime.requestRendererTerminalTabMount('missing')
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows window lookup failures so subscribe keeps its fallback', () => {
|
||||
const { runtime } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null }
|
||||
])
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
internals.getAuthoritativeWindow = () => {
|
||||
throw new Error('no window')
|
||||
}
|
||||
|
||||
expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -2821,6 +2821,9 @@ export type PreloadApi = {
|
||||
onRequestTerminalCreate: (
|
||||
callback: (data: RuntimeTerminalCreateRequestPayload) => void
|
||||
) => () => void
|
||||
onRequestTerminalTabMount: (
|
||||
callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void
|
||||
) => () => void
|
||||
replyTerminalCreate: (reply: {
|
||||
requestId: string
|
||||
tabId?: string
|
||||
|
||||
@@ -3495,6 +3495,16 @@ const api = {
|
||||
ipcRenderer.on('terminal:requestTabCreate', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener)
|
||||
},
|
||||
onRequestTerminalTabMount: (
|
||||
callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { worktreeId: string; tabId?: string; ptyId?: string }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('terminal:requestTabMount', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:requestTabMount', listener)
|
||||
},
|
||||
replyTerminalCreate: (reply: {
|
||||
requestId: string
|
||||
tabId?: string
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping
|
||||
import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel'
|
||||
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
|
||||
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
|
||||
import { resolveTerminalTabIdForPtyId } from '@/lib/terminal-tab-for-pty-id'
|
||||
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
||||
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
|
||||
@@ -1666,6 +1667,29 @@ export function useIpcEvents(): void {
|
||||
)
|
||||
)
|
||||
|
||||
// Why: a mobile subscribe against a terminal this renderer never mounted
|
||||
// this session (cold-activation deferral / cold-park / a workspace the
|
||||
// desktop isn't showing) has no attached PTY, so its snapshot is empty and
|
||||
// the mobile terminal is blank. Background-mount that tab so the PTY attaches
|
||||
// and streams (STA-1840). Idempotent for already-mounted tabs.
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: a never-mounted workspace has no renderer-graph leaf, so the
|
||||
// runtime only knows the ptyId (synthetic pty:<ptyId> handle) — resolve
|
||||
// the owning UI tab from the persisted tab model. Fall back to a
|
||||
// whole-worktree mount so the terminal still attaches if we can't.
|
||||
const resolvedTabId =
|
||||
tabId ??
|
||||
(ptyId ? resolveTerminalTabIdForPtyId(useAppStore.getState(), worktreeId, ptyId) : null)
|
||||
requestBackgroundTerminalWorktreeMount(
|
||||
resolvedTabId ? { worktreeId, tabIds: [resolvedTabId] } : { worktreeId }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Why: CLI-driven terminal creation sends a request and waits for the
|
||||
// tabId reply so it can resolve a handle the caller can use immediately.
|
||||
// This mirrors the browser's onRequestTabCreate/replyTabCreate pattern.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
type ResolverState = Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>
|
||||
|
||||
function state(partial: {
|
||||
tabs?: Record<string, { id: string; ptyId?: string | null }[]>
|
||||
layouts?: Record<string, { ptyIdsByLeafId?: Record<string, string> }>
|
||||
}): ResolverState {
|
||||
return {
|
||||
tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId']
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveTerminalTabIdForPtyId', () => {
|
||||
it('matches a tab by its own ptyId', () => {
|
||||
const s = state({
|
||||
tabs: {
|
||||
wt: [
|
||||
{ id: 'tab-a', ptyId: 'wt@@1' },
|
||||
{ id: 'tab-b', ptyId: 'wt@@2' }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b')
|
||||
})
|
||||
|
||||
it('matches a tab by a split leaf ptyId in its saved layout', () => {
|
||||
const s = state({
|
||||
tabs: { wt: [{ id: 'tab-a', ptyId: null }] },
|
||||
layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } }
|
||||
})
|
||||
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a')
|
||||
})
|
||||
|
||||
it('returns null when no tab owns the ptyId', () => {
|
||||
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
|
||||
expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unknown worktree', () => {
|
||||
const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } })
|
||||
expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
/**
|
||||
* Resolve the UI terminal tab id that owns a given ptyId within a worktree.
|
||||
*
|
||||
* Why: a workspace the renderer never mounted has no live pane and no
|
||||
* renderer-graph leaf, so a mobile subscribe only knows the ptyId (surfaced via
|
||||
* a synthetic `pty:<ptyId>` handle). To background-mount the pane so its PTY
|
||||
* attaches (STA-1840), we map the ptyId back to the persisted tab — either the
|
||||
* tab's own `ptyId` or a split leaf recorded in its saved layout.
|
||||
*/
|
||||
export function resolveTerminalTabIdForPtyId(
|
||||
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
|
||||
worktreeId: string,
|
||||
ptyId: string
|
||||
): string | null {
|
||||
const tabs = state.tabsByWorktree[worktreeId] ?? []
|
||||
for (const tab of tabs) {
|
||||
if (tab.ptyId === ptyId) {
|
||||
return tab.id
|
||||
}
|
||||
const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId
|
||||
if (ptyIdsByLeafId && Object.values(ptyIdsByLeafId).includes(ptyId)) {
|
||||
return tab.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2368,6 +2368,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
onActivateWorktree: () => noopUnsubscribe,
|
||||
onCreateTerminal: () => noopUnsubscribe,
|
||||
onRequestTerminalCreate: () => noopUnsubscribe,
|
||||
onRequestTerminalTabMount: () => noopUnsubscribe,
|
||||
replyTerminalCreate: () => {},
|
||||
onSplitTerminal: () => noopUnsubscribe,
|
||||
onRenameTerminal: () => noopUnsubscribe,
|
||||
|
||||
Reference in New Issue
Block a user