From 819360c541daa5d2aef7810d8c4d89cb03be1cb1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:39:51 -0700 Subject: [PATCH] refactor: share workspace creation behavior across clients --- mobile/app/h/[hostId]/index.tsx | 1 + mobile/src/components/NewWorktreeModal.tsx | 93 +++++++-- .../components/NewWorktreeModalController.tsx | 3 + .../tasks/composer-linked-work-item.test.ts | 4 +- mobile/src/tasks/composer-linked-work-item.ts | 118 ++--------- .../src/tasks/composer-source-base-resolve.ts | 16 +- .../src/tasks/mobile-composer-source-types.ts | 27 +-- mobile/src/tasks/smart-source-fan-out.test.ts | 13 ++ mobile/src/tasks/smart-source-fan-out.ts | 10 +- .../tasks/smart-source-paste-intent.test.ts | 66 ++++++ mobile/src/tasks/smart-source-paste-intent.ts | 34 +++ .../src/tasks/use-mobile-composer-source.ts | 32 ++- .../src/tasks/use-smart-workspace-source.ts | 16 +- .../src/tasks/work-item-lookup-text.test.ts | 2 +- mobile/src/tasks/work-item-lookup-text.ts | 25 +-- .../src/tasks/workspace-create-params.test.ts | 2 +- mobile/src/tasks/workspace-create-params.ts | 36 ++-- mobile/src/tasks/worktree-create-retry.ts | 22 +- mobile/src/transport/protocol-compat.test.ts | 13 ++ src/main/runtime/rpc/schemas.test.ts | 33 +++ .../folder-workspace-composer-helpers.ts | 62 ++---- .../hooks/composer-branch-selection.test.ts | 15 ++ .../src/hooks/composer-branch-selection.ts | 3 + src/renderer/src/hooks/fork-push-warning.ts | 22 +- src/renderer/src/hooks/useComposerState.ts | 106 ++++------ .../src/lib/linear-linked-work-item.ts | 20 +- .../src/lib/linked-work-item-provider.ts | 54 +---- src/renderer/src/lib/work-item-lookup-text.ts | 25 +-- src/renderer/src/store/slices/worktrees.ts | 24 +-- src/shared/composer-branch-selection.ts | 44 ++++ src/shared/new-workspace/fork-push-warning.ts | 16 ++ .../new-workspace/work-item-lookup-text.ts | 30 +++ .../new-workspace/workspace-source.test.ts | 65 ++++++ src/shared/new-workspace/workspace-source.ts | 195 ++++++++++++++++++ .../worktree-create-retry-policy.test.ts | 19 ++ .../worktree-create-retry-policy.ts | 18 ++ src/shared/protocol-compat.test.ts | 22 ++ 37 files changed, 849 insertions(+), 457 deletions(-) create mode 100644 mobile/src/transport/protocol-compat.test.ts create mode 100644 src/shared/new-workspace/fork-push-warning.ts create mode 100644 src/shared/new-workspace/work-item-lookup-text.ts create mode 100644 src/shared/new-workspace/workspace-source.test.ts create mode 100644 src/shared/new-workspace/workspace-source.ts create mode 100644 src/shared/new-workspace/worktree-create-retry-policy.test.ts create mode 100644 src/shared/new-workspace/worktree-create-retry-policy.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 01a3cc95acd..b8db4e3a292 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1428,6 +1428,7 @@ export function HostScreen({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={worktrees} onVisibleChange={(visible) => { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 541c67855b0..81b5dc6726c 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -14,7 +14,7 @@ import { ChevronDown, ChevronUp } 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' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' import { getSuggestedCreatureName } from './worktree-name-suggestion' @@ -59,6 +59,8 @@ import { import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' @@ -108,6 +110,12 @@ type CreateOptions = { approvedSetupContentHash?: string } +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | '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. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 + function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] let hash = 0 @@ -133,6 +141,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -142,6 +151,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -166,6 +176,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -177,21 +188,21 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [showSourceDrawer, setShowSourceDrawer] = useState(false) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) const [note, setNote] = useState('') @@ -210,6 +221,29 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) + + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } // The Smart source picker owns the workspace name AND the linked-source // selection: typing names the workspace and drives source search, and picking @@ -218,6 +252,7 @@ function NewWorktreeModalContent({ const composer = useMobileComposerSource({ client, selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, onError: setError }) @@ -661,6 +696,7 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } @@ -737,9 +773,9 @@ function NewWorktreeModalContent({ function handleRepoSelected(repo: Repo): void { const repoChanged = repo.id !== selectedRepo?.id setSelectedRepo(repo) - // Branch and GitHub/GitLab sources are tied to the repo they were searched - // from; switching repos invalidates them, so clear the selection. - if (repoChanged) { + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { composer.handleClearSmartNameSelection() } } @@ -756,6 +792,7 @@ function NewWorktreeModalContent({ ) const approvedHash = setupTrustPrompt.contentHash setSetupTrustPrompt(null) + transitionDrawer('form') await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to trust setup script.') @@ -764,7 +801,7 @@ function NewWorktreeModalContent({ return ( <> - + Create Workspace @@ -788,7 +825,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -811,9 +848,13 @@ function NewWorktreeModalContent({ label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'} disabled={sshGate.requiresConnection} onBeforeOpen={() => setError('')} - onOpenDrawer={() => setShowSourceDrawer(true)} + onOpenDrawer={() => transitionDrawer('source')} /> + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -864,7 +905,7 @@ function NewWorktreeModalContent({ disabled={sshGate.requiresConnection} onPress={() => { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -980,10 +1021,10 @@ function NewWorktreeModalContent({ )} - {/* Why: list drawers must be siblings of the form drawer so their virtualized - lists are not nested inside the form's vertical ScrollView. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state prevents overlapping native modals from swallowing iOS taps. */} setShowSourceDrawer(false)} + onClose={() => transitionDrawer('form')} /> handleRepoSelected(item.repo)} - onClose={() => setShowRepoPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> void approveSetupTrust(false)} onAlwaysTrust={() => void approveSetupTrust(true)} onDontRun={() => { setSetupTrustPrompt(null) + transitionDrawer('form') void handleCreate({ setupOverride: 'skip' }) }} - onClose={() => setSetupTrustPrompt(null)} + onClose={() => { + setSetupTrustPrompt(null) + transitionDrawer('form') + }} /> ) @@ -1176,6 +1221,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..9a062692d17 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts index 79e125f4783..d08d1388a0e 100644 --- a/mobile/src/tasks/composer-linked-work-item.test.ts +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -58,9 +58,9 @@ describe('shouldApplyAutoName', () => { expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) }) - it('applies when the name is a lookup query (URL / #N / linear id)', () => { + it('applies when the name is a lookup query (URL / #N)', () => { expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) - expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) }) it('keeps a deliberately typed name', () => { diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts index a99b9ad9f72..ed2bc4dbf2d 100644 --- a/mobile/src/tasks/composer-linked-work-item.ts +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -1,17 +1,14 @@ import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' import { - getLinearIssueWorkspaceName, - getLinkedWorkItemSuggestedName, - getLinkedWorkItemWorkspaceName -} from '../../../src/shared/workspace-name' -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links' -import { isWorkItemLookupText } from './work-item-lookup-text' -import { - isBranchCheckedOutInWorktrees, - resolveComposerBranchReuse, - resolveComposerBranchSelection, - resolveComposerReuseOverride -} from '../../../src/shared/composer-branch-selection' + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection' import type { MobileComposerCreateSelection, MobileLinkedWorkItem, @@ -26,14 +23,7 @@ export function buildGitHubLinkedWorkItem(item: { url: string repoId: string }): MobileLinkedWorkItem { - return { - provider: 'github', - type: item.type, - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitHubWorkspaceSource(item) } export function buildGitLabLinkedWorkItem(item: { @@ -43,14 +33,7 @@ export function buildGitLabLinkedWorkItem(item: { url: string repoId: string }): MobileLinkedWorkItem { - return { - provider: 'gitlab', - type: item.type, - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitLabWorkspaceSource(item) } export function buildLinearLinkedWorkItem(issue: { @@ -59,28 +42,14 @@ export function buildLinearLinkedWorkItem(issue: { url: string workspaceId?: string }): MobileLinkedWorkItem { - const orgUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) - return { - provider: 'linear', - type: 'issue', - number: 0, - title: issue.title, - url: issue.url, - linearIdentifier: issue.identifier, - ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(orgUrlKey ? { linearOrganizationUrlKey: orgUrlKey } : {}) - } + return buildLinearWorkspaceSource(issue) } // Faithful port of desktop applyLinkedWorkItem's name gate: the derived name // replaces the current field only when it's empty, still the last auto-name, or // a lookup query — never a name the user deliberately typed. export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { - return ( - !args.currentName.trim() || - args.currentName === args.lastAutoName || - isWorkItemLookupText(args.currentName) - ) + return shouldApplyWorkspaceSourceAutoName(args) } export function resolveWorkItemAutoName(item: { @@ -90,7 +59,7 @@ export function resolveWorkItemAutoName(item: { provider: 'github' | 'gitlab' | 'linear' linearIdentifier?: string }): string { - return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) + return getWorkspaceSourceName({ ...item, url: '' }).seedName } export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { @@ -103,31 +72,7 @@ export function buildSmartNameSelection(args: { linkedWorkItem: MobileLinkedWorkItem | null baseBranch: string | undefined }): SmartNameSelection | null { - const { linkedWorkItem, baseBranch } = args - if (linkedWorkItem) { - const isLinear = linkedWorkItem.provider === 'linear' - const kind: SmartNameSelection['kind'] = isLinear - ? 'linear' - : linkedWorkItem.provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - isLinear || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } - } - if (baseBranch) { - return { kind: 'branch', label: baseBranch } - } - return null + return buildWorkspaceSourceSelection(args) as SmartNameSelection | null } // Derives the create-time selection from composer state: a linked work item wins @@ -191,33 +136,14 @@ export function resolveComposerBranchPick(args: { lastAutoName: string worktreeBranches: readonly string[] }): ComposerBranchPick { - const { refName, localBranchName } = args - const selection = resolveComposerBranchSelection({ - refName, - localBranchName, - currentName: args.currentName, - lastAutoName: args.lastAutoName - }) - const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( - localBranchName, - args.worktreeBranches - ) - const { reuseEligibleBranch, defaultReuse } = resolveComposerBranchReuse({ - refName, - localBranchName, - selectionProducedOverride: selection.branchNameOverride !== undefined, - branchCheckedOutElsewhere - }) - const effectiveOverride = resolveComposerReuseOverride({ - refName, - localBranchName, - branchNameOverride: selection.branchNameOverride, - branchCheckedOutElsewhere - }) + const selection = resolveSharedComposerBranchPick(args) return { - base: { baseBranch: selection.baseBranch, branchNameOverride: effectiveOverride }, - reuseEligibleBranch, - reuseSelectedBranch: defaultReuse, + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, ...(selection.name !== undefined && selection.lastAutoName !== undefined ? { name: selection.name, lastAutoName: selection.lastAutoName } : {}) diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts index caf53497d6a..73c7eaa1839 100644 --- a/mobile/src/tasks/composer-source-base-resolve.ts +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -1,15 +1,13 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' -import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' +import type { GitHubPrStartPoint } from '../../../src/shared/types' // The resolved start point for a linked PR/MR: the base branch to create from // plus the optional review-compare ref, push target, and exact branch name. -export type ComposerHostedBase = { - baseBranch: string - compareBaseRef?: string - pushTarget?: WorkspaceCreateGitPushTarget - branchNameOverride?: string -} +export type ComposerHostedBase = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> type HostedBaseResult = ComposerHostedBase | { error: string } @@ -23,7 +21,7 @@ export async function resolveComposerPrBase(args: { headRefName?: string baseRefName?: string isCrossRepository?: boolean -}): Promise { +}): Promise { const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args const response = await client.sendRequest( 'worktree.resolvePrBase', @@ -39,7 +37,7 @@ export async function resolveComposerPrBase(args: { if (!response.ok) { throw new Error(response.error.message) } - const result = (response as RpcSuccess).result as HostedBaseResult + const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } if ('error' in result) { throw new Error(result.error) } diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts index 8fca3f90b31..d36215189af 100644 --- a/mobile/src/tasks/mobile-composer-source-types.ts +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -1,21 +1,24 @@ import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' export type { SmartNameMode } +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + // Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask // superset): the one work item a Smart selection pins the workspace to. Linear // items carry the workspace/org routing the runtime needs to relink the issue. -export type MobileLinkedWorkItem = { - provider: 'github' | 'gitlab' | 'linear' - type: 'issue' | 'pr' | 'mr' - number: number - title: string - url: string - repoId?: string - linearIdentifier?: string - linearWorkspaceId?: string - linearOrganizationUrlKey?: string +export type MobileLinkedWorkItem = Omit & { + provider: Exclude } export type SmartNameSelectionKind = @@ -28,10 +31,8 @@ export type SmartNameSelectionKind = // The pill descriptor the field renders once a source is selected. Same shape // as desktop's `SmartWorkspaceNameSelection`. -export type SmartNameSelection = { +export type SmartNameSelection = Omit & { kind: SmartNameSelectionKind - label: string - url?: string } // GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts index 1842b892d67..cffdc287e22 100644 --- a/mobile/src/tasks/smart-source-fan-out.test.ts +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -96,4 +96,17 @@ describe('fanOutSmartSearch', () => { await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) }) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts index bb98c022741..e2217597ab9 100644 --- a/mobile/src/tasks/smart-source-fan-out.ts +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -4,7 +4,10 @@ import type { GitLabWorkItem, LinearIssue } from '../../../src/shared/types' -import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + isSmartWorkspaceSourceQueryWithinLimit, + type SmartNameMode +} from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' import type { MrStateFilter } from './mobile-composer-source-types' @@ -64,6 +67,11 @@ type FanOutArgs = { // modes surface the failure. No cross-provider ranking/dedup — the shared row // builder concatenates in provider order. export async function fanOutSmartSearch(args: FanOutArgs): Promise { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } const { client, mode, diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts index 0130e2cad53..a99956ae84a 100644 --- a/mobile/src/tasks/smart-source-paste-intent.test.ts +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import { deriveRepoSlug, findRepoMatchingSlug, + findRepoMatchingSlugForPaste, resolvePasteIntent, type PasteRepoCandidate } from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' describe('resolvePasteIntent', () => { it('classifies a GitHub issue/PR URL as a github-link', () => { @@ -29,6 +31,12 @@ describe('resolvePasteIntent', () => { it('returns null for plain search text', () => { expect(resolvePasteIntent('login bug')).toBeNull() }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).toBeNull() + }) }) describe('deriveRepoSlug', () => { @@ -69,4 +77,62 @@ describe('findRepoMatchingSlug', () => { it('returns null when no repo matches', () => { expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + const client = { + sendRequest: async () => ({ + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + }) + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) }) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index c6fa9cdd331..6cd37f4a298 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -6,6 +6,7 @@ import { type RepoSlug } from '../../../src/shared/new-workspace/github-links' import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' @@ -32,6 +33,9 @@ export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null // GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; // a GitLab issue/MR URL becomes 'gitlab-link'. export function resolvePasteIntent(query: string): PasteIntent { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } const trimmed = query.trim() if (!trimmed) { return null @@ -84,6 +88,36 @@ export function findRepoMatchingSlug( return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null } +export async function findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + export async function lookupGitHubItemByNumber( client: RpcClient, repoId: string, diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts index 6fce6d14e4b..6785e2fe30b 100644 --- a/mobile/src/tasks/use-mobile-composer-source.ts +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' import type { RpcClient } from '../transport/rpc-client' import { buildGitHubLinkedWorkItem, @@ -20,19 +21,11 @@ import { type ComposerHostedBase } from './composer-source-base-resolve' import type { + ComposerBaseState, MobileComposerCreateSelection, MobileLinkedWorkItem, SmartNameSelection } from './mobile-composer-source-types' -import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' - -type ComposerBaseState = { - baseBranch?: string - compareBaseRef?: string - pushTarget?: WorkspaceCreateGitPushTarget - branchNameOverride?: string -} - const EMPTY_BASE: ComposerBaseState = {} export type UseMobileComposerSourceArgs = { @@ -98,6 +91,7 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { pushTarget: result.pushTarget, branchNameOverride: result.branchNameOverride }) + setForkPushWarning(getForkPushWarning(result)) }) .catch((error: unknown) => { if (resolveTokenRef.current !== token) { @@ -270,20 +264,22 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { } }, [clearBaseAndBranch, name]) - const handleBranchNameOverrideChange = useCallback((value: string) => { - setBase((prev) => { + const handleBranchNameOverrideChange = useCallback( + (value: string) => { const next = resolveComposerManualBranchNameChange({ value, - pushTarget: prev.pushTarget, - forkPushWarning: null + pushTarget: base.pushTarget, + forkPushWarning }) - return { - ...prev, + setBase({ + ...base, branchNameOverride: next.branchNameOverride, pushTarget: next.pushTarget - } - }) - }, []) + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) const smartNameSelection = useMemo( () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 70ec04b2b73..1d0c88dafd5 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -10,7 +10,7 @@ import type { RpcClient } from '../transport/rpc-client' import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' import type { MrStateFilter } from './mobile-composer-source-types' import { - findRepoMatchingSlug, + findRepoMatchingSlugForPaste, lookupGitHubItemByNumber, lookupGitHubItemByOwnerRepo, lookupGitLabItemByPath, @@ -73,6 +73,7 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { // the mode/repo changes so one provider's rows never render under another tab. const scopeRef = useRef('') const dismissedPasteRef = useRef('') + const repoSlugCacheRef = useRef>(new Map()) useEffect(() => { if (!client || !enabled || mode === 'text') { @@ -104,7 +105,8 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { mrStateFilter, linearWorkspaceId, repos, - dismissedPasteRef + dismissedPasteRef, + repoSlugCache: repoSlugCacheRef.current }) .then((result) => { if (stale) { @@ -183,12 +185,13 @@ async function runSmartSearch(args: { linearWorkspaceId: string | null | undefined repos: readonly PasteRepoCandidate[] dismissedPasteRef: { current: string } + repoSlugCache: Map }): Promise<{ fan: SmartFanOutResult paste: PasteResolved crossRepoPrompt: SmartCrossRepoPrompt | null }> { - const { client, mode, query, repoId, repos, dismissedPasteRef } = args + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args const fan = await fanOutSmartSearch(args) const paste: PasteResolved = { github: null, gitlab: null } let crossRepoPrompt: SmartCrossRepoPrompt | null = null @@ -202,7 +205,12 @@ async function runSmartSearch(args: { if (intent.kind === 'github-number') { paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) } else if (intent.kind === 'github-link') { - const matchingRepo = findRepoMatchingSlug(repos, intent.link.slug) + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) if (matchingRepo && matchingRepo.id !== repoId) { crossRepoPrompt = { link: intent.link, matchingRepo } } else { diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts index 0d005820da3..f39d3967826 100644 --- a/mobile/src/tasks/work-item-lookup-text.test.ts +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -7,7 +7,7 @@ describe('isWorkItemLookupText', () => { expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) - expect(isWorkItemLookupText('ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(false) }) it('treats plain names as non-lookup text', () => { diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts index f434fcbd015..b130292e8d3 100644 --- a/mobile/src/tasks/work-item-lookup-text.ts +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -1,24 +1 @@ -import { parseGitHubIssueOrPRNumber } from '../../../src/shared/new-workspace/github-links' -import { parseGitLabIssueOrMRNumber } from '../../../src/shared/new-workspace/gitlab-links' - -const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i -const LINEAR_IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ - -// Why: text typed to *find* a work item — a GitHub/GitLab URL, "#123", or a -// Linear link/identifier — is a lookup query, never a deliberate name. Selection -// handlers use this to decide the resolved item's title-derived name may replace -// the field content; otherwise the pasted reference silently survives behind the -// pill and the workspace gets a slugified-URL name. Mirrors desktop's -// isWorkItemLookupText using the shared link parsers. -export function isWorkItemLookupText(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) { - return false - } - return ( - parseGitHubIssueOrPRNumber(trimmed) !== null || - parseGitLabIssueOrMRNumber(trimmed) !== null || - LINEAR_ISSUE_URL_RE.test(trimmed) || - LINEAR_IDENTIFIER_RE.test(trimmed) - ) -} +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce30..e53c7965116 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index bd1518b8308..c6667470279 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -97,7 +94,18 @@ export function buildTaskWorkspaceCreateParams(args: { const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget // Why: desktop only sends displayName while the name is still auto-derived; a // user-edited name suppresses it so the runtime keeps the user's chosen name. - const displayName = nameIsAutoManaged ? { displayName: item.source.title } : {} + const sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} const common = { setupDecision, activate: true, diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 1d2140e9b02..4eb749f5c23 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -1,5 +1,10 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' // Why: server-side collision checks (branch already exists locally / on a remote @@ -7,17 +12,6 @@ import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' // branches outlive worktrees in git, and remote branches/PRs aren't visible from // worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree // loop in src/renderer/src/store/slices/worktrees.ts. -export const WORKTREE_NAME_COLLISION_PATTERNS = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i, - // Older runtimes emit a bare `Branch "x" already exists.`; mirrors the desktop - // createWorktree loop (src/renderer/src/store/slices/worktrees.ts). - /^Branch ".+" already exists\./i -] - -const MAX_NAME_ATTEMPTS = 25 - export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } // Creates a worktree, retrying with a numeric suffix on a name-collision error. @@ -32,10 +26,10 @@ export async function createWorktreeWithNameRetry(args: { maxAttempts?: number }): Promise { const { client, baseName, buildParams } = args - const maxAttempts = args.maxAttempts ?? MAX_NAME_ATTEMPTS + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS let lastError: string | null = null for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const candidateName = attempt === 0 ? baseName : `${baseName}-${attempt + 1}` + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) const response = await client.sendRequest('worktree.create', buildParams(candidateName), { timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) @@ -44,7 +38,7 @@ export async function createWorktreeWithNameRetry(args: { return { worktreeId: result.worktree.id, name: candidateName } } lastError = response.error.message - if (!WORKTREE_NAME_COLLISION_PATTERNS.some((pattern) => pattern.test(lastError ?? ''))) { + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { break } } diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 00000000000..d70ffd61bb1 --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23d5..010ef2bea11 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts index b9a527d174c..c6b920bd9d8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-helpers.ts @@ -1,9 +1,12 @@ -import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { - getLinkedWorkItemProvider, - getLinkedWorkItemWorkspaceName, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider +} from '../../../../shared/new-workspace/workspace-source' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoExecutionHostId, @@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask( if (!item) { return null } - const provider = getLinkedWorkItemProvider(item) + const provider = getWorkspaceSourceProvider(item) return { provider, type: item.type, @@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask( export function getSmartNameSelection( linkedWorkItem: LinkedWorkItemSummary | null ): SmartWorkspaceNameSelection | null { - if (!linkedWorkItem) { - return null - } - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const kind: SmartWorkspaceNameSelection['kind'] = - provider === 'linear' - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } + return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null } export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null { - return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null) + return getWorkspaceSourceName(item).displayName || null } export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'github', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitHubWorkspaceSource(item) } export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary { - return { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title, - url: item.url, - repoId: item.repoId - } + return buildGitLabWorkspaceSource(item) } export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - return buildLinearIssueLinkedWorkItem(issue) + return buildLinearWorkspaceSource(issue) } export function getFolderWorkspacePrimaryActionLabel(): string { diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index cf4e8fb7c61..e19aad2a013 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + getComposerRepoWorktreeBranches, isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, @@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => { }) }) +describe('getComposerRepoWorktreeBranches', () => { + it('supplies only the selected repo branches to reuse eligibility', () => { + expect( + getComposerRepoWorktreeBranches( + [ + { repoId: 'repo-a', branch: 'feature/a' }, + { repoId: 'repo-b', branch: 'feature/b' } + ], + 'repo-a' + ) + ).toEqual(['feature/a']) + }) +}) + describe('resolveComposerReuseOverride', () => { it('keeps the selection override for a reusable (non-busy) local branch', () => { expect( diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index 02f2dfce79f..935c1689e84 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -1,9 +1,12 @@ export { isBranchCheckedOutInWorktrees, + getComposerRepoWorktreeBranches, + resolveComposerBranchPick, resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, resolveComposerManualBranchNameChange, resolveComposerReuseOverride, + type ComposerBranchPick, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/fork-push-warning.ts b/src/renderer/src/hooks/fork-push-warning.ts index 27ab7af8073..a528541421a 100644 --- a/src/renderer/src/hooks/fork-push-warning.ts +++ b/src/renderer/src/hooks/fork-push-warning.ts @@ -1,21 +1 @@ -import type { GitHubPrStartPoint } from '../../../shared/types' - -export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = - 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' - -// Why: only warn for fork PRs where the push target points away from origin and -// whose author left "Allow edits from maintainers" off. That's the one case -// where our push to the contributor's fork can be rejected by GitHub. Returns -// the warning text to show, or null when no warning applies. -export function getForkPushWarning( - result: Pick -): string | null { - if ( - result.maintainerCanModify === false && - result.pushTarget !== undefined && - result.pushTarget.remoteName !== 'origin' - ) { - return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING - } - return null -} +export * from '../../../shared/new-workspace/fork-push-warning' diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 922f1262830..eecc7a6c974 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' +import { + buildWorkspaceSourceSelection, + shouldApplyWorkspaceSourceAutoName +} from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths' @@ -164,12 +168,10 @@ import { } from '@/lib/workspace-create-error-format' import type { SshConnectionStatus } from '../../../shared/ssh-types' import { - isBranchCheckedOutInWorktrees, resolveComposerBranchNameOverrideForCreate, - resolveComposerBranchReuse, - resolveComposerBranchSelection, + resolveComposerBranchPick, resolveComposerManualBranchNameChange, - resolveComposerReuseOverride + getComposerRepoWorktreeBranches } from './composer-branch-selection' import { isCurrentComposerDropOwner } from './composer-drop-owner' import { @@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // name or it silently becomes a slugified-URL workspace name. if ( suggestedName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = titleName?.seedName ?? suggestedName if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const nextName = getLinkedItemDisplayName(linkedItem) if ( nextName && - (!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name)) + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) ) { setName(nextName) lastAutoNameRef.current = nextName @@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleSmartBranchSelect = useCallback( (refName: string, localBranchName: string): void => { smartGitHubPrStartPointSelectionRef.current = null - const selection = resolveComposerBranchSelection({ + const selection = resolveComposerBranchPick({ refName, localBranchName, currentName: name, - lastAutoName: lastAutoNameRef.current + lastAutoName: lastAutoNameRef.current, + worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId) }) setBaseBranch(selection.baseBranch) setCompareBaseRef(undefined) @@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Note: worktreesByRepo only covers visible worktrees; a branch busy only // in a hidden external worktree falls through to the backend conflict // check, which rejects it with a clear "already exists locally" error. - const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( - localBranchName, - (worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch) - ) - const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = - resolveComposerBranchReuse({ - refName, - localBranchName, - selectionProducedOverride: selection.branchNameOverride !== undefined, - branchCheckedOutElsewhere - }) + const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection setReuseEligibleBranch(nextReuseEligibleBranch) setReuseSelectedBranch(defaultReuse) setBranchNameOverridePreservesNameEdits(defaultReuse) - const effectiveOverride = resolveComposerReuseOverride({ - refName, - localBranchName, - branchNameOverride: selection.branchNameOverride, - branchCheckedOutElsewhere - }) if (selection.name !== undefined && selection.lastAutoName !== undefined) { setName(selection.name) lastAutoNameRef.current = selection.lastAutoName - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' - setBranchNameOverride(effectiveOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) } else { - setBranchNameOverride(effectiveOverride) - branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : '' + setBranchNameOverride(selection.branchNameOverride) + branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : '' } }, [name, worktreesByRepo, repoId] @@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const suggestedName = getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue) if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. if ( - !name.trim() || - name === lastAutoNameRef.current || - isWorkItemLookupText(name) || + shouldApplyWorkspaceSourceAutoName({ + currentName: name, + lastAutoName: lastAutoNameRef.current + }) || name.trim().toLowerCase() === issue.identifier.toLowerCase() ) { setName(suggestedName) @@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (isProjectGroupTarget) { return getFolderSmartNameSelection(linkedWorkItem) } - if (linkedWorkItem) { - const provider = getLinkedWorkItemProvider(linkedWorkItem) - const isLinear = provider === 'linear' - const kind: SmartWorkspaceNameSelection['kind'] = isLinear - ? 'linear' - : provider === 'jira' - ? 'jira' - : provider === 'gitlab' - ? linkedWorkItem.type === 'mr' - ? 'gitlab-mr' - : 'gitlab-issue' - : linkedWorkItem.type === 'pr' - ? 'github-pr' - : 'github-issue' - return { - kind, - label: - isLinear || provider === 'jira' || linkedWorkItem.number === 0 - ? linkedWorkItem.title - : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, - url: linkedWorkItem.url - } - } - if (baseBranch) { - return { kind: 'branch', label: baseBranch } - } - return null + return buildWorkspaceSourceSelection({ + linkedWorkItem, + baseBranch + }) as SmartWorkspaceNameSelection | null }, [baseBranch, isProjectGroupTarget, linkedWorkItem]) const handleOpenAgentSettings = useCallback((): void => { diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 530a3c01028..5bb212f2b48 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,6 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links' +import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem( } export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { - const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) - return { - type: 'issue', - provider: 'linear', - // Why: Linear issue prose must not enter prompt metadata; keep only the - // string identifier/link and leave numeric issue metadata empty. - number: 0, - title: issue.title, - url: issue.url, - linearIdentifier: issue.identifier, - ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey - ? { - linearOrganizationUrlKey: organizationUrlKey - } - : {}) - } + return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/linked-work-item-provider.ts b/src/renderer/src/lib/linked-work-item-provider.ts index 7ce731622ff..62918eac598 100644 --- a/src/renderer/src/lib/linked-work-item-provider.ts +++ b/src/renderer/src/lib/linked-work-item-provider.ts @@ -1,50 +1,4 @@ -import type { LinkedWorkItemSummary } from './new-workspace' - -// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern -// GitLab emits issue URLs as `/-/work_items/` as well as the legacy -// `/-/issues/`. Recognize both forms. -const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i - -export function isGitLabIssueUrl(url: string): boolean { - try { - return GL_ISSUE_PATH_RE.test(new URL(url).pathname) - } catch { - return GL_ISSUE_PATH_RE.test(url) - } -} - -function isJiraIssueUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - /\.atlassian\.net$/i.test(parsed.hostname) || - /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) - ) - } catch { - return false - } -} - -export function getLinkedWorkItemProvider( - item: LinkedWorkItemSummary -): NonNullable { - if (item.provider) { - return item.provider - } - if (item.linearIdentifier) { - return 'linear' - } - if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { - return 'jira' - } - if (item.type === 'mr') { - return 'gitlab' - } - if (isGitLabIssueUrl(item.url)) { - return 'gitlab' - } - if (item.number === 0 && !item.url.includes('github.com')) { - return 'linear' - } - return 'github' -} +export { + getWorkspaceSourceProvider as getLinkedWorkItemProvider, + isGitLabIssueUrl +} from '../../../shared/new-workspace/workspace-source' diff --git a/src/renderer/src/lib/work-item-lookup-text.ts b/src/renderer/src/lib/work-item-lookup-text.ts index e8c412d7eac..8ef5ae53099 100644 --- a/src/renderer/src/lib/work-item-lookup-text.ts +++ b/src/renderer/src/lib/work-item-lookup-text.ts @@ -1,24 +1 @@ -import { getSmartGitHubSubmitIntent } from './smart-github-submit' -import { parseGitLabIssueOrMRLink } from './gitlab-links' - -const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i - -/** - * Why: text typed into the smart name field to *find* a work item — a GitHub - * or GitLab URL, "#123", or a linear.app link — is a lookup query, never a - * deliberate workspace name. Selection handlers use this to decide that the - * resolved item's title-derived name may replace the field content; otherwise - * the pasted URL silently survives behind the selection pill and the - * workspace gets a slugified-URL name. - */ -export function isWorkItemLookupText(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) { - return false - } - return ( - getSmartGitHubSubmitIntent(trimmed) !== null || - parseGitLabIssueOrMRLink(trimmed) !== null || - LINEAR_ISSUE_URL_RE.test(trimmed) - ) -} +export * from '../../../shared/new-workspace/work-item-lookup-text' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index dfd32303e25..55611abee4a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,11 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../../shared/new-workspace/worktree-create-retry-policy' import { classifyWorktreeForceDeleteReason, getLockedWorktreeRemovalReason, @@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator options ) => { const automationProvenanceRequest = options?.automationProvenanceRequest - const retryableConflictPatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /^Branch ".+" already exists\./i, - /already has pr #\d+/i - ] - const nextCandidateName = (current: string, attempt: number): string => - attempt === 0 ? current : `${current}-${attempt + 1}` - try { - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = nextCandidateName(name, attempt) + for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(name, attempt) // Why: older runtimes may still reject exact PR branch overrides on // collision, so the renderer retries both branch and worktree names. const candidateBranchNameOverride = branchNameOverride - ? nextCandidateName(branchNameOverride, attempt) + ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) : undefined try { // Why: Manual sort is user-authored order. Stamp new workspaces @@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator return result } catch (error) { const message = error instanceof Error ? error.message : String(error) - const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message)) - if (!shouldRetry || attempt === 24) { + const shouldRetry = isRetryableWorktreeCreateConflict(message) + if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { throw error } } diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index 0ee66f0a28e..9342f18bfd7 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -50,6 +50,15 @@ export function isBranchCheckedOutInWorktrees( return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName) } +export function getComposerRepoWorktreeBranches( + worktrees: readonly { repoId: string; branch: string }[], + repoId: string | null +): string[] { + return repoId + ? worktrees.filter((worktree) => worktree.repoId === repoId).map((worktree) => worktree.branch) + : [] +} + /** * Issue #5181: decide whether a picked branch row is an existing LOCAL branch * that can be reused (checked out) instead of branched off, and whether reuse @@ -99,6 +108,41 @@ export function resolveComposerReuseOverride(args: { return args.branchNameOverride } +export type ComposerBranchPick = ComposerBranchSelection & { + reuseEligibleBranch: string | null + defaultReuse: boolean +} + +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveComposerBranchSelection(args) + const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees( + args.localBranchName, + args.worktreeBranches + ) + const reuse = resolveComposerBranchReuse({ + refName: args.refName, + localBranchName: args.localBranchName, + selectionProducedOverride: selection.branchNameOverride !== undefined, + branchCheckedOutElsewhere + }) + return { + ...selection, + branchNameOverride: resolveComposerReuseOverride({ + refName: args.refName, + localBranchName: args.localBranchName, + branchNameOverride: selection.branchNameOverride, + branchCheckedOutElsewhere + }), + ...reuse + } +} + /** * The branch-name override to apply when creating a worktree from the composer. * diff --git a/src/shared/new-workspace/fork-push-warning.ts b/src/shared/new-workspace/fork-push-warning.ts new file mode 100644 index 00000000000..5c3a7c29e68 --- /dev/null +++ b/src/shared/new-workspace/fork-push-warning.ts @@ -0,0 +1,16 @@ +import type { GitHubPrStartPoint } from '../types' + +export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING = + 'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.' + +// Why: this is the one fork target where Orca can prepare the workspace but a +// later push may still be rejected by GitHub permissions. +export function getForkPushWarning( + result: Pick +): string | null { + return result.maintainerCanModify === false && + result.pushTarget !== undefined && + result.pushTarget.remoteName !== 'origin' + ? FORK_PUSH_NO_MAINTAINER_EDIT_WARNING + : null +} diff --git a/src/shared/new-workspace/work-item-lookup-text.ts b/src/shared/new-workspace/work-item-lookup-text.ts new file mode 100644 index 00000000000..251c9a976c6 --- /dev/null +++ b/src/shared/new-workspace/work-item-lookup-text.ts @@ -0,0 +1,30 @@ +import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from './github-links' +import { parseGitLabIssueOrMRLink } from './gitlab-links' + +const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i +const GITHUB_ITEM_URL_IN_TEXT_RE = + /https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/(?:issues|pull)\/\d+[^\s]*/i +const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?]+$/ + +function hasGitHubLookup(value: string): boolean { + if (parseGitHubIssueOrPRNumber(value) !== null || parseGitHubIssueOrPRLink(value) !== null) { + return true + } + const embedded = GITHUB_ITEM_URL_IN_TEXT_RE.exec(value)?.[0] + return embedded + ? parseGitHubIssueOrPRLink(embedded.replace(TRAILING_URL_PUNCTUATION_RE, '')) !== null + : false +} + +/** Lookup references may be replaced by an auto-name; deliberate names may not. */ +export function isWorkItemLookupText(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) { + return false + } + return ( + hasGitHubLookup(trimmed) || + parseGitLabIssueOrMRLink(trimmed) !== null || + LINEAR_ISSUE_URL_RE.test(trimmed) + ) +} diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts new file mode 100644 index 00000000000..93e46c4db17 --- /dev/null +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + getWorkspaceSourceProvider, + shouldApplyWorkspaceSourceAutoName, + shouldPreserveWorkspaceSourceOnRepoChange +} from './workspace-source' + +describe('workspace source policy', () => { + const linear = buildLinearWorkspaceSource({ + identifier: 'ENG-42', + title: 'Ship mobile parity', + url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity', + workspaceId: 'workspace-1' + }) + + it('builds one Linear identity for desktop and mobile create flows', () => { + expect(linear).toMatchObject({ + provider: 'linear', + number: 0, + linearIdentifier: 'ENG-42', + linearWorkspaceId: 'workspace-1', + linearOrganizationUrlKey: 'acme' + }) + expect(getWorkspaceSourceName(linear)).toEqual({ + seedName: 'eng-42-ship-mobile-parity', + displayName: 'ENG-42 Ship mobile parity' + }) + }) + + it('preserves global work-item sources across repo changes', () => { + expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'github', + type: 'issue', + number: 1, + title: 'Repo scoped', + url: 'https://github.com/o/r/issues/1' + }) + ).toBe(false) + }) + + it('shares provider inference, selection labels, and auto-name gates', () => { + const legacyGitLab = { + type: 'issue' as const, + number: 7, + title: 'Self hosted', + url: 'https://gitlab.example.com/g/p/-/work_items/7' + } + expect(getWorkspaceSourceProvider(legacyGitLab)).toBe('gitlab') + expect(buildWorkspaceSourceSelection({ linkedWorkItem: legacyGitLab })).toMatchObject({ + kind: 'gitlab-issue', + label: '#7 Self hosted' + }) + expect(shouldApplyWorkspaceSourceAutoName({ currentName: '#42', lastAutoName: 'old' })).toBe( + true + ) + expect( + shouldApplyWorkspaceSourceAutoName({ currentName: 'my workspace', lastAutoName: 'old' }) + ).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts new file mode 100644 index 00000000000..b8c3048e9c5 --- /dev/null +++ b/src/shared/new-workspace/workspace-source.ts @@ -0,0 +1,195 @@ +import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links' +import type { FolderWorkspaceLinkedTask, LinearIssue } from '../types' +import { + getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName, + type WorkspaceIntentWorkItem +} from '../workspace-name' +import { isWorkItemLookupText } from './work-item-lookup-text' + +export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider'] + +export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & { + linearWorkspaceId?: string + linearOrganizationUrlKey?: string +} + +export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'github' + type: 'issue' | 'pr' +} + +export type GitLabWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'gitlab' + type: 'issue' | 'mr' +} + +export type LinearWorkspaceSource = WorkspaceSourceLinkedItem & { + provider: 'linear' + type: 'issue' +} + +export type WorkspaceSourceItemLike = Omit & { + provider?: WorkspaceSourceProvider +} + +export type WorkspaceSourceSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + | 'jira' + +export type WorkspaceSourceSelection = { + kind: WorkspaceSourceSelectionKind + label: string + url?: string +} + +const GITLAB_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i + +export function isGitLabIssueUrl(url: string): boolean { + try { + return GITLAB_ISSUE_PATH_RE.test(new URL(url).pathname) + } catch { + return GITLAB_ISSUE_PATH_RE.test(url) + } +} + +function isJiraIssueUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + /\.atlassian\.net$/i.test(parsed.hostname) || + /\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname) + ) + } catch { + return false + } +} + +export function getWorkspaceSourceProvider(item: WorkspaceSourceItemLike): WorkspaceSourceProvider { + if (item.provider) { + return item.provider + } + if (item.linearIdentifier) { + return 'linear' + } + if (item.jiraIdentifier || isJiraIssueUrl(item.url)) { + return 'jira' + } + if (item.type === 'mr' || isGitLabIssueUrl(item.url)) { + return 'gitlab' + } + if (item.number === 0 && !item.url.includes('github.com')) { + return 'linear' + } + return 'github' +} + +export function buildGitHubWorkspaceSource(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId?: string +}): GitHubWorkspaceSource { + return { provider: 'github', ...item } +} + +export function buildGitLabWorkspaceSource(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId?: string +}): GitLabWorkspaceSource { + return { provider: 'gitlab', ...item } +} + +export function buildLinearWorkspaceSource( + issue: Pick +): LinearWorkspaceSource { + const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + return { + provider: 'linear', + type: 'issue', + // Why: Linear uses a string identifier; numeric issue metadata must stay empty. + number: 0, + title: issue.title, + url: issue.url, + linearIdentifier: issue.identifier, + ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), + ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}) + } +} + +export function shouldApplyWorkspaceSourceAutoName(args: { + currentName: string + lastAutoName: string +}): boolean { + return ( + !args.currentName.trim() || + args.currentName === args.lastAutoName || + isWorkItemLookupText(args.currentName) + ) +} + +function toWorkspaceIntentItem(item: WorkspaceSourceItemLike): WorkspaceIntentWorkItem { + return { ...item, provider: getWorkspaceSourceProvider(item) } +} + +export function getWorkspaceSourceName(item: WorkspaceSourceItemLike): { + seedName: string + displayName: string +} { + const normalized = toWorkspaceIntentItem(item) + const resolved = getLinkedWorkItemWorkspaceName(normalized) + return { + seedName: resolved?.seedName ?? getLinkedWorkItemSuggestedName(normalized), + displayName: resolved?.displayName ?? item.title.trim() + } +} + +export function buildWorkspaceSourceSelection(args: { + linkedWorkItem: WorkspaceSourceItemLike | null + baseBranch?: string +}): WorkspaceSourceSelection | null { + const { linkedWorkItem, baseBranch } = args + if (!linkedWorkItem) { + return baseBranch ? { kind: 'branch', label: baseBranch } : null + } + const provider = getWorkspaceSourceProvider(linkedWorkItem) + const kind: WorkspaceSourceSelectionKind = + provider === 'linear' + ? 'linear' + : provider === 'jira' + ? 'jira' + : provider === 'gitlab' + ? linkedWorkItem.type === 'mr' + ? 'gitlab-mr' + : 'gitlab-issue' + : linkedWorkItem.type === 'pr' + ? 'github-pr' + : 'github-issue' + return { + kind, + label: + provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0 + ? linkedWorkItem.title + : `#${linkedWorkItem.number} ${linkedWorkItem.title}`, + url: linkedWorkItem.url + } +} + +export function shouldPreserveWorkspaceSourceOnRepoChange( + item: WorkspaceSourceItemLike | null +): boolean { + if (!item) { + return false + } + const provider = getWorkspaceSourceProvider(item) + return provider === 'linear' || provider === 'jira' +} diff --git a/src/shared/new-workspace/worktree-create-retry-policy.test.ts b/src/shared/new-workspace/worktree-create-retry-policy.test.ts new file mode 100644 index 00000000000..47cc5e79ee6 --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from './worktree-create-retry-policy' + +describe('client worktree create retry policy', () => { + it('uses the same suffix sequence for every client', () => { + expect(getClientWorktreeCreateCandidate('feature', 0)).toBe('feature') + expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2') + }) + + it('retries only known branch and review conflicts', () => { + expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Branch already has PR #42')).toBe(true) + expect(isRetryableWorktreeCreateConflict('Permission denied')).toBe(false) + }) +}) diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts new file mode 100644 index 00000000000..295dd3c7839 --- /dev/null +++ b/src/shared/new-workspace/worktree-create-retry-policy.ts @@ -0,0 +1,18 @@ +export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25 + +// Why: mixed-version runtimes can still return these legacy conflicts instead +// of performing their own suffix retry, so every client needs one policy. +const RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS = [ + /already exists locally/i, + /already exists on a remote/i, + /^Branch ".+" already exists\./i, + /already has pr #\d+/i +] + +export function getClientWorktreeCreateCandidate(value: string, attempt: number): string { + return attempt === 0 ? value : `${value}-${attempt + 1}` +} + +export function isRetryableWorktreeCreateConflict(message: string): boolean { + return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message)) +} diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts index 79b70c2d09a..8b9734c7786 100644 --- a/src/shared/protocol-compat.test.ts +++ b/src/shared/protocol-compat.test.ts @@ -45,6 +45,28 @@ describe('evaluateCompat', () => { expect(verdict).toEqual({ kind: 'ok' }) }) + it('allows desktop protocol 3 to roll out before mobile protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 2, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 3, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + + it('allows mobile protocol 3 to roll out before desktop protocol 2 updates', () => { + const verdict = evaluateCompat({ + mobileProtocolVersion: 3, + minCompatibleDesktopVersion: 2, + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + + expect(verdict).toEqual({ kind: 'ok' }) + }) + it('blocks with mobile-too-old when desktop requires a newer mobile', () => { const verdict = evaluateCompat({ mobileProtocolVersion: MOBILE_V,