mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(mobile): preserve coalesced host activation
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { FolderWorkspace, ProjectGroup, WorkspaceSessionState } from '../../shared/types'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
ProjectGroup,
|
||||
RuntimeMobileSessionTabsResult,
|
||||
WorkspaceSessionState
|
||||
} from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
@@ -23,13 +28,15 @@ vi.mock('electron', () => electronMocks)
|
||||
|
||||
const TAB_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const PRIOR_TAB_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const PRIOR_LEAF_ID = '55555555-5555-4555-8555-555555555555'
|
||||
|
||||
function makeSession(worktreeId: string, ptyId = `${worktreeId}@@race-pty`): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabId: TAB_ID,
|
||||
activeTabIdByWorktree: { [worktreeId]: TAB_ID },
|
||||
activeTabId: PRIOR_TAB_ID,
|
||||
activeTabIdByWorktree: { [worktreeId]: PRIOR_TAB_ID },
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [
|
||||
{
|
||||
@@ -41,6 +48,16 @@ function makeSession(worktreeId: string, ptyId = `${worktreeId}@@race-pty`): Wor
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: PRIOR_TAB_ID,
|
||||
ptyId: 'serve-prior-folder-pty',
|
||||
worktreeId,
|
||||
title: 'Prior terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -50,6 +67,12 @@ function makeSession(worktreeId: string, ptyId = `${worktreeId}@@race-pty`): Wor
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
},
|
||||
[PRIOR_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: PRIOR_LEAF_ID },
|
||||
activeLeafId: PRIOR_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [PRIOR_LEAF_ID]: 'serve-prior-folder-pty' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
@@ -63,7 +86,7 @@ function createRuntime(
|
||||
group: ProjectGroup,
|
||||
ptyId?: string
|
||||
): OrcaRuntimeService {
|
||||
const session = makeSession(`folder:${folders[0]!.id}`, ptyId)
|
||||
let session = makeSession(`folder:${folders[0]!.id}`, ptyId)
|
||||
return new OrcaRuntimeService({
|
||||
getRepo: () => undefined,
|
||||
getRepos: () => [],
|
||||
@@ -71,6 +94,9 @@ function createRuntime(
|
||||
getProjectGroups: () => [group],
|
||||
getWorkspaceSession: () => session,
|
||||
getWorkspaceSessionHostIds: () => ['local'],
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
addRepo: () => {},
|
||||
updateRepo: () => undefined as never,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
@@ -89,74 +115,74 @@ function createRuntime(
|
||||
}
|
||||
|
||||
describe('mobile folder session mutation races', () => {
|
||||
it('reattaches an inventory-restored serve terminal before folder input is writable', async () => {
|
||||
const ptyId = 'serve-restarted-folder-pty'
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'restored-folder',
|
||||
projectGroupId: 'restored-folder-group',
|
||||
name: 'Restored folder',
|
||||
folderPath: '/workspace/restored-folder',
|
||||
connectionId: null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group: ProjectGroup = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const incarnationId = '33333333-3333-4333-8333-333333333333'
|
||||
const runtime = createRuntime([folder], group, ptyId)
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: null
|
||||
repo: null
|
||||
folderWorkspace: FolderWorkspace
|
||||
}>
|
||||
},
|
||||
'resolveTerminalWorkspaceLaunchScope'
|
||||
).mockResolvedValue({
|
||||
id: folderKey,
|
||||
path: folder.folderPath,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: folder
|
||||
})
|
||||
let attached = false
|
||||
let releaseAttach!: () => void
|
||||
const attach = new Promise<void>((resolve) => {
|
||||
releaseAttach = resolve
|
||||
})
|
||||
const spawn = vi.fn(async () => {
|
||||
await attach
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
it.each([
|
||||
['navigation first', 'caller', 'host'],
|
||||
['host focus first', 'host', 'caller']
|
||||
] as const)(
|
||||
'reattaches once and honors host focus with %s',
|
||||
async (_order, firstNavigation, secondNavigation) => {
|
||||
const ptyId = 'serve-restarted-folder-pty'
|
||||
const folder: FolderWorkspace = {
|
||||
id: 'restored-folder',
|
||||
projectGroupId: 'restored-folder-group',
|
||||
name: 'Restored folder',
|
||||
folderPath: '/workspace/restored-folder',
|
||||
connectionId: null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group: ProjectGroup = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const incarnationId = '33333333-3333-4333-8333-333333333333'
|
||||
const runtime = createRuntime([folder], group, ptyId)
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: null
|
||||
repo: null
|
||||
folderWorkspace: FolderWorkspace
|
||||
}>
|
||||
},
|
||||
'resolveTerminalWorkspaceLaunchScope'
|
||||
).mockResolvedValue({
|
||||
id: folderKey,
|
||||
path: folder.folderPath,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: folder
|
||||
})
|
||||
let attached = false
|
||||
let releaseAttach!: () => void
|
||||
const attach = new Promise<void>((resolve) => {
|
||||
releaseAttach = resolve
|
||||
})
|
||||
const spawn = vi.fn(async () => {
|
||||
await attach
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
})
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: folder.folderPath,
|
||||
@@ -165,37 +191,64 @@ describe('mobile folder session mutation races', () => {
|
||||
terminalHandle: 'term_restarted_folder',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${folderKey}`)
|
||||
const listedTab = listed.tabs[0]
|
||||
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_folder' })
|
||||
await expect(
|
||||
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
|
||||
text: 'before attach'
|
||||
])
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses
|
||||
})
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
const activations = Promise.all([
|
||||
runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID),
|
||||
runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
|
||||
])
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
|
||||
releaseAttach()
|
||||
const activated = await activations
|
||||
const activatedTab = activated[0].tabs[0]
|
||||
expect(activated[1].tabs[0]).toMatchObject({ status: 'ready' })
|
||||
await expect(
|
||||
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
|
||||
text: 'folder input'
|
||||
})
|
||||
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: ptyId, worktreeId: folderKey })
|
||||
)
|
||||
})
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${folderKey}`)
|
||||
const listedTab = listed.tabs[0]
|
||||
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_folder' })
|
||||
await expect(
|
||||
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
|
||||
text: 'before attach'
|
||||
})
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
const pendingGet = vi.spyOn(runtime['pendingMobileTerminalMaterializations'], 'get')
|
||||
const firstActivation = runtime.activateMobileSessionTab(
|
||||
`id:${folderKey}`,
|
||||
TAB_ID,
|
||||
undefined,
|
||||
{ navigation: firstNavigation }
|
||||
)
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
|
||||
const secondActivation = runtime.activateMobileSessionTab(
|
||||
`id:${folderKey}`,
|
||||
TAB_ID,
|
||||
undefined,
|
||||
{ navigation: secondNavigation }
|
||||
)
|
||||
await vi.waitFor(() => expect(pendingGet).toHaveBeenCalledTimes(2))
|
||||
releaseAttach()
|
||||
const activated = await Promise.all([firstActivation, secondActivation])
|
||||
const activatedTab = activated[0].tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === TAB_ID
|
||||
)
|
||||
expect(activated[1].tabs).toContainEqual(
|
||||
expect.objectContaining({ parentTabId: TAB_ID, status: 'ready' })
|
||||
)
|
||||
await expect(
|
||||
runtime.sendTerminal(
|
||||
activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing',
|
||||
{
|
||||
text: 'folder input'
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: ptyId, worktreeId: folderKey })
|
||||
)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${folderKey}`)).activeTabId).toBe(
|
||||
`${TAB_ID}::${LEAF_ID}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('fences shared SSH reattachment when the reconnect generation changes', async () => {
|
||||
const connectionId = 'reattach-generation'
|
||||
@@ -231,6 +284,10 @@ describe('mobile folder session mutation races', () => {
|
||||
}
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const runtime = createRuntime([folder], group, ptyId)
|
||||
const focusTerminal = vi.fn()
|
||||
const published: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.setNotifier({ focusTerminal } as never)
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => published.push(snapshot))
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
@@ -289,6 +346,10 @@ describe('mobile folder session mutation races', () => {
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(kill).toHaveBeenCalledWith(ptyId)
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(published).not.toContainEqual(
|
||||
expect.objectContaining({ activeTabId: `${TAB_ID}::${LEAF_ID}` })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['activate', 'close'] as const)(
|
||||
@@ -389,6 +450,10 @@ describe('mobile folder session mutation races', () => {
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const folders = [folder]
|
||||
const runtime = createRuntime(folders, group)
|
||||
const focusTerminal = vi.fn()
|
||||
const published: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.setNotifier({ focusTerminal } as never)
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => published.push(snapshot))
|
||||
vi.spyOn(
|
||||
runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{
|
||||
@@ -440,5 +505,9 @@ describe('mobile folder session mutation races', () => {
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(kill).toHaveBeenCalledWith('stale-materialized-pty')
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(published).not.toContainEqual(
|
||||
expect.objectContaining({ activeTabId: `${TAB_ID}::${LEAF_ID}` })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28261,9 +28261,12 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
it('reattaches an inventory-restored serve terminal once before accepting input', async () => {
|
||||
const ptyId = 'serve-restarted-git-pty'
|
||||
const priorPtyId = 'serve-prior-git-pty'
|
||||
const incarnationId = '91919191-9191-4919-8919-919191919191'
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeTabId: 'prior-tab',
|
||||
activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'prior-tab' },
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
@@ -28275,11 +28278,22 @@ describe('OrcaRuntimeService', () => {
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: 'prior-tab',
|
||||
ptyId: priorPtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Prior Git Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId })
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }),
|
||||
'prior-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: priorPtyId })
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
|
||||
@@ -28296,27 +28310,29 @@ describe('OrcaRuntimeService', () => {
|
||||
attached = true
|
||||
return { id: ptyId, incarnationId }
|
||||
})
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Restored Git Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_git',
|
||||
incarnationId
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => attached,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Restored Git Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_git',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
listProcesses
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const listedTab = listed.tabs[0]
|
||||
const listedTab = listed.tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === 'host-tab'
|
||||
)
|
||||
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_git' })
|
||||
await expect(
|
||||
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
|
||||
@@ -28324,15 +28340,27 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
).rejects.toThrow('terminal_not_writable')
|
||||
|
||||
const activations = Promise.all([
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab'),
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
])
|
||||
const pendingGet = vi.spyOn(runtime['pendingMobileTerminalMaterializations'], 'get')
|
||||
const navigationActivation = runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ navigation: 'caller' }
|
||||
)
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
|
||||
const hostActivation = runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ navigation: 'host' }
|
||||
)
|
||||
await vi.waitFor(() => expect(pendingGet).toHaveBeenCalledTimes(2))
|
||||
releaseAttach()
|
||||
const activated = await activations
|
||||
const activatedTab = activated[0].tabs[0]
|
||||
expect(activated[1].tabs[0]).toMatchObject({ status: 'ready' })
|
||||
const activated = await Promise.all([navigationActivation, hostActivation])
|
||||
const activatedTab = activated[0].tabs.find(
|
||||
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === 'host-tab'
|
||||
)
|
||||
expect(activated[1].activeTabId).toBe(`host-tab::${HEADLESS_LEAF_ID}`)
|
||||
await expect(
|
||||
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
|
||||
text: 'after attach'
|
||||
@@ -28347,12 +28375,18 @@ describe('OrcaRuntimeService', () => {
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId).toBe(
|
||||
`host-tab::${HEADLESS_LEAF_ID}`
|
||||
)
|
||||
})
|
||||
|
||||
it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => {
|
||||
const incarnationId = '92929292-9292-4929-8929-929292929292'
|
||||
const priorPtyId = 'ssh:ssh-1@@prior-relay-pty'
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeTabId: 'prior-tab',
|
||||
activeTabIdByWorktree: { [TEST_WORKTREE_ID]: 'prior-tab' },
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
@@ -28364,13 +28398,24 @@ describe('OrcaRuntimeService', () => {
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: 'prior-tab',
|
||||
ptyId: priorPtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Prior Remote Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({
|
||||
[HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty'
|
||||
})
|
||||
}),
|
||||
'prior-tab': makeHeadlessTerminalLayout({ [HEADLESS_SECOND_LEAF_ID]: priorPtyId })
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
|
||||
@@ -28397,32 +28442,42 @@ describe('OrcaRuntimeService', () => {
|
||||
await ptyAttach()
|
||||
return { id: 'ssh:ssh-1@@relay-pty', isReattach: true }
|
||||
})
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: 'ssh:ssh-1@@relay-pty',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Remote Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_ssh',
|
||||
incarnationId
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: 'ssh:ssh-1@@relay-pty',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Remote Terminal',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
terminalHandle: 'term_restarted_ssh',
|
||||
incarnationId
|
||||
}
|
||||
]
|
||||
listProcesses
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
const activations = Promise.all([
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab'),
|
||||
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
|
||||
])
|
||||
const pendingGet = vi.spyOn(runtime['pendingMobileTerminalMaterializations'], 'get')
|
||||
const navigationActivation = runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ navigation: 'caller' }
|
||||
)
|
||||
await vi.waitFor(() => expect(ptyAttach).toHaveBeenCalledOnce())
|
||||
const hostActivation = runtime.activateMobileSessionTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'host-tab',
|
||||
undefined,
|
||||
{ navigation: 'host' }
|
||||
)
|
||||
await vi.waitFor(() => expect(pendingGet).toHaveBeenCalledTimes(2))
|
||||
releaseAttach()
|
||||
await expect(activations).resolves.toHaveLength(2)
|
||||
await expect(Promise.all([navigationActivation, hostActivation])).resolves.toHaveLength(2)
|
||||
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(ptyAttach).toHaveBeenCalledOnce()
|
||||
@@ -28435,6 +28490,9 @@ describe('OrcaRuntimeService', () => {
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).activeTabId).toBe(
|
||||
`host-tab::${HEADLESS_LEAF_ID}`
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns fresh after an expired hydrated SSH headless reattach clears persistence', async () => {
|
||||
|
||||
@@ -8149,28 +8149,24 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.createRuntimeOwnedMobileSessionTerminal(
|
||||
worktreeId,
|
||||
targetsHost,
|
||||
undefined,
|
||||
{
|
||||
identity: {
|
||||
tabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
sessionId
|
||||
},
|
||||
cwd: tab.startupCwd,
|
||||
command: agentStartup.command,
|
||||
env: agentStartup.env,
|
||||
startupCommandDelivery: agentStartup.startupCommandDelivery,
|
||||
launchConfig: agentStartup.launchConfig,
|
||||
launchAgent: tab.launchAgent,
|
||||
targetGroupId,
|
||||
acceptWorkspaceInventory: () =>
|
||||
acceptMaterialization() &&
|
||||
!this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab)
|
||||
}
|
||||
)
|
||||
// Why: coalescing shares attachment, not a caller's host navigation intent.
|
||||
await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, false, undefined, {
|
||||
identity: {
|
||||
tabId: tab.parentTabId,
|
||||
leafId: tab.leafId,
|
||||
sessionId
|
||||
},
|
||||
cwd: tab.startupCwd,
|
||||
command: agentStartup.command,
|
||||
env: agentStartup.env,
|
||||
startupCommandDelivery: agentStartup.startupCommandDelivery,
|
||||
launchConfig: agentStartup.launchConfig,
|
||||
launchAgent: tab.launchAgent,
|
||||
targetGroupId,
|
||||
acceptWorkspaceInventory: () =>
|
||||
acceptMaterialization() &&
|
||||
!this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab)
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
@@ -8208,9 +8204,30 @@ export class OrcaRuntimeService {
|
||||
if (!acceptMaterialization()) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
const materializedSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const materializedTab = materializedSnapshot?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' && candidate.id === tab.id
|
||||
)
|
||||
if (!materializedSnapshot || !materializedTab) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
if (targetsHost && !materializedTab.isActive) {
|
||||
this.activateHeadlessMobileSessionTerminalTab(
|
||||
worktreeId,
|
||||
materializedSnapshot,
|
||||
materializedTab,
|
||||
{
|
||||
persistWorkspaceSession: this.shouldPersistHeadlessMobileSessionActivation(
|
||||
materializedSnapshot,
|
||||
materializedTab
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return this.applyMobileSessionTabNavigation(
|
||||
this.getMobileSessionTabsForWorktree(worktreeId),
|
||||
tab.id,
|
||||
materializedTab.id,
|
||||
navigation,
|
||||
opts.clientNavigationId
|
||||
)
|
||||
@@ -8337,7 +8354,8 @@ export class OrcaRuntimeService {
|
||||
private activateHeadlessMobileSessionTerminalTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
activeTab: RuntimeMobileSessionTerminalTab
|
||||
activeTab: RuntimeMobileSessionTerminalTab,
|
||||
options: { persistWorkspaceSession?: boolean } = {}
|
||||
): void {
|
||||
const tabs = snapshot.tabs.map((candidate) => ({
|
||||
...candidate,
|
||||
@@ -8357,7 +8375,9 @@ export class OrcaRuntimeService {
|
||||
),
|
||||
tabs
|
||||
}
|
||||
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
|
||||
if (options.persistWorkspaceSession !== false) {
|
||||
this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab)
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user