mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
A CLI or phone file on a layout-owner server opens in the tab group a paired desktop shows (switch-on builds only) (#31264)
* feat(layout-owner): a CLI or phone file on a layout-owner server opens in the group a paired desktop shows Each paired desktop reports, through a new layout.reportView (served only by layout.intents.v1 servers, never to phones), the group it shows in each workspace the server's frames draw. The server keeps reports per connection beside the window's, opens files in the latest one's group, and drops a connection's reports when it closes, falling back to the saved view. * test(e2e): a CLI file open on a switch-on server lands in the group a paired desktop shows * fix(layout-owner): layout.reportView refuses a caller with no connection, as sidebarInputs.reportView does * feat(layout-owner): a desktop's re-sent view after a reconnect never outranks another desktop's live report layout.reportView takes an optional refresh flag. The desktop's first report of each workspace after a stream snapshot carries it; the server ranks such entries below every live report, while they still replace the connection's own earlier entries and beat the saved view. * feat(layout-owner): a paired desktop re-sends its view, as a refresh, when its control connection to the server comes up on a new socket The server drops a connection's view reports when it closes, and the layout stream can stay up across that, so the desktop watches the shared control diagnostics for a new socket. * fix(layout-owner): a desktop's refresh keeps its own live rank; stop reporting to a server without layout.reportView A same-connection resubscribe re-sends its view as a refresh, which demoted the connection's own live report below an older desktop's. Ranks are now an explicit live/refresh tier. A pre-PR switch-on server answers method_not_found; the desktop then stops sending for that stream. * fix(layout-owner): a group the server's own frames move is re-sent as a refresh, never a live report The snapshot resend read the store before the sync applied the snapshot, so the flush's group change reached the store subscription as the user's. Writes made by the frame sync now report as refreshes, and the snapshot resend runs after the flush. * test(layout-owner): a change frame's group move goes out as a refresh; the frame-write mark survives nested writes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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<Record<string, string | null>>,
|
||||
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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string | null>()
|
||||
private readonly fromWindow = new Map<string, Reported>()
|
||||
/** Paired desktop connection id → its reports, as the window's. */
|
||||
private readonly fromClients = new Map<string, Map<string, Reported>>()
|
||||
private order = 0
|
||||
|
||||
/** A window's report: each named workspace's entry replaces the last; others are kept. */
|
||||
merge(report: Readonly<Record<string, string | null>> | 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<Record<string, string | null>>,
|
||||
refresh = false
|
||||
): boolean {
|
||||
const known = this.fromClients.get(connectionId)
|
||||
const reports = known ?? new Map<string, Reported>()
|
||||
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<string, Reported>,
|
||||
report: Readonly<Record<string, string | null>> | 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string>
|
||||
resync: Mock<(environmentId: string, worktreeId: string) => Promise<void>>
|
||||
call: Mock<(target: unknown, method: string, params?: unknown) => Promise<unknown>>
|
||||
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<typeof FrameDrawnModule>()),
|
||||
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<TestStore> {
|
||||
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 }
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string>>,
|
||||
drawn: ReadonlySet<string>,
|
||||
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<Record<string, string>>,
|
||||
drawn: ReadonlySet<string>
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(groups).filter(([key]) => drawn.has(key)))
|
||||
}
|
||||
|
||||
function partition(
|
||||
groups: Record<string, string | null>,
|
||||
first: (key: string) => boolean
|
||||
): [Record<string, string | null>, Record<string, string | null>] {
|
||||
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<string, string> = {}
|
||||
/** Workspaces reported since the last reset: a first report re-establishes, a later one is a change. */
|
||||
const reported = new Set<string>()
|
||||
// Store slices are replaced, never edited: unchanged inputs have nothing new to send.
|
||||
let seen: [Readonly<Record<string, string>>, ReadonlySet<string>] | null = null
|
||||
/** A switch-on server from before the method: it advertises layout intents without it. */
|
||||
let unsupported = false
|
||||
const send = (activeGroupByWorkspace: Record<string, string | null>, 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()
|
||||
}
|
||||
}) ?? (() => {})
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<PublishedWorkspaceLayout | null> {
|
||||
let resolveSnapshot: (value: unknown) => void = () => {}
|
||||
const snapshot = new Promise<unknown>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
const subscription = await subscribeRemoteRuntimeRequest<unknown>(
|
||||
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<null>((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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user