From fd2afc16c8f339a540ff64a5930ead5ab1d207ef Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Tue, 11 Aug 2026 23:25:50 -0700
Subject: [PATCH] Fix paired web Add Project folder browsing (#13885)
---
config/reliability-gates.jsonc | 33 +++++++++++++
.../src/components/sidebar/AddRepoDialog.tsx | 26 ++++++----
.../sidebar/AddRepoDialogStepContent.test.tsx | 12 +++++
.../sidebar/AddRepoDialogStepContent.tsx | 3 ++
.../sidebar/AddRepoHostSelector.tsx | 2 +-
.../sidebar/AddRepoStartSteps.test.tsx | 16 +++++++
.../components/sidebar/AddRepoStartSteps.tsx | 20 ++++----
.../sidebar/add-repo-browse-authority.test.ts | 43 +++++++++++++++++
.../sidebar/add-repo-browse-authority.ts | 24 ++++++++++
.../sidebar/use-add-repo-host-change-reset.ts | 2 +-
.../use-add-repo-host-selection.test.ts | 37 +++++++++++++-
.../sidebar/use-add-repo-host-selection.ts | 23 ++++++---
.../slices/repos-paired-runtime-add.test.ts | 45 +++++++++++++++++
.../multi-client-navigation-isolation.spec.ts | 48 +++++++++++++++++++
14 files changed, 304 insertions(+), 30 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts
create mode 100644 src/renderer/src/components/sidebar/add-repo-browse-authority.ts
create mode 100644 src/renderer/src/store/slices/repos-paired-runtime-add.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index f40d1571e0a..e905ab2546b 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -3233,6 +3233,7 @@
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
@@ -3246,11 +3247,14 @@
"src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
+ "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/renderer/src/web/web-preload-api.test.ts",
"src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts",
+ "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
+ "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
"src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts",
"src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
"src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts",
@@ -3262,6 +3266,7 @@
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/lib/worktree-runtime-owner.test.ts",
"src/renderer/src/store/selectors.test.ts",
+ "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts",
"src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts",
"src/renderer/src/store/slices/repos-project-groups.test.ts",
"src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
@@ -3271,6 +3276,34 @@
"tests/e2e/pr11346-selected-runtime-add.spec.ts"
],
"assertionRefs": [
+ {
+ "file": "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
+ "assertions": [
+ "paired web hides the viewing computer as an Add Project filesystem authority and selects the paired runtime"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts",
+ "assertions": [
+ "paired runtime browsing opens the host browser without invoking native pickFolders while desktop local and SSH routes remain intact"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx",
+ "assertions": ["paired runtime browsing renders the in-app host filesystem browser"]
+ },
+ {
+ "file": "src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx",
+ "assertions": [
+ "host-scoped Add Project actions remain disabled until a paired runtime is selectable"
+ ]
+ },
+ {
+ "file": "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts",
+ "assertions": [
+ "an explicitly selected paired runtime submits repo.add to that runtime without native repo or folder-picker calls"
+ ]
+ },
{
"file": "src/main/ipc/settings.test.ts",
"assertions": [
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx
index 54148a0d4a3..ade12730dee 100644
--- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx
@@ -18,6 +18,7 @@ import {
useAddRepoHostedController,
type AddRepoDialogHostedController
} from './use-add-repo-hosted-controller'
+import { routeAddRepoBrowse } from './add-repo-browse-authority'
export default React.memo(function AddRepoDialog({
hosted
@@ -308,7 +309,7 @@ export default React.memo(function AddRepoDialog({
selectedSshTargetId={hostSelection.selectedSshTargetId}
selectedHostLabel={
hostSelection.hostOptions.find((host) => host.id === hostSelection.selectedHostId)
- ?.label ?? hostSelection.selectedHostId
+ ?.label ?? null
}
lockSshTargetSelection={hostSelection.selectedParsedHost?.kind === 'ssh'}
remotePath={remotePath}
@@ -324,26 +325,31 @@ export default React.memo(function AddRepoDialog({
isCreating={isCreating}
hostSelector={}
showRemoteAction={false}
- browseHostKind={
- selectedHostKind === 'ssh' || selectedHostKind === 'runtime' ? selectedHostKind : 'local'
- }
+ actionsDisabled={!hostSelection.selectedHostId}
+ browseHostKind={selectedHostKind ?? 'runtime'}
createDefaultParent={createDefaultParent}
createGitAvailability={createGitAvailability}
createRuntimeParentStatus={createRuntimeParentStatus}
createParentDefaultPending={createParentDefaultPending}
manualCreateParentEntry={isRuntimeEnvironmentActive || selectedHostKind === 'ssh'}
- onBrowse={
- selectedHostKind === 'ssh'
- ? () => void handleOpenRemoteStep(hostSelection.selectedSshTargetId)
- : selectedHostKind === 'runtime'
- ? () => setStep('server-path')
- : handleBrowse
+ onBrowse={() =>
+ routeAddRepoBrowse(hostSelection.selectedParsedHost, {
+ browseLocal: () => void handleBrowse(),
+ browseRuntime: () => setStep('server-path'),
+ browseSsh: (targetId) => void handleOpenRemoteStep(targetId)
+ })
}
onOpenCloneStep={() => {
+ if (!hostSelection.selectedHostId) {
+ return
+ }
setCloneError(null)
setStep('clone')
}}
onOpenCreateStep={() => {
+ if (!hostSelection.selectedHostId) {
+ return
+ }
setCreateError(null)
setStep('create')
}}
diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
index b70c299be36..abdd169d4fd 100644
--- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx
@@ -281,4 +281,16 @@ describe('AddRepoDialogStepContent nested imports', () => {
expect(html).not.toContain('Create on host')
expect(html).not.toContain('Want to import many repos at once?')
})
+
+ it('opens the in-app filesystem browser for a paired runtime', () => {
+ const html = renderStepContent({
+ step: 'server-path',
+ isRuntimeEnvironmentActive: true,
+ activeRuntimeEnvironmentId: 'paired-host'
+ })
+
+ expect(html).toContain('Browse host filesystem')
+ expect(html).toContain('Navigate to a directory and click Select to choose it.')
+ expect(html).toContain('Select folder')
+ })
})
diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx
index ee05b70b433..9a0da649f8c 100644
--- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx
@@ -46,6 +46,7 @@ type AddRepoDialogStepContentProps = {
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
+ actionsDisabled?: boolean
manualCreateParentEntry?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
createDefaultParent: string
@@ -115,6 +116,7 @@ export function AddRepoDialogStepContent({
hostSelector,
showRemoteAction = true,
canCreateProject = true,
+ actionsDisabled = false,
manualCreateParentEntry = isRuntimeEnvironmentActive,
browseHostKind = 'local',
createDefaultParent,
@@ -159,6 +161,7 @@ export function AddRepoDialogStepContent({
hostSelector={hostSelector}
showRemoteAction={showRemoteAction}
canCreateProject={canCreateProject}
+ actionsDisabled={actionsDisabled}
browseHostKind={browseHostKind}
onBrowse={onBrowse}
onOpenCloneStep={onOpenCloneStep}
diff --git a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
index 0d73eca96cd..b9ab6c89068 100644
--- a/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoHostSelector.tsx
@@ -13,7 +13,7 @@ import { canConnectAddRepoHost, canSelectAddRepoHost } from './add-repo-host-ava
type AddRepoHostSelectorProps = {
hosts: SidebarHostOption[]
- selectedHostId: ExecutionHostId
+ selectedHostId: ExecutionHostId | null
open: boolean
onOpenChange: (open: boolean) => void
onSelectHost: (hostId: ExecutionHostId) => void
diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx
index 40fc712810a..6fa3b88aceb 100644
--- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx
@@ -53,6 +53,7 @@ function renderServerPathStartStep(runtimeEnvironmentId: string | null): string
type LocalStartStepDomOptions = {
isAdding?: boolean
+ actionsDisabled?: boolean
addProjectBusyLabel?: string | null
nestedScanInProgress?: boolean
nestedScanId?: string | null
@@ -76,6 +77,7 @@ async function renderLocalStartStepDom(
repoCount={1}
isSshLikely={isSshLikely}
isAdding={options.isAdding ?? false}
+ actionsDisabled={options.actionsDisabled ?? false}
addProjectBusyLabel={options.addProjectBusyLabel ?? null}
nestedScanInProgress={options.nestedScanInProgress ?? false}
nestedScanId={options.nestedScanId ?? null}
@@ -257,6 +259,20 @@ describe('AddRepoLocalStartStep', () => {
})
})
+ it('disables host-scoped actions until a host is selectable', async () => {
+ const { container, root } = await renderLocalStartStepDom(false, { actionsDisabled: true })
+
+ expect(findButton(container, 'Browse folder').disabled).toBe(true)
+ expect(findButton(container, 'Clone from URL').disabled).toBe(true)
+ expect(findButton(container, 'Project on SSH host').disabled).toBe(true)
+ expect(findButton(container, 'Create new project').disabled).toBe(true)
+ expect(findButton(container, 'Browse folder').textContent).not.toContain('⏎')
+
+ await act(async () => {
+ root.unmount()
+ })
+ })
+
it('marks the autofocused Browse action as selected with the ⏎ chip', async () => {
const { container, root } = await renderLocalStartStepDom(false)
diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx
index 71f8485842b..e5e4b409abc 100644
--- a/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.tsx
@@ -69,6 +69,7 @@ type AddRepoLocalStartStepProps = {
hostSelector?: ReactNode
showRemoteAction?: boolean
canCreateProject?: boolean
+ actionsDisabled?: boolean
browseHostKind?: 'local' | 'ssh' | 'runtime'
onBrowse: () => void
onOpenCloneStep: () => void
@@ -87,6 +88,7 @@ export function AddRepoLocalStartStep({
hostSelector,
showRemoteAction = true,
canCreateProject = true,
+ actionsDisabled = false,
browseHostKind = 'local',
onBrowse,
onOpenCloneStep,
@@ -96,6 +98,7 @@ export function AddRepoLocalStartStep({
}: AddRepoLocalStartStepProps): React.JSX.Element {
const browseActionRef = useRef(null)
const actionsRef = useRef(null)
+ const actionsUnavailable = isAdding || actionsDisabled
const { primaryAction, secondaryActions } = getAddRepoLocalStartActions({
isSshLikely,
onBrowse,
@@ -111,16 +114,13 @@ export function AddRepoLocalStartStep({
// it follows keyboard focus so Enter always activates the highlighted action. Browse is
// autofocused on open, so it starts selected; Tab and ↑/↓ move the highlight.
const [selectedKind, setSelectedKind] = useState(primaryAction.kind)
+ const visibleSelectedKind = actionsUnavailable ? null : selectedKind
useEffect(() => {
- if (isAdding) {
- setSelectedKind(null)
- return
- }
- if (!isAdding) {
+ if (!actionsUnavailable) {
browseActionRef.current?.focus()
}
- }, [isAdding])
+ }, [actionsUnavailable])
// ↑/↓ rove focus across the action buttons in visual order; focus drives the selection.
const handleArrowNavigation = (event: React.KeyboardEvent): void => {
@@ -177,8 +177,8 @@ export function AddRepoLocalStartStep({
icon={primaryAction.icon}
title={primaryAction.title}
description={primaryAction.description}
- disabled={isAdding}
- selected={selectedKind === primaryAction.kind}
+ disabled={actionsUnavailable}
+ selected={visibleSelectedKind === primaryAction.kind}
buttonRef={browseActionRef}
onClick={primaryAction.onClick}
onFocus={() => setSelectedKind(primaryAction.kind)}
@@ -199,8 +199,8 @@ export function AddRepoLocalStartStep({
icon={action.icon}
title={action.title}
description={action.description}
- disabled={isAdding || Boolean(action.disabled)}
- selected={selectedKind === action.kind}
+ disabled={actionsUnavailable || Boolean(action.disabled)}
+ selected={visibleSelectedKind === action.kind}
onClick={action.onClick}
onFocus={() => setSelectedKind(action.kind)}
className={cn(
diff --git a/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts b/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts
new file mode 100644
index 00000000000..35ce05a2244
--- /dev/null
+++ b/src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from 'vitest'
+import { parseExecutionHostId } from '../../../../shared/execution-host'
+import { routeAddRepoBrowse } from './add-repo-browse-authority'
+
+describe('routeAddRepoBrowse', () => {
+ it('opens paired host browsing without invoking native pickFolders', () => {
+ const pickFolders = vi.fn()
+ const browseRuntime = vi.fn()
+
+ routeAddRepoBrowse(parseExecutionHostId('runtime:paired-host'), {
+ browseLocal: pickFolders,
+ browseRuntime,
+ browseSsh: vi.fn()
+ })
+
+ expect(browseRuntime).toHaveBeenCalledOnce()
+ expect(pickFolders).not.toHaveBeenCalled()
+ })
+
+ it('preserves native folder browsing for desktop local hosts', () => {
+ const pickFolders = vi.fn()
+
+ routeAddRepoBrowse(parseExecutionHostId('local'), {
+ browseLocal: pickFolders,
+ browseRuntime: vi.fn(),
+ browseSsh: vi.fn()
+ })
+
+ expect(pickFolders).toHaveBeenCalledOnce()
+ })
+
+ it('preserves SSH host browsing', () => {
+ const browseSsh = vi.fn()
+
+ routeAddRepoBrowse(parseExecutionHostId('ssh:builder'), {
+ browseLocal: vi.fn(),
+ browseRuntime: vi.fn(),
+ browseSsh
+ })
+
+ expect(browseSsh).toHaveBeenCalledWith('builder')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/add-repo-browse-authority.ts b/src/renderer/src/components/sidebar/add-repo-browse-authority.ts
new file mode 100644
index 00000000000..364e21201e4
--- /dev/null
+++ b/src/renderer/src/components/sidebar/add-repo-browse-authority.ts
@@ -0,0 +1,24 @@
+import type { ParsedExecutionHost } from '../../../../shared/execution-host'
+
+export type AddRepoBrowseAuthorityActions = {
+ browseLocal: () => void
+ browseRuntime: () => void
+ browseSsh: (targetId: string) => void
+}
+
+export function routeAddRepoBrowse(
+ host: ParsedExecutionHost | null,
+ actions: AddRepoBrowseAuthorityActions
+): void {
+ if (host?.kind === 'runtime') {
+ actions.browseRuntime()
+ return
+ }
+ if (host?.kind === 'ssh') {
+ actions.browseSsh(host.targetId)
+ return
+ }
+ if (host?.kind === 'local') {
+ actions.browseLocal()
+ }
+}
diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts
index effec4b5192..52a77a3b581 100644
--- a/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts
+++ b/src/renderer/src/components/sidebar/use-add-repo-host-change-reset.ts
@@ -7,7 +7,7 @@ export function useAddRepoHostChangeReset({
onResetHostScopedState
}: {
isOpen: boolean
- selectedHostId: string
+ selectedHostId: string | null
onResetClosed: () => void
onResetHostScopedState: () => void
}) {
diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
index b9a77b2e9bd..45a4fb9abbc 100644
--- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
+++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts
@@ -17,7 +17,8 @@ const mocks = vi.hoisted(() => ({
runtimeEnvironments: [] as { id: string; name: string; source?: 'manual' | 'ephemeral-vm' }[]
},
sshConnect: vi.fn(),
- sshGetState: vi.fn()
+ sshGetState: vi.fn(),
+ isWebClient: false
}))
vi.mock('react', async (importOriginal) => {
@@ -64,6 +65,10 @@ vi.mock('./use-sidebar-host-scope-options', () => ({
useSidebarHostScopeOptions: () => ({ hostOptions: mocks.hostOptions })
}))
+vi.mock('@/lib/web-client-location', () => ({
+ isWebClientLocation: () => mocks.isWebClient
+}))
+
describe('useAddRepoHostSelection', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -103,6 +108,7 @@ describe('useAddRepoHostSelection', () => {
mocks.storeState.runtimeEnvironments = []
mocks.sshConnect.mockReset()
mocks.sshGetState.mockReset()
+ mocks.isWebClient = false
vi.stubGlobal('window', {
api: {
ssh: {
@@ -137,6 +143,35 @@ describe('useAddRepoHostSelection', () => {
expect(setStep).toHaveBeenCalledWith('add')
})
+ it('uses the paired runtime as the only local filesystem authority in web clients', async () => {
+ mocks.isWebClient = true
+ mocks.stateValues = ['local', false]
+ const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
+
+ const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
+
+ expect(result.hostOptions.map((host) => host.id)).toEqual(['ssh:ssh-1', 'runtime:env-1'])
+ expect(result.selectedHostId).toBe('runtime:env-1')
+ expect(result.selectedParsedHost).toMatchObject({
+ kind: 'runtime',
+ environmentId: 'env-1'
+ })
+ })
+
+ it('has no local fallback while a paired web runtime is loading', async () => {
+ mocks.isWebClient = true
+ mocks.stateValues = ['local', false]
+ mocks.hostOptions = [mocks.hostOptions[0]]
+ const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
+
+ const result = useAddRepoHostSelection({ isOpen: true, setStep: vi.fn() })
+
+ expect(result.hostOptions).toEqual([])
+ expect(result.selectedHostId).toBeNull()
+ expect(result.selectedParsedHost).toBeNull()
+ expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('local')
+ })
+
it('selects a local or SSH host without changing the durable active server', async () => {
mocks.stateValues = ['runtime:env-1', false]
mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
diff --git a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
index 684fa75d78b..29f1fadc761 100644
--- a/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
+++ b/src/renderer/src/components/sidebar/use-add-repo-host-selection.ts
@@ -13,6 +13,7 @@ import type { AddRepoDialogStep } from './add-repo-dialog-types'
import { useSidebarHostScopeOptions } from './use-sidebar-host-scope-options'
import { canSelectAddRepoHost } from './add-repo-host-availability'
import { translate } from '@/i18n/i18n'
+import { isWebClientLocation } from '@/lib/web-client-location'
export function useAddRepoHostSelection({
isOpen,
@@ -22,7 +23,7 @@ export function useAddRepoHostSelection({
setStep: (step: AddRepoDialogStep) => void
}): {
hostOptions: ReturnType['hostOptions']
- selectedHostId: ExecutionHostId
+ selectedHostId: ExecutionHostId | null
selectedParsedHost: ReturnType
selectedSshTargetId: string | null
hostSelectorOpen: boolean
@@ -35,6 +36,7 @@ export function useAddRepoHostSelection({
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const { hostOptions } = useSidebarHostScopeOptions()
+ const isWebClient = isWebClientLocation()
const ephemeralRuntimeEnvironmentIds = useMemo(
() =>
new Set(
@@ -49,26 +51,31 @@ export function useAddRepoHostSelection({
hostOptions.filter((host) => {
const parsed = parseExecutionHostId(host.id)
return (
- parsed?.kind !== 'runtime' || !ephemeralRuntimeEnvironmentIds.has(parsed.environmentId)
+ !(isWebClient && parsed?.kind === 'local') &&
+ (parsed?.kind !== 'runtime' || !ephemeralRuntimeEnvironmentIds.has(parsed.environmentId))
)
}),
- [ephemeralRuntimeEnvironmentIds, hostOptions]
+ [ephemeralRuntimeEnvironmentIds, hostOptions, isWebClient]
)
const [selectedAddProjectHostId, setSelectedAddProjectHostId] =
useState(LOCAL_EXECUTION_HOST_ID)
const [hostSelectorOpen, setHostSelectorOpen] = useState(false)
const previousOpenRef = useRef(false)
+ const pairedWebRuntimeHost = isWebClient
+ ? selectableHostOptions.find((host) => host.kind === 'runtime' && canSelectAddRepoHost(host))
+ : undefined
const selectedHost =
selectableHostOptions.find(
(host) => host.id === selectedAddProjectHostId && canSelectAddRepoHost(host)
) ??
+ pairedWebRuntimeHost ??
selectableHostOptions.find(
(host) => host.id === LOCAL_EXECUTION_HOST_ID && canSelectAddRepoHost(host)
) ??
selectableHostOptions.find((host) => canSelectAddRepoHost(host)) ??
selectableHostOptions[0]
- const selectedHostId = selectedHost?.id ?? LOCAL_EXECUTION_HOST_ID
+ const selectedHostId = selectedHost?.id ?? (isWebClient ? null : LOCAL_EXECUTION_HOST_ID)
const selectedParsedHost = parseExecutionHostId(selectedHostId)
const selectedSshTargetId =
selectedParsedHost?.kind === 'ssh' ? selectedParsedHost.targetId : null
@@ -80,14 +87,16 @@ export function useAddRepoHostSelection({
(host) => host.id === focusedHostId && canSelectAddRepoHost(host)
)
? focusedHostId
- : LOCAL_EXECUTION_HOST_ID
- setSelectedAddProjectHostId(nextHostId)
+ : (pairedWebRuntimeHost?.id ?? (isWebClient ? null : LOCAL_EXECUTION_HOST_ID))
+ if (nextHostId) {
+ setSelectedAddProjectHostId(nextHostId)
+ }
}
if (!isOpen) {
setHostSelectorOpen(false)
}
previousOpenRef.current = isOpen
- }, [isOpen, selectableHostOptions, settings])
+ }, [isOpen, isWebClient, pairedWebRuntimeHost?.id, selectableHostOptions, settings])
const handleSelectAddProjectHost = useCallback(
async (hostId: ExecutionHostId): Promise => {
diff --git a/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts b/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts
new file mode 100644
index 00000000000..4e0f4cae622
--- /dev/null
+++ b/src/renderer/src/store/slices/repos-paired-runtime-add.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createTestStore } from './store-test-helpers'
+import {
+ installReposRuntimeRoutingHarness,
+ remoteRepo,
+ reposAdd,
+ reposPickFolder,
+ runtimeEnvironmentCall
+} from './repos-runtime-routing-fixture'
+
+vi.mock('sonner', () => ({
+ toast: {
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn()
+ }
+}))
+
+installReposRuntimeRoutingHarness()
+
+describe('paired runtime repo add routing', () => {
+ it('submits the selected host path to repo.add without native filesystem calls', async () => {
+ runtimeEnvironmentCall.mockResolvedValue({
+ id: 'rpc-paired-add',
+ ok: true,
+ result: { repo: remoteRepo },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ const store = createTestStore()
+
+ await store.getState().addRepoPath('/srv/paired-project', 'folder', {
+ runtimeEnvironmentId: 'paired-host'
+ })
+
+ expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'paired-host',
+ method: 'repo.add',
+ params: { path: '/srv/paired-project', kind: 'folder' },
+ timeoutMs: 15_000
+ })
+ expect(reposAdd).not.toHaveBeenCalled()
+ expect(reposPickFolder).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/e2e/multi-client-navigation-isolation.spec.ts b/tests/e2e/multi-client-navigation-isolation.spec.ts
index 399ae5d963f..d0576f00c8c 100644
--- a/tests/e2e/multi-client-navigation-isolation.spec.ts
+++ b/tests/e2e/multi-client-navigation-isolation.spec.ts
@@ -1,5 +1,7 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
+import { mkdtempSync, rmSync } from 'node:fs'
+import os from 'node:os'
import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@@ -176,3 +178,49 @@ test('keeps two paired browser clients and the host on independent worktrees', a
await clientA?.close()
}
})
+
+test('routes Add Project folder browsing through the paired host', async ({
+ electronApp,
+ orcaPage,
+ registerPostElectronShutdownCleanup
+}) => {
+ const hostFolder = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-web-folder-'))
+ const folderName = path.basename(hostFolder)
+ registerPostElectronShutdownCleanup(async () => {
+ rmSync(hostFolder, { recursive: true, force: true })
+ })
+ const visibleWorktreeId = await orcaPage.evaluate(
+ () => window.__store?.getState().activeWorktreeId
+ )
+ if (!visibleWorktreeId) {
+ throw new Error('Host worktree was not active before paired web validation')
+ }
+
+ const offer = await createPairingOffer(orcaPage)
+ const client = await openPairedClient(electronApp, offer, visibleWorktreeId)
+ try {
+ await client
+ .getByRole('button', { name: /Add Project/i })
+ .first()
+ .click()
+ const addDialog = client.getByRole('dialog', { name: /Add a project/i })
+ await expect(addDialog).toBeVisible()
+ await expect(addDialog).not.toContainText('Local Mac')
+
+ await addDialog.getByRole('button', { name: /Browse folder/i }).click()
+ const browser = client.getByRole('dialog', { name: /Browse host filesystem/i })
+ await expect(browser).toBeVisible()
+ await expect(browser.getByRole('button', { name: /Select folder/i })).toBeVisible()
+ await browser.getByRole('button', { name: /^Cancel$/i }).click()
+
+ const manualPathDialog = client.getByRole('dialog', { name: /Open host project/i })
+ await manualPathDialog.locator('#server-project-path').fill(hostFolder)
+ await manualPathDialog.getByRole('button', { name: /Open as Folder/i }).click()
+ await expect(manualPathDialog).toBeHidden({ timeout: 30_000 })
+ await expect(
+ client.locator('[data-worktree-sidebar]').getByText(folderName, { exact: true }).first()
+ ).toBeVisible({ timeout: 30_000 })
+ } finally {
+ await client.close()
+ }
+})