Fix remote project setup UX gaps

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 00:34:38 -07:00
co-authored by Orca
parent 77671177af
commit 720a1d4ea5
10 changed files with 73 additions and 28 deletions
+2
View File
@@ -54,6 +54,7 @@ const {
readDir: vi.fn().mockResolvedValue([]),
readFile: vi.fn().mockRejectedValue(new Error('not found')),
stat: vi.fn().mockRejectedValue(new Error('not found')),
createDir: vi.fn().mockResolvedValue(undefined),
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
deletePath: vi.fn().mockResolvedValue(undefined)
},
@@ -913,6 +914,7 @@ describe('repos:addRemote', () => {
destination: '/home/user'
})
expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user')
expect(mockGitProvider.clone).toHaveBeenCalledWith(
['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/user',
+7
View File
@@ -329,6 +329,10 @@ async function cloneRemoteRepo(
if (!gitProvider) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const fsProvider = getSshFilesystemProvider(args.connectionId)
if (!fsProvider) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const host = gitProvider.getHostPlatform?.()
if (!host) {
throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.')
@@ -367,6 +371,9 @@ async function cloneRemoteRepo(
activeRemoteClone = metadata
remoteCloneInFlightByPath.add(remoteCloneKey)
try {
// Why: local clone creates the typed parent before spawning git. SSH clone
// must match that behavior or a fresh remote parent surfaces as spawn ENOENT.
await fsProvider.createDir(trimmedDestination)
// Why: the SSH relay exposes argv-based git execution, not a shell. Use
// the repo folder name as the target so git creates it inside the chosen
// parent, and keep the same flag separator safety as local clone.
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
stateIndex: 0,
storeState: {
repos: [] as Repo[],
projects: [],
projectHostSetups: [],
clearOrcaHookTrustForRepo: vi.fn(),
openModal: vi.fn(),
cancelNestedRepoScan: vi.fn()
@@ -92,6 +94,8 @@ describe('useRemoteRepo default-checkout handoff', () => {
mocks.stateSetters = []
mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false, null]
mocks.storeState.repos = []
mocks.storeState.projects = []
mocks.storeState.projectHostSetups = []
mocks.listTargets.mockResolvedValue([
{ id: 'ssh-1', label: 'Builder 1' },
{ id: 'ssh-2', label: 'Builder 2' }
@@ -133,6 +137,12 @@ describe('useRemoteRepo default-checkout handoff', () => {
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true
})
expect(mocks.storeState.projects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })])
)
expect(mocks.storeState.projectHostSetups).toEqual(
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
)
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
})
@@ -7,6 +7,7 @@ import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types
import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
// ── Remote project hook ─────────────────────────────────────────────
@@ -188,13 +189,7 @@ export function useRemoteRepo(
if (existingIdx !== -1) {
state.clearOrcaHookTrustForRepo(repo.id)
}
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
upsertAddedRepoWithProjectHostSetup(repo)
if (!mountedRef.current || gen !== remoteGenRef.current) {
return
@@ -0,0 +1,19 @@
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import type { Repo } from '../../../../shared/types'
import { useAppStore } from '@/store'
export function upsertAddedRepoWithProjectHostSetup(repo: Repo): void {
const state = useAppStore.getState()
const repos = state.repos.some((entry) => entry.id === repo.id)
? state.repos.map((entry) => (entry.id === repo.id ? repo : entry))
: [...state.repos, repo]
const projection = projectHostSetupProjectionFromRepos(repos)
// Why: these Add Project flows call IPC directly, bypassing the repo slice
// action that normally keeps the project-first compatibility model synced.
useAppStore.setState({
repos,
projects: projection.projects,
projectHostSetups: projection.setups
})
}
@@ -10,7 +10,9 @@ const mocks = vi.hoisted(() => ({
refIndex: 0,
storeState: {
settings: { activeRuntimeEnvironmentId: null as string | null },
repos: [] as Repo[]
repos: [] as Repo[],
projects: [],
projectHostSetups: []
},
cloneRemote: vi.fn(),
cloneLocal: vi.fn(),
@@ -96,6 +98,8 @@ describe('useAddRepoCloneFlow', () => {
mocks.refValues = []
mocks.stateValues = ['https://github.com/stablyai/orca.git', '/srv', false, null, null]
mocks.storeState.repos = []
mocks.storeState.projects = []
mocks.storeState.projectHostSetups = []
vi.stubGlobal('window', {
api: {
repos: {
@@ -134,6 +138,12 @@ describe('useAddRepoCloneFlow', () => {
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true
})
expect(mocks.storeState.projects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })])
)
expect(mocks.storeState.projectHostSetups).toEqual(
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
)
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, 'clone_url')
})
@@ -8,6 +8,7 @@ import { getCloneDestinationAutoFill } from './clone-defaults'
import type { AddRepoDialogStep } from './add-repo-dialog-types'
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
export function useAddRepoCloneFlow({
step,
@@ -151,16 +152,7 @@ export function useAddRepoCloneFlow({
translate('auto.components.sidebar.useAddRepoCloneFlow.4d0013cc93', 'Repository cloned'),
{ description: repo.displayName }
)
// Why: eagerly upsert so step 2 finds the repo before the IPC event.
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
upsertAddedRepoWithProjectHostSetup(repo)
// Why: once the repo exists, a transient non-authoritative refresh
// should fall through to project reveal instead of leaving the add flow open.
await fetchWorktrees(repo.id, { requireAuthoritative: true })
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
storeState: {
settings: { activeRuntimeEnvironmentId: null as string | null },
repos: [] as Repo[],
projects: [],
projectHostSetups: [],
worktreesByRepo: {} as Record<string, unknown[]>
},
createRepo: vi.fn(),
@@ -98,6 +100,8 @@ describe('useCreateRepo default-checkout handoff', () => {
mocks.stateSetters = []
mocks.stateValues = ['created', '/projects', 'git', null, false]
mocks.storeState.repos = []
mocks.storeState.projects = []
mocks.storeState.projectHostSetups = []
mocks.storeState.worktreesByRepo = {}
mocks.createRepo.mockReset()
mocks.createRemoteRepo.mockReset()
@@ -130,6 +134,12 @@ describe('useCreateRepo default-checkout handoff', () => {
expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, {
requireAuthoritative: true
})
expect(mocks.storeState.projects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })])
)
expect(mocks.storeState.projectHostSetups).toEqual(
expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })])
)
expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id)
})
@@ -12,6 +12,7 @@ import type { Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import type { RepoKind } from './create-project-defaults'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert'
export function useCreateRepo(
fetchWorktrees: (
@@ -137,8 +138,6 @@ export function useCreateRepo(
return
}
const repo = result.repo
// Upsert into the store before the repos:changed event round-trips,
// so the next step can find the repo immediately.
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
// Why: the IPC handler dedupes by path (see repos:create) and returns
@@ -146,13 +145,7 @@ export function useCreateRepo(
// handler took the dedup path — no new project was created, so don't
// claim one was.
const wasDeduped = existingIdx !== -1
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
upsertAddedRepoWithProjectHostSetup(repo)
if (wasDeduped) {
toast.info(
translate(
+8 -1
View File
@@ -25,6 +25,7 @@ import {
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import type {
GitHubWorkItem,
GitHubPrStartPoint,
@@ -2423,6 +2424,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const submitBaseBranch =
selectedRepoIsGit && !baseBranch
? ((await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null))
?.defaultBaseRef ?? undefined)
: baseBranch
const createDisplayName =
smartGitHubResolution?.displayName ??
(nameIsAutoManaged ? submitTitleName?.displayName : undefined)
@@ -2503,7 +2509,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
repoId,
name: workspaceName,
...(createDisplayName ? { displayName: createDisplayName } : {}),
...(selectedRepoIsGit && baseBranch ? { baseBranch } : {}),
...(selectedRepoIsGit && submitBaseBranch ? { baseBranch: submitBaseBranch } : {}),
setupDecision: effectiveSetupDecision,
...(selectedRepoIsGit && sparseEnabled
? {
@@ -2581,6 +2587,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
resolvedInitialWorkspaceStatus,
selectedRepo,
selectedRepoIsGit,
selectedRepoSettings,
selectedRepoRequiresConnection,
showProjectRequiredError,
settings?.agentCmdOverrides,