mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(terminal): mount one surface per workspace id in the workbench (STA-4846)
This commit is contained in:
@@ -10,9 +10,10 @@ import {
|
||||
type BackgroundMountTerminalWorktreeDetail
|
||||
} from '@/constants/terminal'
|
||||
import { useAppStore } from '../store'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { useAllWorktrees } from '../store/selectors'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { useWorktreeMap } from '../store/selectors'
|
||||
import { projectWorkspaceSurfaces } from './workspace-surface-projection'
|
||||
import { getConnectionId } from '../lib/connection-context'
|
||||
import { basename } from '../lib/path'
|
||||
import {
|
||||
@@ -322,23 +323,29 @@ function Terminal(): React.JSX.Element | null {
|
||||
const measuringTerminalWorktreeIdsRef = useRef(new Set<string>())
|
||||
const terminalWorktreeParkCooldownUntilRef = useRef(new Map<string, number>())
|
||||
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const worktreesById = useWorktreeMap()
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const workspaceSurfaces = useMemo(
|
||||
() => [
|
||||
...allWorktrees.map((worktree) => ({ id: worktree.id, path: worktree.path })),
|
||||
...folderWorkspaces.map((workspace) => ({
|
||||
id: folderWorkspaceKey(workspace.id),
|
||||
path: workspace.folderPath
|
||||
}))
|
||||
],
|
||||
[allWorktrees, folderWorkspaces]
|
||||
)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeWorktreeDeferralHostId = useAppStore((s) =>
|
||||
getResolvedExecutionHostIdForWorktree(s, renderedActiveWorktreeId)
|
||||
)
|
||||
// Why narrow it: only the folder-collision tie-break reads this host, so a git
|
||||
// workspace's ownership settling must not re-identify the whole mount projection.
|
||||
const activeFolderSurfaceHostId =
|
||||
parseWorkspaceKey(renderedActiveWorktreeId ?? '')?.type === 'folder'
|
||||
? activeWorktreeDeferralHostId
|
||||
: null
|
||||
const workspaceSurfaces = useMemo(
|
||||
() =>
|
||||
projectWorkspaceSurfaces({
|
||||
worktreesById,
|
||||
folderWorkspaces,
|
||||
activeWorkspaceId: renderedActiveWorktreeId,
|
||||
activeWorkspaceResolvedHostId: activeFolderSurfaceHostId
|
||||
}),
|
||||
[worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId]
|
||||
)
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
// Why: terminal titles are leaf chrome. The root host only subscribes to
|
||||
// mount/parking semantics; a real transition publishes fresh tab objects,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* STA-4846: the terminal workbench is bare-workspace-id keyed end to end
|
||||
* (`activeWorktreeId`, `tabsByWorktree`, `mountedWorktreeIdsRef`, React keys),
|
||||
* but its catalog inputs are host-qualified — `getIndexedAllWorktrees` emits one
|
||||
* row per (host, id) and `mergeFetchedFolderWorkspacesForHost` keeps one folder
|
||||
* row per (host, id). A repo checked out at the same path locally and on an
|
||||
* SSH/paired-runtime host therefore reached the mount loops twice, mounting the
|
||||
* same tabIds under duplicate React keys with both trees marked visible.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectWorkspaceSurfaces } from './workspace-surface-projection'
|
||||
import { getIndexedWorktreeMap } from '../store/worktree-repo-index'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
|
||||
const SHARED_WORKTREE_ID = 'repo-shared::/work/orca-feature'
|
||||
|
||||
// A production collision differs only by host: `worktreeId` is `repoId::path`,
|
||||
// so both rows necessarily carry the same repoId and path (see STA-4343's
|
||||
// sidebar/worktree-list-groups-host-collision.test.ts).
|
||||
const localWorktree: Worktree = {
|
||||
id: SHARED_WORKTREE_ID,
|
||||
repoId: 'repo-shared',
|
||||
path: '/work/orca-feature',
|
||||
hostId: 'local',
|
||||
head: 'abc123',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'orca-feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1
|
||||
}
|
||||
const sshWorktree: Worktree = { ...localWorktree, hostId: 'ssh:build-box' }
|
||||
|
||||
const localFolder: FolderWorkspace = {
|
||||
id: 'folder-shared',
|
||||
projectGroupId: 'group-shared',
|
||||
name: 'orca',
|
||||
folderPath: '/work/orca-local',
|
||||
executionHostId: 'local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const runtimeFolder: FolderWorkspace = {
|
||||
...localFolder,
|
||||
folderPath: '/remote/orca',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
|
||||
function project(input: {
|
||||
worktrees?: readonly Worktree[]
|
||||
folderWorkspaces?: readonly FolderWorkspace[]
|
||||
activeWorkspaceId?: string | null
|
||||
activeWorkspaceResolvedHostId?: ExecutionHostId | null
|
||||
}): ReturnType<typeof projectWorkspaceSurfaces> {
|
||||
return projectWorkspaceSurfaces({
|
||||
// Built through the production index so the test pins the composition the
|
||||
// workbench actually runs, not a re-implementation of the per-id collapse.
|
||||
worktreesById: getIndexedWorktreeMap({ 'repo-shared': [...(input.worktrees ?? [])] }),
|
||||
folderWorkspaces: input.folderWorkspaces ?? [],
|
||||
activeWorkspaceId: input.activeWorkspaceId ?? null,
|
||||
activeWorkspaceResolvedHostId: input.activeWorkspaceResolvedHostId ?? null
|
||||
})
|
||||
}
|
||||
|
||||
describe('projectWorkspaceSurfaces', () => {
|
||||
it('emits one surface per workspace id when two hosts publish the same worktree', () => {
|
||||
const surfaces = project({ worktrees: [localWorktree, sshWorktree] })
|
||||
|
||||
expect(surfaces).toEqual([{ id: SHARED_WORKTREE_ID, path: '/work/orca-feature' }])
|
||||
})
|
||||
|
||||
it('never emits a duplicate id, so mount loops cannot reuse a React key', () => {
|
||||
const surfaces = project({
|
||||
worktrees: [localWorktree, sshWorktree],
|
||||
folderWorkspaces: [localFolder, runtimeFolder]
|
||||
})
|
||||
|
||||
expect(new Set(surfaces.map((surface) => surface.id)).size).toBe(surfaces.length)
|
||||
})
|
||||
|
||||
it('emits one surface per folder workspace id across hosts', () => {
|
||||
const surfaces = project({ folderWorkspaces: [localFolder, runtimeFolder] })
|
||||
|
||||
expect(surfaces).toHaveLength(1)
|
||||
expect(surfaces[0]?.id).toBe('folder:folder-shared')
|
||||
})
|
||||
|
||||
it('mounts the active folder workspace at its resolved host path, not the first row', () => {
|
||||
const surfaces = project({
|
||||
folderWorkspaces: [localFolder, runtimeFolder],
|
||||
activeWorkspaceId: 'folder:folder-shared',
|
||||
activeWorkspaceResolvedHostId: 'runtime:env-1'
|
||||
})
|
||||
|
||||
expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }])
|
||||
})
|
||||
|
||||
it('keeps the first row when no resolved host disambiguates the folder collision', () => {
|
||||
const surfaces = project({ folderWorkspaces: [runtimeFolder, localFolder] })
|
||||
|
||||
expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }])
|
||||
})
|
||||
|
||||
it('switches the active folder path from first-wins to the host that hydrates', () => {
|
||||
// Ownership resolves to null while the folder-owner index still reads the
|
||||
// colliding id as ambiguous, so the projection first-wins until it lands.
|
||||
const folderWorkspaces = [localFolder, runtimeFolder]
|
||||
const hydrating = project({
|
||||
folderWorkspaces,
|
||||
activeWorkspaceId: 'folder:folder-shared',
|
||||
activeWorkspaceResolvedHostId: null
|
||||
})
|
||||
const hydrated = project({
|
||||
folderWorkspaces,
|
||||
activeWorkspaceId: 'folder:folder-shared',
|
||||
activeWorkspaceResolvedHostId: 'runtime:env-1'
|
||||
})
|
||||
|
||||
expect(hydrating).toEqual([{ id: 'folder:folder-shared', path: '/work/orca-local' }])
|
||||
expect(hydrated).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }])
|
||||
// The id is what mounts; only the path moves, so the transition is no remount.
|
||||
expect(hydrated[0]?.id).toBe(hydrating[0]?.id)
|
||||
})
|
||||
|
||||
it('keeps the surviving host row when a runtime disconnect drops its peer', () => {
|
||||
// Loss of contact with the runtime must not unmount the workspace: the
|
||||
// mount prune drops ids that leave the projection.
|
||||
const surfaces = project({ worktrees: [localWorktree] })
|
||||
|
||||
expect(surfaces).toEqual([{ id: SHARED_WORKTREE_ID, path: '/work/orca-feature' }])
|
||||
})
|
||||
|
||||
it('keeps distinct workspaces on one host', () => {
|
||||
const otherWorktree: Worktree = {
|
||||
...localWorktree,
|
||||
id: 'repo-shared::/work/orca-other',
|
||||
path: '/work/orca-other'
|
||||
}
|
||||
|
||||
expect(project({ worktrees: [localWorktree, otherWorktree] })).toEqual([
|
||||
{ id: SHARED_WORKTREE_ID, path: '/work/orca-feature' },
|
||||
{ id: 'repo-shared::/work/orca-other', path: '/work/orca-other' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Why source text: the per-id collapse lives in the store index, so the module
|
||||
// tests above stay green even if Terminal.tsx goes back to flattening the
|
||||
// host-qualified array itself. The feed is the half that has to be ratcheted.
|
||||
describe('Terminal workbench surface feed', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
it('feeds the projection from the store per-id index, never the host-qualified array', () => {
|
||||
expect(source).not.toContain('useAllWorktrees')
|
||||
expect(source).toContain('const worktreesById = useWorktreeMap()')
|
||||
})
|
||||
|
||||
it('has exactly one projection call site, so no second flatten can hide beside it', () => {
|
||||
expect(
|
||||
source.split('projectWorkspaceSurfaces(').length - 1,
|
||||
'expected exactly one projectWorkspaceSurfaces call in Terminal.tsx'
|
||||
).toBe(1)
|
||||
expect(source).toContain('worktreesById,\n folderWorkspaces,')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getCatalogOwnerHostId } from '../lib/worktree-runtime-owner-index'
|
||||
|
||||
export type WorkspaceSurface = { id: string; path: string }
|
||||
|
||||
type FolderWorkspaceSurfaceRow = Pick<
|
||||
FolderWorkspace,
|
||||
'id' | 'folderPath' | 'connectionId' | 'executionHostId'
|
||||
>
|
||||
|
||||
/**
|
||||
* The terminal workbench's mount set: exactly one surface per workspace id.
|
||||
*
|
||||
* Why collapse here and nowhere else: the workbench is bare-id keyed end to end
|
||||
* (`activeWorktreeId`, `tabsByWorktree`, `mountedWorktreeIdsRef`, React keys),
|
||||
* so it can only represent one surface per id — but both catalogs it reads are
|
||||
* host-qualified on purpose (STA-4343), keeping a row per (host, id). Emitting
|
||||
* both mounts one workspace's tabs twice under a duplicate React key. Listing
|
||||
* surfaces such as the sidebar must keep showing every host.
|
||||
*
|
||||
* `worktreesById` is the store's first-wins per-id index, and that collapse is
|
||||
* lossless: `worktreeId` is `repoId::path`, so colliding rows agree on the path.
|
||||
*/
|
||||
export function projectWorkspaceSurfaces({
|
||||
worktreesById,
|
||||
folderWorkspaces,
|
||||
activeWorkspaceId,
|
||||
activeWorkspaceResolvedHostId
|
||||
}: {
|
||||
worktreesById: ReadonlyMap<string, Pick<Worktree, 'path'>>
|
||||
folderWorkspaces: readonly FolderWorkspaceSurfaceRow[]
|
||||
activeWorkspaceId: string | null
|
||||
/** Resolved (not user-selected) host of the active workspace; the folder tie-break. */
|
||||
activeWorkspaceResolvedHostId: ExecutionHostId | null
|
||||
}): WorkspaceSurface[] {
|
||||
const surfaces: WorkspaceSurface[] = []
|
||||
for (const [worktreeId, worktree] of worktreesById) {
|
||||
surfaces.push({ id: worktreeId, path: worktree.path })
|
||||
}
|
||||
const folderSurfaceIndexById = new Map<string, number>()
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const id = folderWorkspaceKey(workspace.id)
|
||||
const surface = { id, path: workspace.folderPath }
|
||||
const existingIndex = folderSurfaceIndexById.get(id)
|
||||
if (existingIndex === undefined) {
|
||||
folderSurfaceIndexById.set(id, surfaces.length)
|
||||
surfaces.push(surface)
|
||||
continue
|
||||
}
|
||||
// Why: a folder-workspace id is opaque, not path-derived, so colliding hosts
|
||||
// disagree on the path; only the active workspace's resolved host breaks the tie.
|
||||
// Deriving that host from the row alone is sufficient because every stored row is
|
||||
// stamped with an explicit `executionHostId` by `folderWorkspaceWithFetchedOwner`.
|
||||
if (
|
||||
activeWorkspaceResolvedHostId &&
|
||||
id === activeWorkspaceId &&
|
||||
getCatalogOwnerHostId(workspace) === activeWorkspaceResolvedHostId
|
||||
) {
|
||||
surfaces[existingIndex] = surface
|
||||
}
|
||||
}
|
||||
return surfaces
|
||||
}
|
||||
@@ -16,22 +16,37 @@ export async function readHostTabs(
|
||||
return response.result
|
||||
}
|
||||
|
||||
type HostBrowserPageRow = { browserPageId: string; url: string }
|
||||
|
||||
/**
|
||||
* The browser pages the host itself still holds for a worktree.
|
||||
* The host's own browser page registry for a workspace, addressed by worktree selector.
|
||||
*
|
||||
* Why not readHostTabs: a headless paired host does not project browser pages into
|
||||
* session.tabs.list — that snapshot carries only terminals — so asking it whether a page survived
|
||||
* a close answers "no" whether or not the close ever reached the host. browser.tabList reads the
|
||||
* host's own page registry, which is the thing a close has to empty.
|
||||
* session.tabs.list — that snapshot carries only terminals, and it additionally hides client-placed
|
||||
* pages from any peer that does not advertise `BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY`, which the
|
||||
* CLI socket deliberately does not. `browser.tabList` has neither limitation, so it is the only
|
||||
* oracle that answers "does the host still hold this page" for both placements.
|
||||
*/
|
||||
async function readHostBrowserPages(
|
||||
hostClient: RuntimeClient,
|
||||
worktreeSelector: string,
|
||||
timeoutMs?: number
|
||||
): Promise<HostBrowserPageRow[]> {
|
||||
const response = await hostClient.call<{ tabs: HostBrowserPageRow[] }>(
|
||||
'browser.tabList',
|
||||
{ worktree: worktreeSelector },
|
||||
{ timeoutMs }
|
||||
)
|
||||
return response.result.tabs
|
||||
}
|
||||
|
||||
/** The page ids the host still holds for a repo-backed worktree. */
|
||||
export async function readHostBrowserPageIds(
|
||||
hostClient: RuntimeClient,
|
||||
repoPath: string
|
||||
): Promise<string[]> {
|
||||
const response = await hostClient.call<{ tabs: { browserPageId: string }[] }>('browser.tabList', {
|
||||
worktree: `path:${repoPath}`
|
||||
})
|
||||
return response.result.tabs.map((tab) => tab.browserPageId).sort()
|
||||
const tabs = await readHostBrowserPages(hostClient, `path:${repoPath}`)
|
||||
return tabs.map((tab) => tab.browserPageId).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,9 +61,21 @@ export async function readHostBrowserPageUrl(
|
||||
repoPath: string,
|
||||
browserPageId: string
|
||||
): Promise<string | null> {
|
||||
const response = await hostClient.call<{ tabs: { browserPageId: string; url: string }[] }>(
|
||||
'browser.tabList',
|
||||
{ worktree: `path:${repoPath}` }
|
||||
)
|
||||
return response.result.tabs.find((tab) => tab.browserPageId === browserPageId)?.url ?? null
|
||||
const tabs = await readHostBrowserPages(hostClient, `path:${repoPath}`)
|
||||
return tabs.find((tab) => tab.browserPageId === browserPageId)?.url ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every browser page URL the host holds, for callers that address the workspace by selector
|
||||
* (`id:`/`path:`) rather than repo path — a folder workspace has no repo path.
|
||||
*
|
||||
* Why the explicit ceiling: a paired host's client is constructed with a 5s default, which the
|
||||
* first tabList can outrun while the host brings its browser session up.
|
||||
*/
|
||||
export async function readHostBrowserPageUrls(
|
||||
hostClient: RuntimeClient,
|
||||
worktreeSelector: string
|
||||
): Promise<string[]> {
|
||||
const tabs = await readHostBrowserPages(hostClient, worktreeSelector, 15_000)
|
||||
return tabs.map((tab) => tab.url)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady,
|
||||
waitForStartupWorktreeRefresh
|
||||
} from './helpers/store'
|
||||
|
||||
test('routes same-id browser and simulator Cmd-J rows to their owning paired host', async ({
|
||||
orcaPage
|
||||
@@ -66,6 +70,9 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
{ timeout: 60_000, message: 'paired client never mirrored the host browser tab' }
|
||||
)
|
||||
.toBe(remoteHostId)
|
||||
// Why: hydration's deferred all-host scan rewrites worktreesByRepo; seeding ahead of it is silently reaped.
|
||||
await waitForStartupWorktreeRefresh(page)
|
||||
// Why: empties the mirror's reachable-target set, so the session.tabs subscription tears down before seeding.
|
||||
await page.evaluate(() => {
|
||||
window.__store!.setState({
|
||||
runtimeEnvironments: [],
|
||||
@@ -113,6 +120,24 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
) {
|
||||
throw new Error('Paired client did not retain the mirrored host browser topology')
|
||||
}
|
||||
// Why: the fixture nests the client's own layout under its local pane, so the remote group
|
||||
// has to already be rendered there — otherwise the remote rows silently stop being visible.
|
||||
const renderedGroupIds = new Set<string>()
|
||||
const pendingLayoutNodes = [state.layoutByWorktree[sharedWorktreeId]]
|
||||
while (pendingLayoutNodes.length > 0) {
|
||||
const node = pendingLayoutNodes.pop()
|
||||
if (!node) {
|
||||
continue
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
renderedGroupIds.add(node.groupId)
|
||||
continue
|
||||
}
|
||||
pendingLayoutNodes.push(node.first, node.second)
|
||||
}
|
||||
if (renderedGroupIds.size > 0 && !renderedGroupIds.has(remoteGroup.id)) {
|
||||
throw new Error('Paired client layout does not render the mirrored host browser group')
|
||||
}
|
||||
const local = {
|
||||
...seed,
|
||||
hostId: 'local' as const,
|
||||
@@ -162,9 +187,17 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
})
|
||||
// Why: a session.tabs frame rebuilds the host's live tabs from the snapshot either way;
|
||||
// keeping their ids in the groups' tabOrder is what makes placement treat them as already
|
||||
// known, so the frame re-lands them where they are instead of adopting them into a group.
|
||||
const retainedMirroredTabs = (state.unifiedTabsByWorktree[sharedWorktreeId] ?? []).map(
|
||||
(candidate) =>
|
||||
candidate.id === remoteUnifiedTab.id
|
||||
? { ...candidate, executionHostId: remoteHostId }
|
||||
: candidate
|
||||
)
|
||||
const tabs = [
|
||||
tab('browser-tab-local', 'browser-local', 'group-local', 'local', 'browser', 'Local'),
|
||||
{ ...remoteUnifiedTab, executionHostId: remoteHostId },
|
||||
tab(
|
||||
'simulator-local',
|
||||
'simulator-local',
|
||||
@@ -180,15 +213,16 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
remoteHostId,
|
||||
'simulator',
|
||||
'Remote emulator proof'
|
||||
)
|
||||
),
|
||||
...retainedMirroredTabs
|
||||
]
|
||||
store.setState({
|
||||
repos: [{ ...seedRepo, connectionId: null, executionHostId: 'local' }, seedRepo],
|
||||
worktreesByRepo: { [seed.repoId]: [local, remote] },
|
||||
worktreesByRepo: { ...state.worktreesByRepo, [seed.repoId]: [local, remote] },
|
||||
activeRepoId: seed.repoId,
|
||||
activeWorktreeId: sharedWorktreeId,
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
browserTabsByWorktree: {
|
||||
...state.browserTabsByWorktree,
|
||||
[sharedWorktreeId]: [localBrowser, seededRemoteBrowser]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
@@ -212,8 +246,9 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: { [sharedWorktreeId]: tabs },
|
||||
unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [sharedWorktreeId]: tabs },
|
||||
groupsByWorktree: {
|
||||
...state.groupsByWorktree,
|
||||
[sharedWorktreeId]: [
|
||||
{
|
||||
id: 'group-local',
|
||||
@@ -221,28 +256,45 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
activeTabId: 'browser-tab-local',
|
||||
tabOrder: ['browser-tab-local', 'simulator-local']
|
||||
},
|
||||
{
|
||||
...remoteGroup,
|
||||
worktreeId: sharedWorktreeId,
|
||||
activeTabId: remoteUnifiedTab.id,
|
||||
tabOrder: [remoteUnifiedTab.id, 'simulator-remote']
|
||||
}
|
||||
...(state.groupsByWorktree[sharedWorktreeId] ?? []).map((group) =>
|
||||
group.id === remoteGroup.id
|
||||
? {
|
||||
...group,
|
||||
activeTabId: remoteUnifiedTab.id,
|
||||
tabOrder: [...group.tabOrder, 'simulator-remote']
|
||||
}
|
||||
: group
|
||||
)
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [sharedWorktreeId]: 'group-local' },
|
||||
activeGroupIdByWorktree: {
|
||||
...state.activeGroupIdByWorktree,
|
||||
[sharedWorktreeId]: 'group-local'
|
||||
},
|
||||
layoutByWorktree: {
|
||||
...state.layoutByWorktree,
|
||||
[sharedWorktreeId]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-local' },
|
||||
second: { type: 'leaf', groupId: remoteGroup.id },
|
||||
// Why: wrap the client's own layout so a host-side split keeps rendering its panes.
|
||||
second: state.layoutByWorktree[sharedWorktreeId] ?? {
|
||||
type: 'leaf',
|
||||
groupId: remoteGroup.id
|
||||
},
|
||||
ratio: 0.5
|
||||
}
|
||||
},
|
||||
activeBrowserTabId: 'browser-local',
|
||||
activeBrowserTabIdByWorktree: { [sharedWorktreeId]: 'browser-local' },
|
||||
activeBrowserTabIdByWorktree: {
|
||||
...state.activeBrowserTabIdByWorktree,
|
||||
[sharedWorktreeId]: 'browser-local'
|
||||
},
|
||||
activeTabType: 'browser',
|
||||
activeTabTypeByWorktree: { [sharedWorktreeId]: 'browser' }
|
||||
activeTabTypeByWorktree: {
|
||||
...state.activeTabTypeByWorktree,
|
||||
[sharedWorktreeId]: 'browser'
|
||||
}
|
||||
})
|
||||
return {
|
||||
remoteGroupId: remoteGroup.id,
|
||||
@@ -282,20 +334,38 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
browserCount: 2,
|
||||
owners: [
|
||||
['browser-tab-local', 'local', seeded.sharedWorktreeId],
|
||||
[seeded.remoteTabId, remoteHostId, seeded.sharedWorktreeId],
|
||||
['simulator-local', 'local', seeded.sharedWorktreeId],
|
||||
['simulator-remote', remoteHostId, seeded.sharedWorktreeId]
|
||||
['simulator-remote', remoteHostId, seeded.sharedWorktreeId],
|
||||
[seeded.remoteTabId, remoteHostId, seeded.sharedWorktreeId]
|
||||
],
|
||||
workspaceOwners: [
|
||||
['browser-local', seeded.sharedWorktreeId],
|
||||
[seeded.remoteWorkspaceId, seeded.sharedWorktreeId]
|
||||
]
|
||||
})
|
||||
// Why: a live catalog refresh that reaps one same-id row re-hosts the surviving palette
|
||||
// entry, so assert the collision still exists at click time instead of blaming the palette.
|
||||
const expectSameIdCollisionIntact = async (step: string): Promise<void> => {
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(worktreeId) =>
|
||||
window
|
||||
.__store!.getState()
|
||||
.allWorktrees()
|
||||
.filter((worktree) => worktree.id === worktreeId)
|
||||
.map((worktree) => worktree.hostId)
|
||||
.sort(),
|
||||
seeded.sharedWorktreeId
|
||||
),
|
||||
`same-id host rows before ${step}`
|
||||
).toEqual(['local', remoteHostId].sort())
|
||||
}
|
||||
await page.evaluate(() => window.__store!.getState().openModal('worktree-palette'))
|
||||
let palette = page.getByRole('dialog', { name: 'Jump to...' })
|
||||
let input = palette.getByPlaceholder(
|
||||
'Search chats, terminals, worktrees, settings, and actions...'
|
||||
)
|
||||
await expectSameIdCollisionIntact('remote browser page palette open')
|
||||
const remoteBrowserAfterOpen = await page.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
const state = window.__store!.getState()
|
||||
@@ -304,10 +374,6 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
)
|
||||
return {
|
||||
browserCount: state.browserTabsByWorktree[worktreeId]?.length ?? 0,
|
||||
hosts: state
|
||||
.allWorktrees()
|
||||
.filter((worktree) => worktree.id === worktreeId)
|
||||
.map((worktree) => worktree.hostId),
|
||||
owner: tab?.executionHostId ?? null
|
||||
}
|
||||
},
|
||||
@@ -317,7 +383,6 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
}
|
||||
)
|
||||
expect(remoteBrowserAfterOpen.browserCount).toBe(2)
|
||||
expect(remoteBrowserAfterOpen.hosts).toEqual(['local', remoteHostId])
|
||||
expect(remoteBrowserAfterOpen.owner).toBe(remoteHostId)
|
||||
await input.fill('New Tab')
|
||||
await expect(
|
||||
@@ -328,6 +393,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
body: await page.screenshot(),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
await expectSameIdCollisionIntact('remote browser page click')
|
||||
await palette.locator(`[cmdk-item][data-value="browser-page:${seeded.remotePageId}"]`).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -357,6 +423,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
await expect(palette.locator('[cmdk-item][data-value="browser-page:page-local"]')).toHaveCount(
|
||||
1
|
||||
)
|
||||
await expectSameIdCollisionIntact('local browser page click')
|
||||
await palette.locator('[cmdk-item][data-value="browser-page:page-local"]').click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -384,6 +451,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
body: await page.screenshot(),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
await expectSameIdCollisionIntact('remote simulator click')
|
||||
await palette.locator('[cmdk-item][data-value="simulator-tab:simulator-remote"]').click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -415,6 +483,7 @@ test('routes same-id browser and simulator Cmd-J rows to their owning paired hos
|
||||
'[cmdk-item][data-value="simulator-tab:simulator-local"]'
|
||||
)
|
||||
await expect(localSimulatorRow).toHaveCount(1)
|
||||
await expectSameIdCollisionIntact('local simulator click')
|
||||
await localSimulatorRow.click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectGroup } from '../../src/shared/project-group-types'
|
||||
import type { Repo } from '../../src/shared/repo-types'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { revealPairedClientWindow } from './helpers/paired-client-window-reveal'
|
||||
import { forwardRendererConsole } from './helpers/renderer-console-forwarding'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedElectronClient
|
||||
@@ -21,11 +22,6 @@ import {
|
||||
} from './pr11346-selected-runtime-identity-oracle'
|
||||
|
||||
async function selectRuntimeHost(page: Page, runtimeName: string): Promise<Locator> {
|
||||
const crashDialog = page.getByRole('dialog', { name: /recoverable UI error/i })
|
||||
if (await crashDialog.isVisible()) {
|
||||
// Why: same-ID collision fixtures intentionally exceed terminal-workbench invariants.
|
||||
await crashDialog.getByRole('button', { name: /Don't Send/i }).click()
|
||||
}
|
||||
await page
|
||||
.getByRole('button', { name: /Add Project/i })
|
||||
.first()
|
||||
@@ -100,6 +96,9 @@ async function runSelectedRuntimeAddJourney(
|
||||
|
||||
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
||||
const client = await launchPairedElectronClient(offer, testInfo, runtimeName)
|
||||
// Why: the client renders the workbench under test, and a contained render
|
||||
// crash only names its component stack on the renderer console.
|
||||
forwardRendererConsole(client.page, testInfo)
|
||||
const serverUserDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const clientUserDataDir = await client.app.evaluate(({ app }) => app.getPath('userData'))
|
||||
const serverRuntime = new RuntimeClient(serverUserDataDir)
|
||||
|
||||
Reference in New Issue
Block a user