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 }) + } +})