From de009e0189e26ee5dd8833be03b08d5271841e64 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:21:51 -0700 Subject: [PATCH] [P1] fix(routing): keep the paired-HUB owner for the active workspace selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #11346 made `activeWorkspaceExecutionHostId` authoritative for the active workspace, but the short-circuits that read it never see the owner record, so a project reached through a paired runtime HUB (`hostId: ssh:` plus `runtimeOwnerEnvironmentId: `) loses its transport and its repo: - `resolveWorktreeOperationRouteResult` returned `runtimeEnvironmentId: null` for any non-`runtime:` selection, bypassing the HUB recovery in `resolveExactWorktreeRoute` — every owner-routed terminal/git/filesystem operation on the active workspace lost the HUB that proxies the SSH target. - `selectRepoByIdForActiveWorkspace` filtered `state.repos` by the selected host with no fallback, but `withRepoHostOwnership` deliberately keeps the SSH host on the worktree while the repo row stays HUB-owned — so the active repo read as `null` and `useGitStatusPolling` stopped polling the whole workspace. Route resolution now recovers the HUB owner from the owner rows on the selected host, and drops it only when rival HUBs project the same host. The repo selector opens exactly one hole in #11346's fail-closed rule — an `ssh:` selection over a `runtime:` repo row — and every other host mismatch still returns `null`. `worktree-owner-route.ts` is a pure extraction of the existing owner-route helpers; no behavior moved with it. --- .../src/lib/worktree-operation-route.test.ts | 92 +++++++++++ .../src/lib/worktree-operation-route.ts | 147 ++++++++---------- src/renderer/src/lib/worktree-owner-route.ts | 89 +++++++++++ .../src/lib/worktree-runtime-owner.test.ts | 31 ++++ .../src/lib/worktree-runtime-owner.ts | 13 +- src/renderer/src/store/selectors.test.ts | 97 +++++++++++- src/renderer/src/store/selectors.ts | 26 +++- 7 files changed, 394 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/lib/worktree-owner-route.ts diff --git a/src/renderer/src/lib/worktree-operation-route.test.ts b/src/renderer/src/lib/worktree-operation-route.test.ts index fe965d9aad0..8f2ef74d3b5 100644 --- a/src/renderer/src/lib/worktree-operation-route.test.ts +++ b/src/renderer/src/lib/worktree-operation-route.test.ts @@ -241,6 +241,98 @@ describe('resolveWorktreeOperationRouteResult', () => { }) }) + describe('active workspace host selection', () => { + it('keeps the HUB transport when the paired SSH worktree is the active workspace', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('recovers the HUB transport from the repo for an active mixed-version SSH publication', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + repos: [ + { + id: 'repo-1', + connectionId: 'hub-private-target', + executionHostId: 'runtime:hub-a' + } + ], + detectedWorktreesByRepo: { + 'repo-1': { worktrees: [worktree('ssh:hub-private-target')] } + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('keeps the selected host authoritative when the same ID exists on two hosts', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:hub-a', + worktreesByRepo: { 'repo-1': [worktree('local'), worktree('runtime:hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('drops the HUB transport when two HUBs project the active SSH worktree', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:same-private-target', + worktreesByRepo: { + 'repo-1': [ + worktree('ssh:same-private-target', 'hub-a'), + worktree('ssh:same-private-target', 'hub-b') + ] + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:same-private-target', runtimeEnvironmentId: null } + }) + }) + + it('keeps an unknown active worktree on its selected host', () => { + expect( + resolveWorktreeOperationRouteResult( + { activeWorktreeId: WORKTREE_ID, activeWorkspaceExecutionHostId: 'ssh:ssh-1' }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + }) + }) + }) + describe('folder workspaces', () => { const FOLDER_WORKSPACE_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const FOLDER_KEY = `folder:${FOLDER_WORKSPACE_ID}` diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index c2281b28ac0..fadd7284172 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -2,11 +2,11 @@ import type { AppState } from '@/store/types' import { getRepoExecutionHostId, parseExecutionHostId, - toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { findFolderWorkspaceOwner, @@ -24,7 +24,7 @@ export type WorktreeOperationRouteResolution = | { kind: 'ambiguous' } | { kind: 'missing' } -type WorktreeOperationOwnerRecord = { +export type WorktreeOperationOwnerRecord = { id: string repoId: string hostId?: ExecutionHostId @@ -32,7 +32,7 @@ type WorktreeOperationOwnerRecord = { } // settings/runtimeEnvironments come from FolderWorkspaceRuntimeOwnerState's legacy-owner base. -type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { +export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { repos?: readonly Pick[] worktreesByRepo?: Record detectedWorktreesByRepo?: Record @@ -45,83 +45,67 @@ const repoOperationRouteIndexCache = new WeakMap< ReadonlyMap >() -function routeForOwner(owner: { - hostId?: ExecutionHostId - runtimeOwnerEnvironmentId?: string -}): WorktreeOperationRoute | null { - const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() - if (!owner.hostId && !runtimeOwnerEnvironmentId) { +function ownerRecordsOnHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationOwnerRecord[] { + const owners: WorktreeOperationOwnerRecord[] = [] + const catalogs = [ + ...Object.values(state.worktreesByRepo ?? {}), + ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) + ] + for (const worktrees of catalogs) { + for (const worktree of worktrees) { + if ( + worktree.id === worktreeId && + parseExecutionHostId(worktree.hostId)?.id === executionHostId + ) { + owners.push(worktree) + } + } + } + return owners +} + +/** + * The active workspace's host selection is authoritative identity, but it carries no transport: + * an `ssh:` host reached through a paired HUB names the target, not the HUB that proxies it. Keep + * the selected host and recover the runtime owner from the matching owner rows (#11346). + */ +export function resolveActiveWorkspaceRoute( + state: WorktreeOperationRouteState, + worktreeId: string +): WorktreeOperationRoute | null { + const activeHost = + state.activeWorktreeId === worktreeId + ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) + : null + if (!activeHost) { return null } - const parsedHost = parseExecutionHostId(owner.hostId) + if (activeHost.kind === 'runtime') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId } + } + // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. + if (activeHost.kind !== 'ssh') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: null } + } + const environmentIds = new Set() + for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) { + const resolution = resolveExactWorktreeRoute(state, owner) + if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { + environmentIds.add(resolution.route.runtimeEnvironmentId) + } + } + const environmentId = environmentIds.values().next().value return { - executionHostId: owner.hostId ?? null, - runtimeEnvironmentId: - runtimeOwnerEnvironmentId || - (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + executionHostId: activeHost.id, + // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. + runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null } } -function addRoute( - routes: Map, - route: WorktreeOperationRoute | null -): void { - if (!route) { - return - } - routes.set(JSON.stringify(route), route) -} - -function resolveRepoRouteForSshOwner( - repos: WorktreeOperationRouteState['repos'], - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - if (!repos || !owner.hostId) { - return { kind: 'missing' } - } - const routes = new Map() - for (const repo of repos) { - if (repo.id !== owner.repoId) { - continue - } - const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null - if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { - continue - } - addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) - } - const route = routes.values().next().value - if (routes.size === 1 && route) { - return { kind: 'resolved', route } - } - return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } -} - -function resolveExactWorktreeRoute( - state: WorktreeOperationRouteState, - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - const route = routeForOwner(owner) - if (!route) { - return { kind: 'missing' } - } - if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { - return { kind: 'resolved', route } - } - // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. - const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) - if (repoRoute.kind === 'ambiguous') { - return repoRoute - } - if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { - return { - kind: 'resolved', - route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } - } - } - return { kind: 'resolved', route } -} - export function resolveWorktreeOperationRoute( state: WorktreeOperationRouteState, worktreeId: string @@ -134,18 +118,9 @@ export function resolveWorktreeOperationRouteResult( state: WorktreeOperationRouteState, worktreeId: string ): WorktreeOperationRouteResolution { - const activeHost = - state.activeWorktreeId === worktreeId - ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) - : null - if (activeHost) { - return { - kind: 'resolved', - route: { - executionHostId: activeHost.id, - runtimeEnvironmentId: activeHost.kind === 'runtime' ? activeHost.environmentId : null - } - } + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return { kind: 'resolved', route: activeRoute } } // Why: folder workspaces are not Git worktrees — they never appear in the worktree/repo // catalogs scanned below, so without this branch a plain local folder workspace reads as an diff --git a/src/renderer/src/lib/worktree-owner-route.ts b/src/renderer/src/lib/worktree-owner-route.ts new file mode 100644 index 00000000000..a370eca651c --- /dev/null +++ b/src/renderer/src/lib/worktree-owner-route.ts @@ -0,0 +1,89 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { + WorktreeOperationOwnerRecord, + WorktreeOperationRoute, + WorktreeOperationRouteResolution, + WorktreeOperationRouteState +} from './worktree-operation-route' + +export function routeForOwner(owner: { + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +}): WorktreeOperationRoute | null { + const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() + if (!owner.hostId && !runtimeOwnerEnvironmentId) { + return null + } + const parsedHost = parseExecutionHostId(owner.hostId) + return { + executionHostId: owner.hostId ?? null, + runtimeEnvironmentId: + runtimeOwnerEnvironmentId || + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + } +} + +export function addRoute( + routes: Map, + route: WorktreeOperationRoute | null +): void { + if (!route) { + return + } + routes.set(JSON.stringify(route), route) +} + +function resolveRepoRouteForSshOwner( + repos: WorktreeOperationRouteState['repos'], + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + if (!repos || !owner.hostId) { + return { kind: 'missing' } + } + const routes = new Map() + for (const repo of repos) { + if (repo.id !== owner.repoId) { + continue + } + const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null + if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { + continue + } + addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) + } + const route = routes.values().next().value + if (routes.size === 1 && route) { + return { kind: 'resolved', route } + } + return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } +} + +export function resolveExactWorktreeRoute( + state: WorktreeOperationRouteState, + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + const route = routeForOwner(owner) + if (!route) { + return { kind: 'missing' } + } + if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { + return { kind: 'resolved', route } + } + // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. + const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) + if (repoRoute.kind === 'ambiguous') { + return repoRoute + } + if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { + return { + kind: 'resolved', + route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } + } + } + return { kind: 'resolved', route } +} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 410c3b3bebf..995d7c15715 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -579,3 +579,34 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => { expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) }) }) + +describe('active workspace host selection', () => { + const PAIRED_HUB_WORKTREE_ID = 'hub-repo::wt-paired' + const pairedHubState: WorktreeRuntimeOwnerState = { + activeWorktreeId: PAIRED_HUB_WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { + 'hub-repo': [ + { + id: PAIRED_HUB_WORKTREE_ID, + repoId: 'hub-repo', + hostId: 'ssh:hub-private-target', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + } + + it('keeps the HUB transport for the active paired SSH worktree', () => { + expect(getRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a') + expect(getExplicitRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'hub-a' + ) + }) + + it('keeps the selected host authoritative for the active worktree', () => { + expect(getExecutionHostIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'ssh:hub-private-target' + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 0ad6b696c07..e322e834d43 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentIdForFolderWorkspace } from './folder-workspace-runtime-owner' import { + resolveActiveWorkspaceRoute, resolveExplicitWorktreeOperationRouteResult, resolveWorktreeOperationRouteResult } from './worktree-operation-route' @@ -62,9 +63,9 @@ export function getRuntimeEnvironmentIdForWorktree( if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { @@ -113,9 +114,9 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( if (!worktreeId) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index ca9248a3ab5..ba77ae902d0 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { isGitRepoKind } from '../../../shared/repo-kind' import type { AppState } from './types' import { getAllWorktreesFromState, @@ -288,6 +289,100 @@ describe('store selectors', () => { ).toBeNull() }) + it('keeps the repo when a paired-hub worktree reports a different execution host', () => { + // Why: withRepoHostOwnership deliberately keeps an SSH worktree's own host while its repo stays hub-owned. + const repo = makeRepo({ + id: 'hub-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [repo], + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'hub-repo' + ) + ).toBe(repo) + // Why: useGitStatusPolling gates every lane on this exact expression, so a null repo silently stops polling. + const activeRepo = selectRepoByIdForActiveWorkspace( + { + repos: [repo], + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'hub-repo' + ) + expect(activeRepo ? isGitRepoKind(activeRepo) : false).toBe(true) + }) + + it('still prefers the host-matching row when one repo ID spans hosts', () => { + const hub = makeRepo({ + id: 'shared-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + const ssh = makeRepo({ + id: 'shared-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('hub-private-target') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [hub, ssh], + activeRepoId: 'shared-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'shared-repo' + ) + ).toBe(ssh) + }) + + it('keeps every non-paired-hub host mismatch failing closed', () => { + // Why: the paired-hub exemption is ssh-selection-over-runtime-repo only; anything else is a wrong host. + const runtime = makeRepo({ + id: 'same-repo', + path: '/runtime/repo', + displayName: 'runtime', + executionHostId: toRuntimeExecutionHostId('env-1') + }) + const ssh = makeRepo({ + id: 'ssh-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('target-1') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [runtime], + activeRepoId: 'same-repo', + activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('env-2') + }, + 'same-repo' + ) + ).toBeNull() + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [ssh], + activeRepoId: 'ssh-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('target-2') + }, + 'ssh-repo' + ) + ).toBeNull() + }) + it('prefers hydrated project host setup state when present', () => { const repos = [ makeRepo({ diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 1904074f487..fec5a2481ae 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import type { Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' import { getIndexedAllWorktrees as getCachedAllWorktrees, @@ -181,16 +181,26 @@ export function selectRepoByIdForActiveWorkspace( if (!repoId) { return null } + const repo = getCachedRepoMap(state.repos).get(repoId) ?? null if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) { - return ( - state.repos.find( - (repo) => - repo.id === repoId && - getRepoExecutionHostId(repo) === state.activeWorkspaceExecutionHostId - ) ?? null + const hostMatch = state.repos.find( + (candidate) => + candidate.id === repoId && + getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId ) + if (hostMatch) { + return hostMatch + } + // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo + // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. + const isPairedHubRepo = + parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind === 'ssh' && + parseExecutionHostId(repo ? getRepoExecutionHostId(repo) : undefined)?.kind === 'runtime' + if (!isPairedHubRepo) { + return null + } } - return getCachedRepoMap(state.repos).get(repoId) ?? null + return repo } export const useRepoById = (repoId: string | null) =>