From ddcedee37c24ef8e6e0457f8a700d1a0ef2fb0b0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:28:07 -0700 Subject: [PATCH] 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 --- .../mobile-session-editor-projection.ts | 4 +- ...ession-tab-browser-placement-projection.ts | 8 + .../session-tab-file-host-projection.test.ts | 88 ++++++++ .../session-tab-file-host-projection.ts | 28 +++ .../rpc/methods/session-tabs-inventory.ts | 6 +- .../mobile-session-editor-tabs.ts | 7 +- .../runtime-mobile-session-tab-contracts.ts | 4 + src/shared/session-tab-file-host.ts | 9 + .../old-client-session-editor-rows.ts | 13 +- .../e2e/paired-desktop-pairing-cycle.spec.ts | 208 ++++++++++++++++++ 10 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tab-file-host-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-file-host-projection.ts create mode 100644 src/shared/session-tab-file-host.ts create mode 100644 tests/e2e/paired-desktop-pairing-cycle.spec.ts diff --git a/src/main/runtime/mobile-session-editor-projection.ts b/src/main/runtime/mobile-session-editor-projection.ts index 317c9fd907b..5fef66f40d6 100644 --- a/src/main/runtime/mobile-session-editor-projection.ts +++ b/src/main/runtime/mobile-session-editor-projection.ts @@ -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 { diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.ts index 3a36e6a0d50..295f6010e03 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-projection.ts @@ -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 +): 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 })) diff --git a/src/main/runtime/rpc/methods/session-tab-file-host-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-file-host-projection.test.ts new file mode 100644 index 00000000000..25f916635b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-file-host-projection.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-file-host-projection.ts b/src/main/runtime/rpc/methods/session-tab-file-host-projection.ts new file mode 100644 index 00000000000..ad495cf563b --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-file-host-projection.ts @@ -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 +} diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index c93e6de27eb..d77072f717f 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -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[2] ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + projectSessionTabFileHosts( + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + clientKind + ), clientCapabilities ) } diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts index dbe69a83008..8abd79fd6da 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -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) } } diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 2ab843c5ee3..57c5ff0c9d8 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -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 = { diff --git a/src/shared/session-tab-file-host.ts b/src/shared/session-tab-file-host.ts new file mode 100644 index 00000000000..15ef5458639 --- /dev/null +++ b/src/shared/session-tab-file-host.ts @@ -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 { + const hostRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() + return hostRuntimeEnvironmentId ? { hostRuntimeEnvironmentId } : {} +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts index fec37ff57e8..7753736dbc1 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts @@ -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) } } diff --git a/tests/e2e/paired-desktop-pairing-cycle.spec.ts b/tests/e2e/paired-desktop-pairing-cycle.spec.ts new file mode 100644 index 00000000000..fbd89fdee0f --- /dev/null +++ b/tests/e2e/paired-desktop-pairing-cycle.spec.ts @@ -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 { + 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 { + 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 { + 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 }) + } +})