fix(remote): a desktop no longer serves other runtimes its tabs on its servers' files (#33424) (#33752)

* fix(remote): a desktop ignores its own workspaces echoed back by a server it serves (#33424)

When two desktops pair to each other, each server republishes the tabs it
mirrors from its own servers. Desktop A then received B's mirror of A's own
workspace under the real workspace id and grafted it onto its local copy as
B-hosted tabs, so opening those files asked B (selector_not_found) and closing
them closed B's tabs.

A session-tabs frame from a paired server no longer writes a workspace this
app lists as its own (a stamped local or direct SSH row) unless that server
also lists a copy. Other servers' workspaces stay open to every runtime.

* fix(remote): retire echoes already taken and replay frames a twin's late row releases

An echo fenced out of this app's own workspace now applies as that server's
tombstone, so rows it left before the fence (restored sessions, or frames that
arrived before this app's own row listed) are retired without closing anything.
A held frame replays once the server's own copy of the workspace lists, so a
same-id twin that publishes before its row loads still mirrors. The e2e waits
on a later frame from the same stream instead of a fixed delay.

* fix(remote): serve a desktop's tabs on its servers' files only to its phone, not to other runtimes

Redesign of the #33424 fix at the source. A desktop publishes every editor tab in
its window, including ones on files of its paired servers; its phone uses those
through the desktop's relay. Served to another runtime, such a tab routes the file
to the desktop, and in a pairing cycle it is that runtime's own workspace coming
back as the desktop's tab: file reads fail with selector_not_found and closing it
closes the desktop's real tab.

Publishers now stamp those rows with hostRuntimeEnvironmentId (window, layout-owner
main projection and the windowless projection alike). projectSessionTabsForClient
strips the stamp for every caller and drops the stamped rows for runtime-scope
callers, keeping groups, layout and the active tab consistent. Phones and local
callers see what they saw before. The receiver-side fence is removed.

* refactor(remote): keep the file-host stamp helper outside the old-client boundary; test an all-server snapshot
This commit is contained in:
Neil
2026-10-25 20:28:07 -07:00
committed by GitHub
parent 5d6a110706
commit ddcedee37c
10 changed files with 367 additions and 8 deletions
@@ -6,6 +6,7 @@ import type {
} from '../../shared/runtime-types'
import type { Tab } from '../../shared/tab-types'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { editorRowHost } from '../../shared/session-tab-file-host'
import {
editorTabNamesFile,
sameEditorFile
@@ -58,7 +59,8 @@ function editorTab(
!!activeFileId &&
editorTabNamesFile(editorWorkspace(session, worktreeId), activeFileId, file),
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true
isPinned: unifiedTab?.isPinned === true,
...editorRowHost(file)
}
if (file.language === 'markdown') {
return {
@@ -29,6 +29,14 @@ export function projectSessionTabBrowserPlacements(
if (removedIds.size === 0) {
return payload
}
return withoutSessionTabs(payload, removedIds)
}
/** Hides tabs from a client, keeping its groups, layout and active tab consistent. */
export function withoutSessionTabs(
payload: RuntimeMobileSessionTabsResult,
removedIds: ReadonlySet<string>
): RuntimeMobileSessionTabsResult {
const retainedTabs = payload.tabs.filter((tab) => !removedIds.has(tab.id))
const active = selectProjectedActiveTab(payload.activeTabId, retainedTabs)
const tabs = retainedTabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id }))
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { projectSessionTabsForClient } from './session-tabs-inventory'
// #33424: this desktop opened README.md in a workspace of paired server A while A pairs back to it.
function desktopSnapshot(): RuntimeMobileSessionTabsResult {
return {
worktree: 'repo::/work/app',
publicationEpoch: 'epoch-1',
snapshotVersion: 3,
activeGroupId: 'group-1',
activeTabId: 'server-readme',
activeTabType: 'markdown',
tabGroups: [
{
id: 'group-1',
activeTabId: 'server-readme',
tabOrder: ['local-notes', 'server-readme'],
recentTabIds: ['local-notes', 'server-readme']
}
],
tabs: [
{
type: 'file',
id: 'local-notes',
title: 'notes.txt',
filePath: '/work/app/notes.txt',
relativePath: 'notes.txt',
language: 'plaintext',
isDirty: false,
isActive: false
},
{
type: 'markdown',
id: 'server-readme',
title: 'README.md',
filePath: '/work/app/README.md',
relativePath: 'README.md',
language: 'markdown',
mode: 'edit',
isDirty: false,
isActive: true,
sourceFileId: 'server-readme-file',
sourceFilePath: '/work/app/README.md',
sourceRelativePath: 'README.md',
documentVersion: 'file:server-readme-file',
hostRuntimeEnvironmentId: 'server-a'
}
]
}
}
describe("a desktop's tab on a paired server's file", () => {
it('is not served to another runtime, which would take it as its own', () => {
const served = projectSessionTabsForClient(desktopSnapshot(), 'runtime', [])
expect(served.tabs.map((tab) => tab.id)).toEqual(['local-notes'])
expect(served.tabGroups?.[0]?.tabOrder).toEqual(['local-notes'])
expect(served.activeTabId).toBe('local-notes')
expect(served.activeGroupId).toBe('group-1')
})
it('leaves another runtime no groups or focus when every row is on a server', () => {
const snapshot = desktopSnapshot()
const onlyServerRows = {
...snapshot,
tabs: snapshot.tabs.filter((tab) => tab.id === 'server-readme'),
tabGroups: snapshot.tabGroups?.map((group) => ({
...group,
tabOrder: ['server-readme'],
recentTabIds: ['server-readme']
}))
}
const served = projectSessionTabsForClient(onlyServerRows, 'runtime', [])
expect(served.tabs).toEqual([])
expect(served.tabGroups).toBeUndefined()
expect(served.activeTabId).toBeNull()
expect(served.activeGroupId).toBeNull()
})
it.each([
['its phone', 'mobile' as const],
['a local caller', undefined]
])('is served to %s without the host stamp', (_label, clientKind) => {
const served = projectSessionTabsForClient(desktopSnapshot(), clientKind, [])
expect(served.tabs.map((tab) => tab.id)).toEqual(['local-notes', 'server-readme'])
expect(served.tabs.some((tab) => 'hostRuntimeEnvironmentId' in tab)).toBe(false)
})
})
@@ -0,0 +1,28 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { withoutSessionTabs } from './session-tab-browser-placement-projection'
/** A desktop's tab on a file of one of its servers is for its phone, which reaches that server through
* the desktop. Another runtime would route the file here, and in a pairing cycle it is that runtime's
* own workspace coming back to it (#33424). */
export function projectSessionTabFileHosts(
payload: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined
): RuntimeMobileSessionTabsResult {
const hosted = new Set(
payload.tabs.filter((tab) => 'hostRuntimeEnvironmentId' in tab).map((tab) => tab.id)
)
if (hosted.size === 0) {
return payload
}
const stripped = {
...payload,
tabs: payload.tabs.map((tab) => {
if (!('hostRuntimeEnvironmentId' in tab)) {
return tab
}
const { hostRuntimeEnvironmentId: _host, ...served } = tab
return served
})
}
return clientKind === 'runtime' ? withoutSessionTabs(stripped, hosted) : stripped
}
@@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-
import type { RpcContext } from '../core'
import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection'
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import { projectSessionTabFileHosts } from './session-tab-file-host-projection'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta'
@@ -32,7 +33,10 @@ export function projectSessionTabsForClient(
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2]
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
projectSessionTabFileHosts(
projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities),
clientKind
),
clientCapabilities
)
}
@@ -4,6 +4,7 @@ import type {
RuntimeMobileSessionMarkdownTab
} from '../../../../shared/runtime-types'
import type { Tab } from '../../../../shared/tab-types'
import { editorRowHost } from '../../../../shared/session-tab-file-host'
import type { MobileSessionWorktreeInputs } from './types'
import {
isFileActiveEditorSurface,
@@ -47,7 +48,8 @@ export function buildMobileMarkdownTab(
...(sourceFile.isUntitled ? { isUntitled: true as const } : {}),
documentVersion: draftVersion ?? `file:${sourceFile.id}`,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true
isPinned: unifiedTab?.isPinned === true,
...editorRowHost(file)
}
}
@@ -73,6 +75,7 @@ export function buildMobileFileTab(
isPinned: unifiedTab?.isPinned === true,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId)
: isFileActiveEditorSurface(inputs, file)
: isFileActiveEditorSurface(inputs, file),
...editorRowHost(file)
}
}
@@ -61,6 +61,8 @@ export type RuntimeMobileSessionMarkdownTab = {
documentVersion: string
color?: string | null
isPinned?: boolean
/** The paired server the file lives on. Its publisher sets it; runtimes strip it before serving. */
hostRuntimeEnvironmentId?: string
}
export type RuntimeMobileSessionFileTab = {
@@ -76,6 +78,8 @@ export type RuntimeMobileSessionFileTab = {
color?: string | null
isPinned?: boolean
isActive: boolean
/** The paired server the file lives on. Its publisher sets it; runtimes strip it before serving. */
hostRuntimeEnvironmentId?: string
}
export type RuntimeMobileSessionBrowserTab = {
+9
View File
@@ -0,0 +1,9 @@
import type { RuntimeMobileSessionFileTab } from './runtime-mobile-session-tab-contracts'
/** Names the paired server a row's file lives on, for runtimes to keep from other runtimes (#33424). */
export function editorRowHost(file: {
runtimeEnvironmentId?: string | null
}): Pick<RuntimeMobileSessionFileTab, 'hostRuntimeEnvironmentId'> {
const hostRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim()
return hostRuntimeEnvironmentId ? { hostRuntimeEnvironmentId } : {}
}
@@ -5,6 +5,7 @@ import type {
RuntimeMobileSessionMarkdownTab
} from '../../runtime-mobile-session-tab-contracts'
import type { LayoutEditorView } from '../layout-editor-view'
import { editorRowHost } from '../../session-tab-file-host'
import type { DesktopLayoutView } from '../workspace-layout-beside'
import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file'
import type { LayoutContentTab, LayoutEditorFile, WorkspaceLayout } from '../workspace-layout-model'
@@ -54,7 +55,8 @@ export function editorTabRow(tab: LayoutContentTab, scope: EditorRowScope): Edit
filePath: file.filePath,
relativePath: file.relativePath,
isDirty: hasDraft(scope, file),
...tabRowProps(tab, scope)
...tabRowProps(tab, scope),
...editorRowHost(file)
}
if (file.language === 'markdown') {
return {
@@ -106,7 +108,8 @@ function markdownPreviewRow(
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
documentVersion: `file:${sourceId}`,
...tabRowProps(tab, scope)
...tabRowProps(tab, scope),
...editorRowHost(preview)
}
}
@@ -135,7 +138,8 @@ export function diffTabRow(
...(diffSource === 'staged' || diffSource === 'unstaged' ? { diffSource } : {}),
// The model holds no unsaved edit of a diff's modified side; the window's row it lists wins.
isDirty: false,
...tabRowProps(tab, scope)
...tabRowProps(tab, scope),
...editorRowHost(file)
}
}
@@ -159,6 +163,7 @@ export function reviewTabRow(
mode: 'edit',
// The model holds no unsaved edit of a review; the window's row it lists wins.
isDirty: false,
...tabRowProps(tab, scope)
...tabRowProps(tab, scope),
...editorRowHost(file)
}
}
@@ -0,0 +1,208 @@
// #33424: two desktops paired to each other. B's tab on a file in A's workspace must not reach A as B's
// own tab, or A routes that file's reads and closes to B, which lacks the workspace.
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { RuntimeClient } from '../../src/cli/runtime/client'
import { expect, test } from './helpers/orca-app'
import { createRestartSession } from './helpers/orca-restart'
import { createRuntimeDesktopPairingOffer } from './helpers/paired-electron-client'
async function pairAndSelect(page: Page, pairingUrl: string, name: string): Promise<string> {
return page.evaluate(
async ({ name, pairingUrl }) => {
const store = window.__store!
const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({
name,
pairingCode: pairingUrl,
allowLoopback: true
})
if (!result.ok) {
throw new Error(`Pairing ${name} failed: ${JSON.stringify(result)}`)
}
const id = result.environment.id
store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list())
if (!(await store.getState().refreshRuntimeEnvironmentStatus(id))) {
throw new Error(`Could not reach ${name}`)
}
if (!(await store.getState().setActiveRuntimeEnvironmentPreference(id))) {
throw new Error(`Could not select ${name}`)
}
return id
},
{ name, pairingUrl }
)
}
async function addLocalFolder(page: Page, folderPath: string): Promise<string> {
const repoId = await page.evaluate(async (folderPath) => {
const added = await window.api.repos.add({ path: folderPath, kind: 'folder' })
if ('error' in added) {
throw new Error(added.error)
}
return added.repo.id
}, folderPath)
let worktreeId = ''
await expect
.poll(
async () => {
worktreeId = await page.evaluate(async (repoId) => {
const store = window.__store!
await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true })
await store.getState().fetchWorktrees(repoId)
return (
store
.getState()
.allWorktrees()
.find((entry) => entry.repoId === repoId)?.id ?? ''
)
}, repoId)
return worktreeId
},
{ timeout: 30_000, message: 'Desktop B never listed its own folder' }
)
.not.toBe('')
return worktreeId
}
function tabIdsIn(page: Page, worktreeId: string): Promise<string[]> {
return page.evaluate(
(id) => (window.__store!.getState().unifiedTabsByWorktree[id] ?? []).map((tab) => tab.id),
worktreeId
)
}
function openFilesIn(page: Page, worktreeId: string) {
return page.evaluate(
(id) =>
window
.__store!.getState()
.openFiles.filter((file) => file.worktreeId === id)
.map((file) => ({
relativePath: file.relativePath,
env: file.runtimeEnvironmentId ?? null
})),
worktreeId
)
}
test('a desktop gets no tab back for its own workspace from a server it serves', async ({
orcaPage
}, testInfo) => {
// Two desktops launch and pair to each other.
test.setTimeout(240_000)
const desktopBFolder = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-desktop-b-'))
writeFileSync(path.join(desktopBFolder, 'note.md'), '# desktop B\n')
const hubBSession = createRestartSession(testInfo)
const hubB = await hubBSession.launch()
try {
await hubB.page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 60_000 }
)
const worktree = await orcaPage.evaluate(() => {
const listed = window.__store!.getState().allWorktrees()[0]
return listed ? { id: listed.id, path: listed.path } : null
})
expect(worktree).not.toBeNull()
const { id: worktreeId, path: worktreePath } = worktree!
const desktopBWorktreeId = await addLocalFolder(hubB.page, desktopBFolder)
const desktopATabIds = await tabIdsIn(orcaPage, worktreeId)
const desktopBOnA = await pairAndSelect(
orcaPage,
(await createRuntimeDesktopPairingOffer(hubB.page)).pairingUrl,
'Desktop B'
)
const desktopAOnB = await pairAndSelect(
hubB.page,
(await createRuntimeDesktopPairingOffer(orcaPage)).pairingUrl,
'Desktop A'
)
await expect
.poll(
() =>
hubB.page.evaluate(
(id) =>
window
.__store!.getState()
.allWorktrees()
.some((entry) => entry.id === id),
worktreeId
),
{
timeout: 60_000,
message: 'Desktop B never listed desktop A’s workspace'
}
)
.toBe(true)
await hubB.page.evaluate(
({ environmentId, worktreeId, worktreePath }) => {
const state = window.__store!.getState()
state.setActiveView('terminal')
state.setActiveWorktree(worktreeId, `runtime:${environmentId}`)
state.openFile({
filePath: `${worktreePath}/README.md`,
relativePath: 'README.md',
worktreeId,
runtimeEnvironmentId: environmentId,
language: 'markdown',
mode: 'edit'
})
},
{ environmentId: desktopAOnB, worktreeId, worktreePath }
)
await expect
.poll(() => openFilesIn(hubB.page, worktreeId), { timeout: 30_000 })
.toEqual([{ relativePath: 'README.md', env: desktopAOnB }])
// B's runtime holds B's tab in A's workspace; locally it is listed, only other runtimes are spared it.
const desktopBRuntime = new RuntimeClient(hubBSession.userDataDir, 30_000, null, null)
await expect
.poll(
async () => {
const listed = await desktopBRuntime.call<{
snapshots: { worktree: string; tabs: { type: string }[] }[]
}>('session.tabs.listAll')
return listed.result.snapshots.some(
(snapshot) =>
snapshot.worktree === worktreeId &&
snapshot.tabs.some((tab) => tab.type === 'markdown')
)
},
{ timeout: 30_000, message: 'Desktop B never published its tab in A’s workspace' }
)
.toBe(true)
// A frame B publishes later reaches A after any echo on the same stream.
await hubB.page.evaluate(
({ worktreeId, filePath }) => {
window.__store!.getState().openFile({
filePath,
relativePath: 'note.md',
worktreeId,
language: 'markdown',
mode: 'edit'
})
},
{ worktreeId: desktopBWorktreeId, filePath: path.join(desktopBFolder, 'note.md') }
)
await expect
.poll(() => openFilesIn(orcaPage, desktopBWorktreeId), {
timeout: 30_000,
message: 'Desktop A never mirrored a tab in desktop B’s own workspace'
})
.toEqual([{ relativePath: 'note.md', env: desktopBOnA }])
expect(await openFilesIn(orcaPage, worktreeId)).toEqual([])
expect(await tabIdsIn(orcaPage, worktreeId)).toEqual(desktopATabIds)
expect(await openFilesIn(hubB.page, worktreeId)).toEqual([
{ relativePath: 'README.md', env: desktopAOnB }
])
} finally {
await hubBSession.close(hubB.app)
await hubBSession.dispose()
rmSync(desktopBFolder, { recursive: true, force: true })
}
})