mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(mobile): harden blank terminal recovery mounts
This commit is contained in:
@@ -6721,6 +6721,10 @@ export class OrcaRuntimeService {
|
||||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||||
}
|
||||
|
||||
hasHeadlessTerminalState(ptyId: string): boolean {
|
||||
return this.headlessTerminals.has(ptyId)
|
||||
}
|
||||
|
||||
serializeMainTerminalBuffer(
|
||||
ptyId: string,
|
||||
opts: { scrollbackRows?: number } = {}
|
||||
@@ -18722,21 +18726,8 @@ 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.
|
||||
// Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic
|
||||
// handles need the ptyId so the renderer can mount the exact owning tab.
|
||||
requestRendererTerminalTabMount(handle: string): void {
|
||||
const record = this.handles.get(handle)
|
||||
if (!record?.worktreeId) {
|
||||
|
||||
@@ -2356,17 +2356,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
let leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
const isMobile = params.client?.type === 'mobile'
|
||||
const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary)
|
||||
let rendererMountRequested = false
|
||||
|
||||
// Why: the left pane's PTY spawns asynchronously after the tab is created.
|
||||
// Mobile clients that subscribe before the PTY is ready would get a bare
|
||||
// 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).
|
||||
// Why: a never-mounted tab has no graph leaf to await; mounting the
|
||||
// exact tab lets its PTY attach without activating the worktree.
|
||||
runtime.requestRendererTerminalTabMount(params.terminal)
|
||||
rendererMountRequested = true
|
||||
try {
|
||||
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
|
||||
leaf = { ptyId }
|
||||
@@ -2777,14 +2777,13 @@ 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) {
|
||||
// Why: missing model state—not snapshot text—is the signal that this
|
||||
// PTY may never have attached; avoid remounting legitimate blank panes.
|
||||
if (
|
||||
isMobile &&
|
||||
!rendererMountRequested &&
|
||||
runtime.hasHeadlessTerminalState?.(ptyId) === false
|
||||
) {
|
||||
runtime.requestRendererTerminalTabMount(params.terminal)
|
||||
}
|
||||
let initialOutputOverflowed = false
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import type { RpcRequest } from './core'
|
||||
@@ -18,6 +11,7 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
requestRendererTerminalTabMount: () => {},
|
||||
hasHeadlessTerminalState: () => true,
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -30,22 +24,21 @@ const makeRequest = (method: string, params?: unknown): RpcRequest => ({
|
||||
})
|
||||
|
||||
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.
|
||||
it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => {
|
||||
// Why: stale preview text must not hide the missing live model/attachment.
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const requestRendererTerminalTabMount = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
requestRendererTerminalTabMount,
|
||||
hasHeadlessTerminalState: vi.fn().mockReturnValue(false),
|
||||
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),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: 'stale snapshot', 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 }),
|
||||
@@ -85,7 +78,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('does not request a renderer tab mount when the mobile snapshot has content', async () => {
|
||||
it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => {
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const requestRendererTerminalTabMount = vi.fn()
|
||||
const runtime = stubRuntime({
|
||||
@@ -95,9 +88,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
|
||||
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 }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
|
||||
@@ -17,8 +17,6 @@ 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
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
@@ -83,10 +73,7 @@ describe('requestRendererTerminalTabMount', () => {
|
||||
})
|
||||
|
||||
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).
|
||||
// Why: never-mounted workspaces expose only synthetic pty handles to mobile.
|
||||
const { runtime, send } = seedRuntime([
|
||||
{ handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' }
|
||||
])
|
||||
|
||||
@@ -14,7 +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 { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount'
|
||||
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
||||
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
|
||||
@@ -1667,26 +1667,23 @@ 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.
|
||||
// Why: background-mounting a mobile-subscribed tab attaches a PTY that this
|
||||
// renderer never mounted, without navigating the desktop (STA-1840).
|
||||
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: synthetic pty handles need persisted-tab resolution, but a miss
|
||||
// must not mount every saved terminal in a large hidden worktree.
|
||||
const mount = planMobileTerminalTabMount(useAppStore.getState(), {
|
||||
worktreeId,
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(ptyId ? { ptyId } : {})
|
||||
})
|
||||
if (mount) {
|
||||
requestBackgroundTerminalWorktreeMount(mount)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount'
|
||||
|
||||
type PlannerState = Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>
|
||||
|
||||
function state(tabCount = 1): PlannerState {
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
wt: Array.from({ length: tabCount }, (_, index) => ({
|
||||
id: `tab-${index}`,
|
||||
ptyId: `wt@@${index}`
|
||||
}))
|
||||
} as unknown as AppState['tabsByWorktree'],
|
||||
terminalLayoutsByTabId: {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('planMobileTerminalTabMount', () => {
|
||||
it('keeps real-tab requests targeted to exactly one tab', () => {
|
||||
expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({
|
||||
worktreeId: 'wt',
|
||||
tabIds: ['tab-0']
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves synthetic handles to exactly one owning tab at workspace scale', () => {
|
||||
expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({
|
||||
worktreeId: 'wt',
|
||||
tabIds: ['tab-173']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mount the whole worktree when a stale pty id has no owner', () => {
|
||||
expect(
|
||||
planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal'
|
||||
import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id'
|
||||
|
||||
export type MobileTerminalTabMountRequest = {
|
||||
worktreeId: string
|
||||
tabId?: string
|
||||
ptyId?: string
|
||||
}
|
||||
|
||||
/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */
|
||||
export function planMobileTerminalTabMount(
|
||||
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
|
||||
request: MobileTerminalTabMountRequest
|
||||
): BackgroundMountTerminalWorktreeDetail | null {
|
||||
if (!request.worktreeId) {
|
||||
return null
|
||||
}
|
||||
const tabId =
|
||||
request.tabId ??
|
||||
(request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null)
|
||||
return tabId ? { worktreeId: request.worktreeId, tabIds: [tabId] } : null
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
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.
|
||||
*/
|
||||
/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */
|
||||
export function resolveTerminalTabIdForPtyId(
|
||||
state: Pick<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
|
||||
worktreeId: string,
|
||||
|
||||
Reference in New Issue
Block a user