diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 6025e525627..c413c01166f 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -46,15 +46,26 @@ vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' })) import { setCachedRepos } from '../cache/repo-cache' import { NewWorktreeModal } from './NewWorktreeModal' -const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }] +const repos = [ + { + id: 'repo-1', + displayName: 'orca', + path: '/src/orca', + kind: 'git', + upstream: { owner: 'stablyai', repo: 'orca' } + } +] -function repoPickerItems(renderer: ReactTestRenderer): { label: string; detail: string }[] { +function pickerItems( + renderer: ReactTestRenderer, + title: string +): { label: string; detail: string }[] { const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer') - const repoPicker = pickers.find((node) => node.props.title === 'Repository') - return repoPicker?.props.items ?? [] + const picker = pickers.find((node) => node.props.title === title) + return picker?.props.items ?? [] } -describe('NewWorktreeModal repo list', () => { +describe('NewWorktreeModal project targets', () => { let renderer: ReactTestRenderer beforeEach(() => { @@ -91,12 +102,15 @@ describe('NewWorktreeModal repo list', () => { }) expect(sendRequest).toHaveBeenCalledWith('repo.list') - expect(repoPickerItems(renderer)).toEqual([ - expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' }) + expect(pickerItems(renderer, 'Project')).toEqual([ + expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' }) + ]) + expect(pickerItems(renderer, 'Run on')).toEqual([ + expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' }) ]) }) - it('labels same-name local and SSH repositories with their locations', async () => { + it('groups same-name checkouts under one project with separate run targets', async () => { const listedRepos = [ ...repos, { @@ -104,7 +118,8 @@ describe('NewWorktreeModal repo list', () => { displayName: 'orca', path: '/home/dev/orca', connectionId: 'build-server', - kind: 'git' + kind: 'git', + upstream: { owner: 'stablyai', repo: 'orca' } } ] const client = { @@ -130,11 +145,14 @@ describe('NewWorktreeModal repo list', () => { await Promise.resolve() }) - expect(repoPickerItems(renderer)).toEqual([ - expect.objectContaining({ label: 'orca', detail: 'Local · /src/orca' }), + expect(pickerItems(renderer, 'Project')).toEqual([ + expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' }) + ]) + expect(pickerItems(renderer, 'Run on')).toEqual([ + expect.objectContaining({ label: 'Local Mac', detail: '/src/orca' }), expect.objectContaining({ - label: 'orca', - detail: 'SSH · build-server · /home/dev/orca' + label: 'SSH · build-server', + detail: '/home/dev/orca' }) ]) }) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 84089ed4e26..e369ab32b70 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,7 +10,7 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp } from 'lucide-react-native' +import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' @@ -29,7 +29,11 @@ import { type SetupHookTrust } from '../tasks/setup-hook-trust' import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' -import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types' +import type { + PersistedTrustedOrcaHooks, + Repo as SharedRepo, + TuiAgent +} from '../../../src/shared/types' import type { SshConnectionState } from '../../../src/shared/ssh-types' import { NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS, @@ -63,19 +67,27 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' -import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail' +import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields' +import { + buildNewWorkspaceProjectOptions, + buildNewWorkspaceRunTargetOptions, + getNewWorkspaceProjectId, + getNewWorkspaceRunTarget +} from './new-workspace-project-targets' -type Repo = { - id: string - displayName: string - path: string - badgeColor?: string - connectionId?: string | null - executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null - kind?: 'git' | 'folder' - upstream?: { owner: string; repo: string } | null - gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null -} +type Repo = Pick & + Partial< + Pick< + SharedRepo, + | 'badgeColor' + | 'connectionId' + | 'executionHostId' + | 'kind' + | 'upstream' + | 'repoIcon' + | 'gitRemoteIdentity' + > + > type SetupDecision = 'inherit' | 'run' | 'skip' type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' @@ -696,16 +708,15 @@ function NewWorktreeModalContent({ isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) ) const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL] - const repoPickerItems = useMemo( - () => - repos.map((repo) => ({ - id: repo.id, - label: repo.displayName, - detail: getNewWorkspaceRepoDetail(repo), - repo - })), - [repos] + const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) + const selectedProjectId = selectedRepo ? getNewWorkspaceProjectId(selectedRepo) : null + const selectedProject = + projectPickerItems.find((project) => project.id === selectedProjectId) ?? null + const runTargetPickerItems = useMemo( + () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId), + [repos, selectedProjectId] ) + const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo) : null function prepareSelectionPickerOpen(): void { // Why: picker taps can beat an open soft keyboard; dismissing it prevents the @@ -793,10 +804,7 @@ function NewWorktreeModalContent({ > - Create Workspace - - Pick a repository and agent to spin up a new workspace. - + Create worktree {loading ? ( @@ -805,40 +813,23 @@ function NewWorktreeModalContent({ ) : repos.length === 0 ? ( - No repositories found + No projects found ) : ( <> - - Repository - { - prepareSelectionPickerOpen() - transitionDrawer('repo') - }} - > - {selectedRepo ? ( - - ) : null} - - - {selectedRepo?.displayName ?? 'Select a repository'} - - {selectedRepo ? ( - - {getNewWorkspaceRepoDetail(selectedRepo)} - - ) : null} - - - - + { + prepareSelectionPickerOpen() + transitionDrawer('project') + }} + onOpenRunTarget={() => { + prepareSelectionPickerOpen() + transitionDrawer('runTarget') + }} + /> - {sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label} + {sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label} @@ -1010,7 +1001,7 @@ function NewWorktreeModalContent({ ) : ( - {sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'} + {sshGate.requiresConnection ? 'Connect target' : 'Create worktree'} )} @@ -1039,10 +1030,10 @@ function NewWorktreeModalContent({ /> handleRepoSelected(item.repo)} onClose={() => transitionDrawer('form')} renderIcon={(item) => { @@ -1050,6 +1041,16 @@ function NewWorktreeModalContent({ }} /> + handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} + renderIcon={() => } + /> + void + onOpenRunTarget: () => void +}) { + return ( + <> + + Project + + {projectBadgeColor ? ( + + ) : null} + + + + + + + Run on + + + + + + + + ) +} + +function SelectionCopy({ + selection, + placeholder +}: { + selection: Selection | null + placeholder: string +}) { + return ( + + + {selection?.label ?? placeholder} + + {selection?.detail ? ( + + {selection.detail} + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm + }, + projectDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + fieldButtonCopy: { + flex: 1, + minWidth: 0 + }, + fieldButtonText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonDetail: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginTop: 1 + }, + fieldButtonPlaceholder: { + color: colors.textMuted + } +}) diff --git a/mobile/src/components/new-workspace-project-targets.test.ts b/mobile/src/components/new-workspace-project-targets.test.ts new file mode 100644 index 00000000000..2dd6d092c72 --- /dev/null +++ b/mobile/src/components/new-workspace-project-targets.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + buildNewWorkspaceProjectOptions, + buildNewWorkspaceRunTargetOptions, + getNewWorkspaceRunTarget +} from './new-workspace-project-targets' + +describe('new workspace project targets', () => { + it('groups local and SSH checkouts of the same project', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const options = buildNewWorkspaceProjectOptions([ + { id: 'local', displayName: 'orca', path: '/src/orca', upstream }, + { + id: 'ssh', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + upstream + } + ]) + + expect(options).toHaveLength(1) + expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' }) + }) + + it('shows the provider slug recovered from canonical git identity', () => { + const options = buildNewWorkspaceProjectOptions([ + { + id: 'local', + displayName: 'orca', + path: '/src/orca', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' + } + } + ]) + + expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' }) + }) + + it('labels local, SSH, and paired runtime targets', () => { + expect( + getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }) + ).toEqual({ label: 'Local Mac', detail: '/src/orca' }) + expect( + getNewWorkspaceRunTarget({ + id: 'ssh', + displayName: 'orca', + path: 'C:\\src\\orca', + executionHostId: 'ssh:Windows%20VM' + }) + ).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' }) + expect( + getNewWorkspaceRunTarget({ + id: 'runtime', + displayName: 'orca', + path: '/src/orca', + executionHostId: 'runtime:devbox' + }) + ).toEqual({ label: 'Remote · devbox', detail: '/src/orca' }) + }) + + it('shows one target per host when the project has multiple local worktrees', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const repos = [ + { id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream }, + { id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream }, + { + id: 'ssh', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + upstream + } + ] + const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null + + expect(buildNewWorkspaceRunTargetOptions(repos, projectId)).toEqual([ + expect.objectContaining({ id: 'local-a', label: 'Local Mac', detail: '/src/orca-a' }), + expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' }) + ]) + }) +}) diff --git a/mobile/src/components/new-workspace-project-targets.ts b/mobile/src/components/new-workspace-project-targets.ts new file mode 100644 index 00000000000..671eea80463 --- /dev/null +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -0,0 +1,101 @@ +import type { Repo } from '../../../src/shared/types' +import { getRepoExecutionHostId } from '../../../src/shared/execution-host' +import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' + +type WorkspaceRepo = Pick & + Partial< + Pick + > + +export type NewWorkspaceProjectOption = { + id: string + label: string + detail?: string + repo: TRepo +} + +export type NewWorkspaceRunTargetOption = { + id: string + label: string + detail: string + repo: TRepo +} + +function executionHostName(executionHostId: string): string { + const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1) + try { + return decodeURIComponent(encodedName) + } catch { + return encodedName + } +} + +export function getNewWorkspaceProjectId(repo: WorkspaceRepo): string { + return getProjectIdentityKey(repo) +} + +export function buildNewWorkspaceProjectOptions( + repos: readonly TRepo[] +): NewWorkspaceProjectOption[] { + const options = new Map>() + const hostIdsByProject = new Map>() + for (const repo of repos) { + const id = getNewWorkspaceProjectId(repo) + const hostIds = hostIdsByProject.get(id) ?? new Set() + hostIds.add(getRepoExecutionHostId(repo)) + hostIdsByProject.set(id, hostIds) + if (!options.has(id)) { + options.set(id, { + id, + label: repo.displayName, + repo + }) + } + } + return [...options.values()].map((option) => { + const providerParts = option.id.startsWith('github:') + ? option.id.slice('github:'.length).split('/') + : [] + const providerDetail = providerParts.slice(-2).join('/') + const hostCount = hostIdsByProject.get(option.id)?.size ?? 0 + const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '') + return detail ? { ...option, detail } : option + }) +} + +export function getNewWorkspaceRunTarget(repo: WorkspaceRepo): { + label: string + detail: string +} { + const connectionId = repo.connectionId?.trim() + if (connectionId) { + return { label: `SSH · ${connectionId}`, detail: repo.path } + } + if (repo.executionHostId?.startsWith('ssh:')) { + return { label: `SSH · ${executionHostName(repo.executionHostId)}`, detail: repo.path } + } + if (repo.executionHostId?.startsWith('runtime:')) { + return { label: `Remote · ${executionHostName(repo.executionHostId)}`, detail: repo.path } + } + return { label: 'Local Mac', detail: repo.path } +} + +export function buildNewWorkspaceRunTargetOptions( + repos: readonly TRepo[], + projectId: string | null +): NewWorkspaceRunTargetOption[] { + if (!projectId) { + return [] + } + const options = new Map>() + for (const repo of repos) { + if (getNewWorkspaceProjectId(repo) !== projectId) { + continue + } + const hostId = getRepoExecutionHostId(repo) + if (!options.has(hostId)) { + options.set(hostId, { id: repo.id, ...getNewWorkspaceRunTarget(repo), repo }) + } + } + return [...options.values()] +} diff --git a/mobile/src/components/new-workspace-repo-detail.test.ts b/mobile/src/components/new-workspace-repo-detail.test.ts deleted file mode 100644 index 7033454c0bb..00000000000 --- a/mobile/src/components/new-workspace-repo-detail.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getNewWorkspaceRepoDetail } from './new-workspace-repo-detail' - -describe('new workspace repository detail', () => { - it('identifies local repositories', () => { - expect(getNewWorkspaceRepoDetail({ path: '/src/orca' })).toBe('Local · /src/orca') - }) - - it('identifies SSH repositories by connection and path', () => { - expect(getNewWorkspaceRepoDetail({ path: '/src/orca', connectionId: 'build-server' })).toBe( - 'SSH · build-server · /src/orca' - ) - }) - - it('uses the execution host when an SSH connection ID is absent', () => { - expect( - getNewWorkspaceRepoDetail({ path: 'C:\\src\\orca', executionHostId: 'ssh:Windows%20VM' }) - ).toBe('SSH · Windows VM · C:\\src\\orca') - }) - - it('distinguishes paired runtime repositories from local repositories', () => { - expect( - getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:devbox' }) - ).toBe('Remote · devbox · /src/orca') - }) - - it('prefers the explicit execution host over stale SSH metadata', () => { - expect( - getNewWorkspaceRepoDetail({ - path: '/src/orca', - connectionId: 'old-ssh-target', - executionHostId: 'runtime:devbox' - }) - ).toBe('Remote · devbox · /src/orca') - expect( - getNewWorkspaceRepoDetail({ - path: '/src/orca', - connectionId: 'old-ssh-target', - executionHostId: 'local' - }) - ).toBe('Local · /src/orca') - }) - - it('falls back when an execution host label is malformed', () => { - expect( - getNewWorkspaceRepoDetail({ - path: '/src/orca', - connectionId: 'build-server', - executionHostId: 'ssh:%E0%A4%A' - }) - ).toBe('SSH · build-server · /src/orca') - expect(getNewWorkspaceRepoDetail({ path: '/src/orca', executionHostId: 'runtime:' })).toBe( - 'Local · /src/orca' - ) - }) -}) diff --git a/mobile/src/components/new-workspace-repo-detail.ts b/mobile/src/components/new-workspace-repo-detail.ts deleted file mode 100644 index 9e9ae9a3752..00000000000 --- a/mobile/src/components/new-workspace-repo-detail.ts +++ /dev/null @@ -1,41 +0,0 @@ -type RepoLocation = { - path: string - connectionId?: string | null - executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null -} - -function executionHostName(executionHostId: string): string | null { - const encodedName = executionHostId.slice(executionHostId.indexOf(':') + 1) - if (!encodedName) { - return null - } - try { - return decodeURIComponent(encodedName) || null - } catch { - return null - } -} - -export function getNewWorkspaceRepoDetail(repo: RepoLocation): string { - const executionHostId = repo.executionHostId?.trim() - if (executionHostId === 'local') { - return `Local · ${repo.path}` - } - if (executionHostId?.startsWith('ssh:')) { - const hostName = executionHostName(executionHostId) - if (hostName) { - return `SSH · ${hostName} · ${repo.path}` - } - } - if (executionHostId?.startsWith('runtime:')) { - const hostName = executionHostName(executionHostId) - if (hostName) { - return `Remote · ${hostName} · ${repo.path}` - } - } - const connectionId = repo.connectionId?.trim() - if (connectionId) { - return `SSH · ${connectionId} · ${repo.path}` - } - return `Local · ${repo.path}` -} diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.test.ts b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts index 450097b74b0..eebe66015fe 100644 --- a/mobile/src/components/new-worktree-form-sheet-visibility.test.ts +++ b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts @@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => { expect( resolveNewWorktreeFormSheetVisible({ modalVisible: true, - drawerView: 'repo', + drawerView: 'project', formPinnedUnderSource: false }) ).toBe(false) diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.ts b/mobile/src/components/use-new-worktree-drawer-navigation.ts index c21a6dee4f1..4f3261cb685 100644 --- a/mobile/src/components/use-new-worktree-drawer-navigation.ts +++ b/mobile/src/components/use-new-worktree-drawer-navigation.ts @@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react' import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility' -export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' +export type NewWorktreeDrawerView = + | 'form' + | 'transition' + | 'source' + | 'project' + | 'runTarget' + | 'agent' + | 'trust' // Why: iOS cannot reliably present a second native modal until the first drawer's // exit commits; one extra frame keeps transitions sequential on slower devices.