diff --git a/.github/workflows/layout-owner-e2e.yml b/.github/workflows/layout-owner-e2e.yml index 3547a6cff80..1e1cec91125 100644 --- a/.github/workflows/layout-owner-e2e.yml +++ b/.github/workflows/layout-owner-e2e.yml @@ -25,7 +25,7 @@ jobs: uses: ./.github/workflows/e2e.yml with: ref: ${{ inputs.ref || github.sha }} - test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/paired-desktop-server-layout-frames.spec.ts","tests/e2e/ssh-orcad-"]' + test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-focused-terminal-create.spec.ts","tests/e2e/headless-serve-mobile-chat-tab-order.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/headless-serve-session-history-naming-requests.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/paired-desktop-server-layout-frames.spec.ts","tests/e2e/paired-desktop-server-file-open-group.spec.ts","tests/e2e/ssh-orcad-"]' run_changed_e2e: true needs_build: true artifact_suffix: -layout-owner-switch-on diff --git a/src/main/runtime/editor-open-active-group.test.ts b/src/main/runtime/editor-open-active-group.test.ts index ec31a7f97a3..7106f1488b9 100644 --- a/src/main/runtime/editor-open-active-group.test.ts +++ b/src/main/runtime/editor-open-active-group.test.ts @@ -72,3 +72,47 @@ describe('a file main opens lands in the group the window shows', () => { expect(host.groupOfFile()).toBe('g1') }) }) + +describe('a file main opens lands in the group a paired desktop shows', () => { + it("the latest desktop's group, then the other's when that one's connection closes", async () => { + const host = twoGroupHost() + host.runtime.reportClientView('desktop-a', { [TEST_WORKTREE_ID]: 'g2' }) + host.runtime.reportClientView('desktop-b', { [TEST_WORKTREE_ID]: 'g1' }) + expect(host.runtime.groupForTest(TEST_WORKTREE_ID)).toBe('g1') + + host.runtime.cleanupSubscriptionsForConnection('desktop-b') + await host.open() + + expect(host.groupOfFile()).toBe('g2') + }) + + it("the saved view's once every reporting desktop has gone", async () => { + const host = twoGroupHost() + host.runtime.reportClientView('desktop-a', { [TEST_WORKTREE_ID]: 'g2' }) + host.runtime.reportClientView('desktop-a', { [TEST_WORKTREE_ID]: 'g2' }) + host.runtime.cleanupSubscriptionsForConnection('desktop-a') + + expect(host.runtime.groupForTest(TEST_WORKTREE_ID)).toBe('g1') + }) + + it('the window, when it reported after the desktop', () => { + const host = twoGroupHost() + host.runtime.reportClientView('desktop-a', { [TEST_WORKTREE_ID]: 'g1' }) + host.runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + activeGroupByWorkspace: { [TEST_WORKTREE_ID]: 'g2' } + }) + + expect(host.runtime.groupForTest(TEST_WORKTREE_ID)).toBe('g2') + }) + it("a desktop's refresh after a reconnect leaves the group another desktop switched to", async () => { + const host = twoGroupHost() + host.runtime.reportClientView('desktop-a', { [TEST_WORKTREE_ID]: 'g2' }) + host.runtime.reportClientView('desktop-b', { [TEST_WORKTREE_ID]: 'g1' }, true) + + await host.open() + + expect(host.groupOfFile()).toBe('g2') + }) +}) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index d53661127f8..8ac079962b0 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -64,7 +64,11 @@ import { MANAGED_RUNTIME_INSTALL_METHODS } from './managed-runtime-install' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' import { AGENT_LAUNCH_METHODS } from './agent-launch' -import { LAYOUT_APPLY_METHODS, WORKSPACE_LAYOUT_METHODS } from './workspace-layout' +import { + LAYOUT_APPLY_METHODS, + LAYOUT_VIEW_REPORT_METHODS, + WORKSPACE_LAYOUT_METHODS +} from './workspace-layout' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-switch' @@ -112,7 +116,7 @@ export const ALL_RPC_METHODS = [ ...SESSION_TAB_METHODS, ...WORKSPACE_LAYOUT_METHODS, // Switch point: a window-owned build registers no layout command. - ...(ORCA_LAYOUT_OWNER_IS_MAIN ? LAYOUT_APPLY_METHODS : []), + ...(ORCA_LAYOUT_OWNER_IS_MAIN ? [...LAYOUT_APPLY_METHODS, ...LAYOUT_VIEW_REPORT_METHODS] : []), ...NATIVE_CHAT_METHODS, ...FILE_METHODS, ...GIT_METHODS, diff --git a/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts b/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts index 3b5e75ef88a..f55f3fd6fcb 100644 --- a/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout-paired-desktop.test.ts @@ -12,7 +12,12 @@ import type { RpcCallerScope } from '../rpc-caller-scope' const PAIRED_DESKTOP: RpcCallerScope = { kind: 'runtime-paired', grants: [] } const PHONE: RpcCallerScope = { kind: 'mobile' } -const LAYOUT_METHODS = ['layout.subscribe', 'layout.unsubscribe', 'layout.apply'] as const +const LAYOUT_METHODS = [ + 'layout.subscribe', + 'layout.unsubscribe', + 'layout.apply', + 'layout.reportView' +] as const const layoutOwner = vi.hoisted(() => ({ isMain: true })) vi.mock('../../../../shared/layout-owner-switch', () => ({ @@ -74,7 +79,8 @@ describe('layout methods on a layout-owner server', () => { it.each([ ['layout.subscribe', 'method_not_supported'], ['layout.unsubscribe', 'invalid_argument'], - ['layout.apply', 'invalid_argument'] + ['layout.apply', 'invalid_argument'], + ['layout.reportView', 'invalid_argument'] ] as const)('admits a paired desktop and refuses a phone: %s', async (method, nextCheck) => { const { OrcaRuntimeService, RpcDispatcher } = await switchedBuild(true) const dispatcher = new RpcDispatcher({ runtime: new OrcaRuntimeService() }) @@ -108,6 +114,74 @@ describe('layout methods on a layout-owner server', () => { }) }) +describe('layout.reportView', () => { + const DESKTOP = { + callerScope: PAIRED_DESKTOP, + connectionId: 'desktop-1', + clientKind: 'runtime' as const + } + + it("records the desktop's groups under its connection", async () => { + const { OrcaRuntimeService, RpcDispatcher } = await switchedBuild(true) + const runtime = new OrcaRuntimeService() + const reported = vi.spyOn(runtime, 'reportClientView') + const dispatcher = new RpcDispatcher({ runtime }) + const reply = await dispatcher.dispatch( + request('layout.reportView', { activeGroupByWorkspace: { 'wt-1': 'g2', 'wt-2': null } }), + DESKTOP + ) + expect(reply.ok).toBe(true) + expect(reported).toHaveBeenCalledWith('desktop-1', { 'wt-1': 'g2', 'wt-2': null }, false) + }) + + it('passes a refresh on as one', async () => { + const { OrcaRuntimeService, RpcDispatcher } = await switchedBuild(true) + const runtime = new OrcaRuntimeService() + const reported = vi.spyOn(runtime, 'reportClientView') + const reply = await new RpcDispatcher({ runtime }).dispatch( + request('layout.reportView', { activeGroupByWorkspace: { 'wt-1': 'g2' }, refresh: true }), + DESKTOP + ) + expect(reply.ok).toBe(true) + expect(reported).toHaveBeenCalledWith('desktop-1', { 'wt-1': 'g2' }, true) + }) + + it('refuses a caller with no connection to tie the report to', async () => { + const { OrcaRuntimeService, RpcDispatcher } = await switchedBuild(true) + const runtime = new OrcaRuntimeService() + const reported = vi.spyOn(runtime, 'reportClientView') + const reply = await new RpcDispatcher({ runtime }).dispatch( + request('layout.reportView', { activeGroupByWorkspace: { 'wt-1': 'g2' } }), + { callerScope: PAIRED_DESKTOP, clientKind: 'runtime' } + ) + expect(reply.ok).toBe(false) + expect(reported).not.toHaveBeenCalled() + }) + + it.each([ + [{}], + [{ activeGroupByWorkspace: { 'wt-1': 7 } }], + [{ activeGroupByWorkspace: { 'wt-1': '' } }], + [{ activeGroupByWorkspace: ['g1'] }], + [{ activeGroupByWorkspace: {}, refresh: false }] + ])('refuses params it cannot read: %j', async (params) => { + const { OrcaRuntimeService, RpcDispatcher } = await switchedBuild(true) + const runtime = new OrcaRuntimeService() + const reported = vi.spyOn(runtime, 'reportClientView') + const reply = await new RpcDispatcher({ runtime }).dispatch( + request('layout.reportView', params), + DESKTOP + ) + expect(errorCode(reply)).toBe('invalid_argument') + expect(reported).not.toHaveBeenCalled() + }) + + it('is not registered by a window-owned build', async () => { + const { ALL_RPC_METHODS } = await switchedBuild(false) + expect(ALL_RPC_METHODS.map((method) => method.name)).not.toContain('layout.reportView') + }) +}) + describe('an older desktop on a layout-owner server', () => { const WT = 'wt-1' const SELECTOR = `id:${WT}` diff --git a/src/main/runtime/rpc/methods/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout.ts index 9e28d9b4cf0..0c5a3ea057b 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.ts @@ -4,6 +4,7 @@ import { LayoutApplyParams, + LayoutReportViewParams, LayoutSubscribeParams, LayoutUnsubscribeParams } from '../../../../shared/rpc-contract/workspace-layout-params' @@ -103,3 +104,21 @@ export const LAYOUT_APPLY_METHODS = [ handler: async (params, { runtime }) => runtime.applyLayoutCommand(params.command) }) ] + +/** A paired desktop's view of this server's workspaces, so a CLI or phone file opens in the group it + * shows. Registered with layout.apply: only a server advertising `layout.intents.v1` takes it. */ +export const LAYOUT_VIEW_REPORT_METHODS = [ + defineMethod({ + name: 'layout.reportView', + permission: 'workspace', + params: LayoutReportViewParams, + handler: async (params, { runtime, connectionId, signal }) => { + // A report outlives the call, so it needs a connection whose close drops it (as sidebarInputs.reportView). + if (connectionId === undefined || signal?.aborted) { + throw new Error('client_disconnected') + } + runtime.reportClientView(connectionId, params.activeGroupByWorkspace, params.refresh === true) + return { ok: true } + } + }) +] diff --git a/src/main/runtime/session-graph/session-graph-promotion.ts b/src/main/runtime/session-graph/session-graph-promotion.ts index c8d6a781ecf..46cd4709863 100644 --- a/src/main/runtime/session-graph/session-graph-promotion.ts +++ b/src/main/runtime/session-graph/session-graph-promotion.ts @@ -22,6 +22,21 @@ export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifyS /** The group each workspace shows, as the attached window last reported it (syncWindowGraph). */ protected readonly activeGroupViewFacts = new ActiveGroupViewFacts() + /** A paired desktop's `layout.reportView`: its reports count until its connection closes. */ + reportClientView( + connectionId: string, + activeGroupByWorkspace: Readonly>, + refresh = false + ): void { + if (this.activeGroupViewFacts.mergeFromClient(connectionId, activeGroupByWorkspace, refresh)) { + this.registerSubscriptionCleanup( + `layout-view:${connectionId}`, + () => this.activeGroupViewFacts.dropClient(connectionId), + connectionId + ) + } + } + protected editorOpenGroupFor(worktreeId: string): string | undefined { return this.activeGroupViewFacts.groupFor(worktreeId, () => { const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) diff --git a/src/main/runtime/view-facts/active-group-view-facts.test.ts b/src/main/runtime/view-facts/active-group-view-facts.test.ts index 03a17442796..d8570609b84 100644 --- a/src/main/runtime/view-facts/active-group-view-facts.test.ts +++ b/src/main/runtime/view-facts/active-group-view-facts.test.ts @@ -25,3 +25,97 @@ describe("the window's active group, as main keeps it", () => { expect(facts.groupFor('a', () => 'saved')).toBeUndefined() }) }) + +describe('the group a paired desktop shows, beside the window', () => { + it('the latest report wins, whichever connection or window sent it', () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.mergeFromClient('desktop-b', { wt: 'gB' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('gB') + facts.mergeFromClient('desktop-a', { wt: 'gA2' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('gA2') + facts.merge({ wt: 'gW' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('gW') + facts.mergeFromClient('desktop-b', { wt: 'gB2' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('gB2') + }) + + it("a closed connection's report goes: the other desktop's counts, else the saved view", () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.mergeFromClient('desktop-b', { wt: 'gB' }) + facts.dropClient('desktop-b') + expect(facts.groupFor('wt', () => 'saved')).toBe('gA') + facts.dropClient('desktop-a') + expect(facts.groupFor('wt', () => 'saved')).toBe('saved') + }) + + it("a closed connection leaves the window's report", () => { + const facts = new ActiveGroupViewFacts() + facts.merge({ wt: 'gW' }) + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.dropClient('desktop-a') + expect(facts.groupFor('wt', () => 'saved')).toBe('gW') + }) + + it("is first only for a connection's first report", () => { + const facts = new ActiveGroupViewFacts() + expect(facts.mergeFromClient('desktop-a', { wt: 'g1' })).toBe(true) + expect(facts.mergeFromClient('desktop-a', { wt: 'g2' })).toBe(false) + facts.dropClient('desktop-a') + expect(facts.mergeFromClient('desktop-a', {})).toBe(true) + }) +}) + +describe("a desktop's refresh: its view re-sent after a reconnect", () => { + it("never displaces another desktop's live report", () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.mergeFromClient('desktop-b', { wt: 'gB' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('gA') + }) + + it('counts as live without the flag', () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.mergeFromClient('desktop-b', { wt: 'gB' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('gB') + }) + + it("replaces the connection's own entry, and beats the saved view", () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }) + facts.mergeFromClient('desktop-a', { wt: 'gA2' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('gA2') + }) + + it('loses to a later live report of the window, and the later refresh wins among refreshes', () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-a', { wt: 'gA' }, true) + facts.mergeFromClient('desktop-b', { wt: 'gB' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('gB') + facts.merge({ wt: 'gW' }) + facts.mergeFromClient('desktop-a', { wt: 'gA2' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('gW') + }) +}) + +describe("a refresh on the connection's own live report", () => { + it('keeps its rank: a resubscribe does not hand the workspace to an older report', () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-b', { wt: 'g1' }) + facts.mergeFromClient('desktop-a', { wt: 'g2' }) + facts.mergeFromClient('desktop-a', { wt: 'g2' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('g2') + }) + + it('takes a changed group at the rank it had', () => { + const facts = new ActiveGroupViewFacts() + facts.mergeFromClient('desktop-b', { wt: 'g1' }) + facts.mergeFromClient('desktop-a', { wt: 'g2' }) + facts.mergeFromClient('desktop-a', { wt: 'g3' }, true) + expect(facts.groupFor('wt', () => 'saved')).toBe('g3') + facts.mergeFromClient('desktop-b', { wt: 'g4' }) + expect(facts.groupFor('wt', () => 'saved')).toBe('g4') + }) +}) diff --git a/src/main/runtime/view-facts/active-group-view-facts.ts b/src/main/runtime/view-facts/active-group-view-facts.ts index 1192d4cf0f9..0ba94cce042 100644 --- a/src/main/runtime/view-facts/active-group-view-facts.ts +++ b/src/main/runtime/view-facts/active-group-view-facts.ts @@ -1,21 +1,72 @@ -// The tab group each workspace shows, as the window last reported it: a view fact, kept in memory -// only. Main opens a CLI or phone file there; with no window, in the last group it was told. A -// removed workspace's entry stays: a stale group is refused and the open falls back to main's rule. +// The tab group each workspace shows, as the window and each paired desktop last reported it: a +// view fact, kept in memory only. Main opens a CLI or phone file in the group of whichever of them +// reported that workspace last; with none, in the saved view's. A refresh (a desktop re-sending its +// view after a reconnect) ranks below every live report, so it never displaces another's, nor +// lowers the connection's own. A closed connection's reports go with it. A removed workspace's entry stays: a stale group is refused and the open falls back to +// main's rule. + +/** `refresh` entries rank below every `live` one; within a tier, the later wins. */ +type Reported = { groupId: string | null; tier: 'live' | 'refresh'; order: number } + +function outranks(a: Reported, b: Reported): boolean { + return a.tier === b.tier ? a.order > b.order : a.tier === 'live' +} export class ActiveGroupViewFacts { /** Workspace key → the group the window shows; null when it shows none. */ - private readonly reported = new Map() + private readonly fromWindow = new Map() + /** Paired desktop connection id → its reports, as the window's. */ + private readonly fromClients = new Map>() + private order = 0 /** A window's report: each named workspace's entry replaces the last; others are kept. */ merge(report: Readonly> | undefined): void { - for (const [workspace, groupId] of Object.entries(report ?? {})) { - this.reported.set(workspace, groupId) - } + this.record(this.fromWindow, report) } - /** The reported group, else the saved view's (after a restart no window has reported yet). */ + /** A paired desktop's report, as the window's; true when it is the connection's first. */ + mergeFromClient( + connectionId: string, + report: Readonly>, + refresh = false + ): boolean { + const known = this.fromClients.get(connectionId) + const reports = known ?? new Map() + this.fromClients.set(connectionId, reports) + this.record(reports, report, refresh) + return known === undefined + } + + dropClient(connectionId: string): void { + this.fromClients.delete(connectionId) + } + + /** The latest report's group, else the saved view's (after a restart no one has reported yet). */ groupFor(workspace: string, savedGroupId: () => string | null | undefined): string | undefined { - const groupId = this.reported.has(workspace) ? this.reported.get(workspace) : savedGroupId() + let latest = this.fromWindow.get(workspace) + for (const reports of this.fromClients.values()) { + const reported = reports.get(workspace) + if (reported && (!latest || outranks(reported, latest))) { + latest = reported + } + } + const groupId = latest ? latest.groupId : savedGroupId() return groupId ?? undefined } + + private record( + into: Map, + report: Readonly> | undefined, + refresh = false + ): void { + for (const [workspace, groupId] of Object.entries(report ?? {})) { + const previous = into.get(workspace) + if (refresh && previous?.tier === 'live') { + // A resubscribe on the same connection re-sends its view: it keeps the rank it earned. + into.set(workspace, { ...previous, groupId }) + continue + } + into.set(workspace, { groupId, tier: refresh ? 'refresh' : 'live', order: ++this.order }) + } + } } diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts index 25ba6f68012..f7a095a6771 100644 --- a/src/renderer/src/runtime/server-layout-frames.ts +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -13,6 +13,7 @@ import { runtimeHostConnectionStateForEntry } from './runtime-host-connection-state' import { createServerLayoutFrameTranslator } from './server-layout-frame-ids' +import { createServerViewReport, onServerControlConnected } from './server-layout-view-report' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' import { mirroredRuntimeEnvironmentIdForWorkspace } from './web-session-tabs-sync/mirrored-workspace-owner' import { @@ -85,8 +86,35 @@ function openServerFrames( gate(new Set()) } const draws = serverDraws(environmentId) + const viewReport = createServerViewReport(environmentId) + /** The server may hold none of this window's reports: a new stream or control socket. */ + const reportCurrent = (): void => { + if (live) { + viewReport.update(store.getState().activeGroupIdByWorktree, gated) + } + } + const reportAgain = (): void => { + viewReport.reset() + reportCurrent() + } + const stopControlWatch = onServerControlConnected(environmentId, reportAgain) + /** True while the sync writes a frame: a group it moves is the server's doing, not the user's. */ + let applyingFrames = false + const frameStore: FramesStore = { + getState: () => store.getState(), + subscribe: (listener) => store.subscribe(listener), + setState: (patch) => { + const previous = applyingFrames + applyingFrames = true + try { + store.setState(patch) + } finally { + applyingFrames = previous + } + } + } - const sync = openWorkspaceLayoutFrameSync(store, { + const sync = openWorkspaceLayoutFrameSync(frameStore, { target: { kind: 'environment', environmentId }, // Only gated workspaces: a handed-back one is the mirror's, whatever the frozen frames still hold. draws: (state) => { @@ -96,6 +124,12 @@ function openServerFrames( translate: (frame) => { const translated = translator.translate(frame) live ||= frame.type === 'snapshot' + if (frame.type === 'snapshot') { + viewReport.reset() + // Two hops, past the flush the sync queues after this returns: if applying the snapshot + // changed nothing in the store, nothing has re-sent the view yet. + queueMicrotask(() => queueMicrotask(reportCurrent)) + } // Before the frame shows: the mirror must not write a workspace the frames now draw. regate() return translated @@ -110,6 +144,9 @@ function openServerFrames( // gated, then drawn, as the routing that names it arrives. let routing: unknown[] = [] const stopRouting = store.subscribe((state) => { + if (live) { + viewReport.update(state.activeGroupIdByWorktree, gated, applyingFrames) + } const next = routingInputs(state) if (sameValues(next, routing)) { return @@ -133,6 +170,7 @@ function openServerFrames( close: (returnWorkspaces) => { stopRouting() stopHandles() + stopControlWatch() sync.close() if (returnWorkspaces) { handBack() diff --git a/src/renderer/src/runtime/server-layout-view-report.test.ts b/src/renderer/src/runtime/server-layout-view-report.test.ts new file mode 100644 index 00000000000..d7a3c61687f --- /dev/null +++ b/src/renderer/src/runtime/server-layout-view-report.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type * as FrameDrawnModule from './web-session-tabs-sync/frame-drawn-server-workspaces' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { _resetLayoutFramePositionForTests } from './layout-frame-position' +import { + CAPABLE, + ENV, + HOST_GROUP, + WT, + SERVER_LAYOUT, + createFakeLayoutStreams, + createServerHarness, + settle, + type TestStore +} from './server-layout-frames.test-fixture' +import { + isFrameDrawnServerWorkspace, + resetFrameDrawnServerWorkspacesForTests +} from './web-session-tabs-sync/frame-drawn-server-workspaces' +import { resetMirroredTerminalHandlesForTests } from './web-session-tabs-sync/mirrored-terminal-handle-index' +import { resetWebSessionTabsSyncTestState } from './web-session-tabs-sync-test-harness' + +type Subscribe = (...args: never[]) => { close: () => void } +const hoisted = vi.hoisted( + (): { + owners: Map + resync: Mock<(environmentId: string, worktreeId: string) => Promise> + call: Mock<(target: unknown, method: string, params?: unknown) => Promise> + subscribe: Subscribe | null + } => ({ + owners: new Map(), + resync: vi.fn(), + call: vi.fn(), + subscribe: null + }) +) + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: (...args: never[]) => hoisted.subscribe!(...args) +})) +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: (target: unknown, method: string, params?: unknown) => + hoisted.call(target, method, params) +})) +vi.mock('./web-session-tabs-sync/mirrored-workspace-owner', () => ({ + mirroredRuntimeEnvironmentIdForWorkspace: (_state: unknown, key: string) => + hoisted.owners.get(key) ?? null +})) +vi.mock('./web-session-tabs-sync/frame-drawn-server-workspaces', async (importOriginal) => ({ + ...(await importOriginal()), + resyncMirroredServerWorkspace: hoisted.resync +})) + +createStoreSessionMockApi() + +const streams = createFakeLayoutStreams() +hoisted.subscribe = streams.subscribe + +/** A workspace this window holds that no server's frames draw. */ +const LOCAL_WT = 'repo::/local-worktree' +const { setServers, mirroredStore, install, uninstallAll } = createServerHarness(hoisted) + +beforeEach(() => { + hoisted.owners.clear() + hoisted.resync.mockReset().mockResolvedValue(undefined) + hoisted.call.mockReset().mockResolvedValue({ ok: true }) + streams.all.length = 0 + resetWebSessionTabsSyncTestState() + resetFrameDrawnServerWorkspacesForTests() + resetMirroredTerminalHandlesForTests() + _resetLayoutFramePositionForTests() +}) +afterEach(uninstallAll) + +type ControlEvent = { + environmentId: string + transportGeneration: number + diagnostics: { state: string; lastConnectedAt: number | null } +} +const controlListeners = new Set<(event: ControlEvent) => void>() +beforeEach(() => { + controlListeners.clear() + Object.assign(window.api.runtimeEnvironments ?? {}, { + onSharedControlDiagnostics: (listener: (event: ControlEvent) => void) => { + controlListeners.add(listener) + return () => controlListeners.delete(listener) + } + }) +}) + +/** The shared control connection's state, as main broadcasts it. */ +function control( + environmentId: string, + state: string, + transportGeneration: number, + lastConnectedAt: number +): void { + for (const listener of controlListeners) { + listener({ environmentId, transportGeneration, diagnostics: { state, lastConnectedAt } }) + } +} + +function reports(): unknown[] { + return hoisted.call.mock.calls + .filter(([, method]) => method === 'layout.reportView') + .map(([target, , params]) => ({ target, params })) +} + +function showGroup(store: TestStore, key: string, groupId: string): void { + store.setState({ + activeGroupIdByWorktree: { ...store.getState().activeGroupIdByWorktree, [key]: groupId } + }) +} + +async function frameDrawnStore(): Promise { + const store = mirroredStore() + showGroup(store, LOCAL_WT, 'local-group') + install(store) + setServers(store, [CAPABLE]) + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 1, + epoch: 'e' + }) + await settle() + return store +} + +/** ENV's workspace with a second, empty group beside the first. */ +const TWO_GROUPS = { + ...SERVER_LAYOUT, + groups: [ + { id: HOST_GROUP, tabIds: ['host-tab-1'] }, + { id: 'host-group-2', tabIds: [] } + ], + groupLayout: { + type: 'split' as const, + direction: 'horizontal' as const, + first: { type: 'leaf' as const, groupId: HOST_GROUP }, + second: { type: 'leaf' as const, groupId: 'host-group-2' } + } +} + +describe('the group this window shows, told to the server whose frames draw it', () => { + it('reports the frame-drawn workspaces only', async () => { + await frameDrawnStore() + + expect(isFrameDrawnServerWorkspace(ENV, WT)).toBe(true) + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: HOST_GROUP }, refresh: true } + } + ]) + }) + + it('sends again only when a group changes', async () => { + const store = await frameDrawnStore() + hoisted.call.mockClear() + + showGroup(store, LOCAL_WT, 'local-group-2') + store.setState({ activeGroupIdByWorktree: { ...store.getState().activeGroupIdByWorktree } }) + expect(reports()).toEqual([]) + + showGroup(store, WT, 'host-group-2') + showGroup(store, WT, 'host-group-2') + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: 'host-group-2' } } + } + ]) + }) + + it('does not resend a report the server failed to take', async () => { + hoisted.call.mockRejectedValue(new Error('offline')) + const store = await frameDrawnStore() + store.setState({ activeGroupIdByWorktree: { ...store.getState().activeGroupIdByWorktree } }) + await settle() + + expect(reports()).toHaveLength(1) + }) + + it("reports again on a new stream's snapshot as a refresh, then a change as live", async () => { + const store = await frameDrawnStore() + hoisted.call.mockClear() + + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 2, + epoch: 'e2' + }) + await settle() + + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: HOST_GROUP }, refresh: true } + } + ]) + + hoisted.call.mockClear() + showGroup(store, WT, 'host-group-2') + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: 'host-group-2' } } + } + ]) + }) + + it('sends nothing to a server without layout intents', async () => { + const store = mirroredStore() + install(store) + setServers(store, [{ ...CAPABLE, capable: false }]) + showGroup(store, WT, 'host-group-2') + await settle() + + // Presence: the server is paired and its workspace mirrored, with a group shown. + expect(store.getState().runtimeEnvironments.map((environment) => environment.id)).toEqual([ENV]) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe('host-group-2') + expect(reports()).toEqual([]) + }) + + it('reports again as a refresh when the control connection comes up on a new socket, the stream still up', async () => { + const store = await frameDrawnStore() + control(ENV, 'ready', 1, 100) + showGroup(store, WT, 'host-group-2') + hoisted.call.mockClear() + + control(ENV, 'reconnecting', 1, 100) + control(ENV, 'ready', 1, 100) + control('web-env-other', 'ready', 1, 200) + expect(reports()).toEqual([]) + + control(ENV, 'ready', 1, 200) + control(ENV, 'ready', 2, 200) + expect(streams.openTo(ENV).closed).toBe(false) + const again = { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: 'host-group-2' }, refresh: true } + } + expect(reports()).toEqual([again, again]) + }) + + it('stops sending, and warns once, after a server answers it has no such method', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + hoisted.call.mockRejectedValue(new Error('method_not_found')) + const store = await frameDrawnStore() + expect(reports()).toHaveLength(1) + + showGroup(store, WT, 'host-group-2') + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 2, + epoch: 'e2' + }) + control(ENV, 'ready', 3, 300) + await settle() + + expect(reports()).toHaveLength(1) + expect(warn.mock.calls.filter(([line]) => String(line).includes('view report'))).toHaveLength(1) + warn.mockRestore() + }) + + it("sends a snapshot's own group move as a refresh, never as a live report", async () => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: TWO_GROUPS }], + seq: 1, + epoch: 'e' + }) + await settle() + showGroup(store, WT, 'host-group-2') + expect(store.getState().activeGroupIdByWorktree[WT]).toBe('host-group-2') + hoisted.call.mockClear() + + // The server closed the group the desktop showed: its snapshot moves the desktop's view. + streams.send(streams.openTo(ENV), { + type: 'snapshot', + workspaces: [{ key: WT, layout: SERVER_LAYOUT }], + seq: 2, + epoch: 'e2' + }) + await settle() + + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(HOST_GROUP) + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: HOST_GROUP }, refresh: true } + } + ]) + }) + + it("sends a later frame's group move as a refresh, after a live report", async () => { + const store = mirroredStore() + install(store) + setServers(store, [CAPABLE]) + const stream = streams.openTo(ENV) + streams.send(stream, { + type: 'snapshot', + workspaces: [{ key: WT, layout: TWO_GROUPS }], + seq: 1 + }) + await settle() + showGroup(store, WT, 'host-group-2') + expect(reports().at(-1)).toEqual({ + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: 'host-group-2' } } + }) + hoisted.call.mockClear() + + // The server closes the group the desktop shows, in a frame after the snapshot. + streams.send(stream, { type: 'workspace', key: WT, layout: SERVER_LAYOUT, seq: 2 }) + await settle() + + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(HOST_GROUP) + expect(reports()).toEqual([ + { + target: { kind: 'environment', environmentId: ENV }, + params: { activeGroupByWorkspace: { [WT]: HOST_GROUP }, refresh: true } + } + ]) + }) +}) diff --git a/src/renderer/src/runtime/server-layout-view-report.ts b/src/renderer/src/runtime/server-layout-view-report.ts new file mode 100644 index 00000000000..d6ec281f6c6 --- /dev/null +++ b/src/renderer/src/runtime/server-layout-view-report.ts @@ -0,0 +1,116 @@ +// Tells a paired server which group this window shows in each workspace its frames draw +// (`layout.reportView`), so a CLI or phone file opened on the server lands where the user looks. +// Group ids are the server's own: frames draw its groups under their ids. Fire-and-forget. + +import { errorMessage } from '../../../shared/error-message' +import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' +import { callRuntimeRpc } from './runtime-rpc-client' +import { changedActiveGroups } from './sync-runtime-graph/active-group-publication' + +export type ServerViewReport = { + /** Sends the frame-drawn workspaces whose group changed since the last send, if any did. + * `fromFrames`: the server's own frames moved it, not the user, so it goes as a refresh. */ + update: ( + activeGroupIdByWorktree: Readonly>, + drawn: ReadonlySet, + fromFrames?: boolean + ) => void + /** Forgets what was sent: a new stream may come with a connection the server knows nothing of. + * The next report of each workspace is a refresh, which never outranks another client's. A + * server that lacked the method stays skipped: it is the same server for this stream's life. */ + reset: () => void +} + +function only( + groups: Readonly>, + drawn: ReadonlySet +): Record { + return Object.fromEntries(Object.entries(groups).filter(([key]) => drawn.has(key))) +} + +function partition( + groups: Record, + first: (key: string) => boolean +): [Record, Record] { + const entries = Object.entries(groups) + return [ + Object.fromEntries(entries.filter(([key]) => first(key))), + Object.fromEntries(entries.filter(([key]) => !first(key))) + ] +} + +export function createServerViewReport(environmentId: string): ServerViewReport { + let sent: Record = {} + /** Workspaces reported since the last reset: a first report re-establishes, a later one is a change. */ + const reported = new Set() + // Store slices are replaced, never edited: unchanged inputs have nothing new to send. + let seen: [Readonly>, ReadonlySet] | null = null + /** A switch-on server from before the method: it advertises layout intents without it. */ + let unsupported = false + const send = (activeGroupByWorkspace: Record, refresh: boolean): void => { + if (unsupported || Object.keys(activeGroupByWorkspace).length === 0) { + return + } + callRuntimeRpc({ kind: 'environment', environmentId }, 'layout.reportView', { + activeGroupByWorkspace, + ...(refresh ? { refresh: true } : {}) + }).catch((error: unknown) => { + if (unsupported) { + return + } + if (hasRuntimeRpcErrorCode(error, 'method_not_found')) { + unsupported = true + console.warn( + '[layout-owner] this server takes no view reports; files open in its saved view' + ) + return + } + // Not resent: the next change or stream reports again, and the server keeps its saved view. + console.warn('[layout-owner] a server did not take the view report:', errorMessage(error)) + }) + } + return { + update: (activeGroupIdByWorktree, drawn, fromFrames = false) => { + if (seen?.[0] === activeGroupIdByWorktree && seen[1] === drawn) { + return + } + seen = [activeGroupIdByWorktree, drawn] + const current = only(activeGroupIdByWorktree, drawn) + // A workspace that left the frames is not reported as showing none: its server keeps the last. + const changed = changedActiveGroups(current, only(sent, drawn)) + sent = current + if (!changed) { + return + } + const [refreshed, live] = partition(changed, (key) => fromFrames || !reported.has(key)) + for (const key of Object.keys(changed)) { + reported.add(key) + } + send(refreshed, true) + send(live, false) + }, + reset: () => { + sent = {} + seen = null + reported.clear() + } + } +} + +/** Calls back each time the shared control connection to `environmentId` comes up on a new socket: + * the server dropped the reports of the old one, while the layout stream may have stayed up. */ +export function onServerControlConnected(environmentId: string, connected: () => void): () => void { + let socket: string | null = null + return ( + window.api?.runtimeEnvironments?.onSharedControlDiagnostics?.((event) => { + if (event.environmentId !== environmentId || event.diagnostics.state !== 'ready') { + return + } + const next = `${event.transportGeneration}:${event.diagnostics.lastConnectedAt}` + if (next !== socket) { + socket = next + connected() + } + }) ?? (() => {}) + ) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 161a45192d0..fbcfdbb9413 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -612,6 +612,7 @@ import { } from './usage-credentials-params' import { LayoutApplyParams, + LayoutReportViewParams, LayoutSubscribeParams, LayoutUnsubscribeParams } from './workspace-layout-params' @@ -1081,6 +1082,7 @@ export const RPC_PARAMS_BY_METHOD = { 'jira.updateIssue': IssueUpdate, 'layout.apply': LayoutApplyParams, 'layout.getRemoteSnapshots': null, + 'layout.reportView': LayoutReportViewParams, 'layout.subscribe': LayoutSubscribeParams, 'layout.unsubscribe': LayoutUnsubscribeParams, 'linear.addIssueComment': IssueCommentOfLinearParams, diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 90d9cb83616..e54ea64f376 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -21,6 +21,13 @@ export const LayoutUnsubscribeParams = z.object({ subscriptionId: z.string().min(1, 'Missing subscriptionId') }) +/** A paired desktop's view of the server's workspaces: each one's shown group, null for none. */ +export const LayoutReportViewParams = z.object({ + activeGroupByWorkspace: z.record(z.string().min(1), z.string().min(1).nullable()), + /** A re-send after a reconnect, not a change the user made: it never outranks a live report. */ + refresh: z.literal(true).optional() +}) + const OnPane = { workspace: z.string().min(1), tabId: z.string().min(1), diff --git a/tests/e2e/paired-desktop-server-file-open-group.spec.ts b/tests/e2e/paired-desktop-server-file-open-group.spec.ts new file mode 100644 index 00000000000..7663e053e0b --- /dev/null +++ b/tests/e2e/paired-desktop-server-file-open-group.spec.ts @@ -0,0 +1,248 @@ +/** + * A switch-on desktop paired to a switch-on `orca serve` reports the group it shows in each server + * workspace its frames draw (`layout.reportView`), so a file the CLI opens on the server lands in + * that group, whichever of the workspace's groups the server's saved view names (H5c-v). + * + * Run (switch-on build: ORCA_LAYOUT_OWNER=main ... --mode e2e): + * pnpm exec playwright test tests/e2e/paired-desktop-server-file-open-group.spec.ts \ + * --config tests/playwright.config.ts --project electron-headless --workers=2 + */ +import { existsSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pairedRuntimeConnectionFromOffer } from '../../src/shared/paired-runtime-connection' +import { decodePairingOffer } from '../../src/shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../src/shared/remote-runtime-client' +import { HOST_TERMINAL_SURFACE_SEPARATOR } from '../../src/shared/terminal-surface-id' +import { LAYOUT_INTENTS_RUNTIME_CAPABILITY } from '../../src/shared/workspace-layout/layout-intents-capability' +import type { PublishedWorkspaceLayout } from '../../src/shared/workspace-layout/workspace-layout-published' +import { readWorkspaceLayoutStreamFrame } from '../../src/shared/workspace-layout/workspace-layout-stream-frames' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' + +const LAYOUT_OWNER_BUILD = existsSync( + path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') +) + +/** The server's own layout of the workspace: the first snapshot of a stream of the test's own. */ +async function readServerLayout( + host: HeadlessPairedRuntimeHost, + worktreeId: string +): Promise { + let resolveSnapshot: (value: unknown) => void = () => {} + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve + }) + const subscription = await subscribeRemoteRuntimeRequest( + pairedRuntimeConnectionFromOffer(decodePairingOffer(host.offer.pairingUrl)), + 'layout.subscribe', + { workspaces: [worktreeId] }, + 30_000, + { + onResponse: (response) => resolveSnapshot(response.ok ? response.result : null), + onError: () => resolveSnapshot(null), + onClose: () => resolveSnapshot(null) + } + ) + try { + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 10_000)) + const frame = readWorkspaceLayoutStreamFrame(await Promise.race([snapshot, timeout])) + if (frame?.type !== 'snapshot') { + return null + } + return frame.workspaces.find((entry) => entry.key === worktreeId)?.layout ?? null + } finally { + subscription.close() + } +} + +function groupOfTab(layout: PublishedWorkspaceLayout | null, tabId: string): string | null { + return layout?.groups.find((group) => group.tabIds.includes(tabId))?.id ?? null +} + +/** The group holding the editor tab of `fileName`; null while the server has none. */ +function groupOfFile(layout: PublishedWorkspaceLayout | null, fileName: string): string | null { + const tab = layout?.tabs.find( + (entry) => + entry.kind === 'editor' && decodeURIComponent(entry.entityId).endsWith(`/${fileName}`) + ) + return tab ? groupOfTab(layout, tab.id) : null +} + +async function waitForWorktree(host: HeadlessPairedRuntimeHost, repoId: string): Promise { + let worktreeId = '' + await expect + .poll( + async () => { + const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', { + repo: `id:${repoId}` + }) + worktreeId = listed.result.worktrees[0]?.id ?? '' + return worktreeId + }, + { timeout: 30_000, message: 'Serve host never listed its folder workspace' } + ) + .not.toBe('') + return worktreeId +} + +async function createServerTerminal( + host: HeadlessPairedRuntimeHost, + worktreeId: string +): Promise { + const created = await host.client.call<{ tab: { id: string } }>('session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + activate: false, + select: false, + navigation: 'caller' + }) + return created.result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]! +} + +/** Shows the server workspace on the desktop and waits until its frames draw `groupIds`. */ +async function showFrameDrawnWorkspace( + client: PairedElectronClient, + worktreeId: string, + groupIds: string[] +): Promise { + await expect + .poll( + () => + client.page.evaluate( + ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + if (!state?.allWorktrees().some((worktree) => worktree.id === worktreeId)) { + return 'unlisted' + } + if (state.activeWorktreeId !== worktreeId) { + state.setActiveView('terminal') + state.setActiveWorktree(worktreeId, `runtime:${environmentId}`) + } + const drawn = window.__isFrameDrawnServerWorkspaceE2E?.(environmentId, worktreeId) + return drawn + ? (state.groupsByWorktree[worktreeId] ?? []).map((group) => group.id) + : 'not frame-drawn' + }, + { environmentId: client.environmentId, worktreeId } + ), + { timeout: 90_000, message: 'The desktop never drew both server groups from frames' } + ) + .toEqual(groupIds) +} + +async function showGroupOnDesktop( + client: PairedElectronClient, + worktreeId: string, + groupId: string +): Promise { + await client.page.evaluate( + ({ worktreeId, groupId }) => window.__store!.getState().focusGroup(worktreeId, groupId), + { worktreeId, groupId } + ) + await expect + .poll(() => + client.page.evaluate( + (id) => window.__store!.getState().activeGroupIdByWorktree[id] ?? null, + worktreeId + ) + ) + .toBe(groupId) +} + +test.describe('a file opened on a switch-on server lands in the group a paired desktop shows', () => { + test.skip(!LAYOUT_OWNER_BUILD, 'Needs a layout-owner build (ORCA_LAYOUT_OWNER=main)') + + test('each CLI open follows the group the desktop switched to', async ({ + testRepoPath + }, testInfo) => { + test.setTimeout(300_000) + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + try { + const status = await host.client.call<{ capabilities?: string[] }>('status.get') + expect(status.result.capabilities ?? []).toContain(LAYOUT_INTENTS_RUNTIME_CAPABILITY) + + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'folder' + }) + const worktreeId = await waitForWorktree(host, added.result.repo.id) + const firstTab = await createServerTerminal(host, worktreeId) + const secondTab = await createServerTerminal(host, worktreeId) + let groupA: string | null = null + await expect + .poll(async () => (groupA = groupOfTab(await readServerLayout(host, worktreeId), firstTab))) + .not.toBeNull() + // Two groups: the second terminal moves into a new group beside the first. + await host.client.call('layout.apply', { + command: { + type: 'splitGroup', + workspace: worktreeId, + tabId: secondTab, + besideGroupId: groupA, + direction: 'right' + } + }) + let groups: string[] = [] + await expect + .poll(async () => { + const layout = await readServerLayout(host, worktreeId) + groups = layout?.groups.map((group) => group.id) ?? [] + const split = + groups.length === 2 && + groupOfTab(layout, firstTab) === groups[0] && + groupOfTab(layout, secondTab) === groups[1] + return split ? 'split' : { groups, layout } + }) + .toBe('split') + + client = await launchPairedElectronClient(host.offer, testInfo, 'server-file-open-group') + const desktop = client + await showFrameDrawnWorkspace(desktop, worktreeId, groups) + + // Both groups in turn: whichever one the server's saved view names, the other proves the report. + for (const [index, target] of [groups[1]!, groups[0]!, groups[1]!].entries()) { + await test.step(`the desktop shows group ${index === 1 ? 'A' : 'B'}; a CLI open lands there`, async () => { + await showGroupOnDesktop(desktop, worktreeId, target) + // A fresh file per try: the report is fire-and-forget, so the server may not have it yet. + let attempt = 0 + await expect + .poll( + async () => { + const fileName = `h5cv-${index}-${attempt++}.txt` + writeFileSync(path.join(testRepoPath, fileName), `${fileName}\n`) + await host.client.call('files.open', { + worktree: `id:${worktreeId}`, + relativePath: fileName + }) + let landed: string | null = null + await expect + .poll( + async () => + (landed = groupOfFile(await readServerLayout(host, worktreeId), fileName)), + { timeout: 15_000 } + ) + .not.toBeNull() + return landed + }, + { + timeout: 30_000, + intervals: [1_000], + message: 'The CLI-opened file never landed in the group the desktop shows' + } + ) + .toBe(target) + }) + } + } finally { + await client?.dispose() + await host.dispose() + } + }) +})