orca/docs/create-from-start-field.md

29 KiB
Raw Blame History

Engineering Spec: "Start from" field in Create Workspace

Summary

Add a "Start from" field to the Create Workspace composer that lets the user pick a branch or PR as the basis for the new workspace. The picker is scoped to the selected repo; changing repo resets the field. This replaces no existing behavior: "Start from" defaults to the repo's effective base ref (repo.worktreeBaseRef when configured, otherwise the repo's detected default branch), so the current quick-create flow is unchanged.

This spec targets the shared composer flow, not just the modal wrapper. The quick-create modal and the full-page composer both consume NewWorkspaceComposerCard + useComposerState, so the new field/state should live there unless a piece is truly modal-only.

Issues are out of scope for this picker. Picking an issue does not change the base ref — it only sets linkedIssue, which the existing Link-work-item UI (useComposerState.ts:256260) already handles. Putting Issues in a picker called "Start from" creates a second entry point into the same state and misleads the user. Users who want to link an issue use the existing Link UI.

User-visible behavior

Selection Branch created from Workspace metadata
Branch (default or other) selected branch/ref
PR PR's same-repo head branch/ref linkedPR = #N

Important: Orca's create flow always creates a new worktree branch derived from the workspace name. "Start from PR" therefore means branch from the PR head, not "check out the PR branch directly".

Scope for v1: local repos support both picker tabs. Remote SSH repos support Branches only in this spec; PR start points stay disabled there until GitHub lookup can run without assuming a local cwd.

PR scope for v1: only PRs whose head branch lives in the selected repo are selectable. Fork PRs render disabled with copy like "Fork PRs aren't supported yet in Start from" because the current create flow cannot safely resolve a fork head from headRefName alone.

Repo ↔ Start from contradiction: the picker is repo-scoped. Changing repoId clears the prior selection and resets the field to the new repo's effective base ref. The field renders the reset inline (e.g. trigger reads "Default branch — was PR #8778") instead of a fading toast, so the state is recoverable visually and not missed by users focused on another field.

Naming: when the picker selects a PR and the Name field is still auto-managed (matches lastAutoNameRef.current, including empty), apply the existing auto-name behavior from getLinkedWorkItemSuggestedName() to the actual name state. Once the user edits the name, subsequent PR selections leave name alone. This matches the existing Link-UI rule exactly — one code path, not two.

Linked-work-item interaction: the picker writes into the same linkedWorkItem/linkedPR state that the Link UI owns. A PR selection is a linkedWorkItem assignment. Switching back to a branch leaves linkedWorkItem alone: the user changed the start ref, not the link. If the user wants to remove the link, they use the Link UI. No source-tagging, no parallel state, no "whose selection was it" bookkeeping.


Data model

No new shared type. The "Start from" picker is a UI affordance that writes into fields the system already has:

  • CreateWorktreeArgs.baseBranch (existing, src/shared/types.ts:461) — carries the resolved git ref the new worktree branches from.
  • WorktreeMeta.linkedPR (existing, src/shared/types.ts:56) — set on PR selection. useComposerState already owns linkedWorkItem + linkedPR state and already writes it via applyWorktreeMeta post-create (useComposerState.ts:880).

Every picker selection reduces to one of:

Picker selection baseBranch passed to create Linked metadata
Branch, local row short branch name (e.g. main)
Branch, remote-tracking row remote-qualified form from the row's full refname (e.g. origin/main, upstream/feat-x)
PR #N (same-repo head) <remote>/<headRefName> after main-process git fetch of that ref (see PR head resolution) linkedPR = N

Why full-ref precision without a new type: local and remote-tracking refs are not interchangeable. The picker emits the right short form to baseBranch — remote-tracking rows pass the <remote>/<name> form so the new branch tracks the remote ref instead of creating a detached HEAD. Classification at the picker must use the row's underlying full refname (refs/heads/… vs refs/remotes/<remote>/…), never prefix-matching a short name (a local branch literally named origin/foo is legal). The remote name comes from the row's full refname, not a hardcoded origin — repos can have upstream or other remotes.

Scope note on main-side remote derivation. createRemoteWorktree (worktree-remote.ts:96) and createLocalWorktree (worktree-remote.ts:243) currently derive the remote as baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'. For picker selections this is fine — remote-tracking rows and PR refs always contain a slash, so the derived remote is correct. Branch-picker rows without a slash are local heads, and the 'origin' fallback is unused. Non-picker code paths that pass slashless remote refs still hit the origin hardcode; fixing that is out of scope for this spec. The resolvePrBase resolver (below) must derive the push remote explicitly, since the PR head may live on upstream or another remote configured at the repo level.

PR head resolution lives in main, not the renderer. gh pr list returns headRefName as a short branch name that typically does not exist locally. Passing the bare headRefName as baseBranch will fail git worktree add in the common case. Git-ref resolution (git fetch, git rev-parse) belongs in main; the renderer does not shell out.

New IPC: worktrees:resolvePrBase

window.api.worktrees.resolvePrBase({
  repoId,
  prNumber,
  // Optional cache hints from the renderer's existing PR cache.
  // When both are present, main skips the `gh pr view` lookup.
  headRefName?: string,
  isCrossRepository?: boolean,
}) => Promise<{ baseBranch: string } | { error: string }>

On PR selection, the picker calls this resolver. Main:

  1. If both hints are present, skip GitHub lookup. Otherwise resolve the PR via the existing GitHub client to obtain headRefName + isCrossRepository.
  2. Reject fork PRs (isCrossRepository === true) with "Fork PRs aren't supported yet".
  3. Runs git fetch <remote> <headRefName> against the repo's default remote (see Default remote selection below).
  4. Verifies the fetched ref with git rev-parse --verify <remote>/<headRefName>.
  5. Returns { baseBranch: "<remote>/<headRefName>" } or a user-readable error.

Pre-submit resolution surfaces "branch deleted on remote" in the picker, not at create time. The resolved string is the one passed to CreateWorktreeArgs.baseBranch — no special kind: 'pr' at the IPC boundary.

Concurrency / stale resolves. A PR selection commits to baseBranch only after resolvePrBase succeeds. If the user selects a second PR before the first resolves, the first resolve's result is discarded (last-click-wins). Track this in the picker with a per-click token or AbortController; do not simply await sequentially, or late resolves will clobber newer selections.

Submit while resolve pending. If the user triggers create while a resolvePrBase is still in flight, the submit path waits for the pending resolve (or fails fast with a visible "Resolving PR head…" state). It must not submit with an unresolved baseBranch and it must not submit with the previous selection's baseBranch.

Default remote selection. "Default remote" is not "the one named origin." Resolve inside resolvePrBase in this order: (1) the remote configured on the repo's default branch (git config branch.<default>.remote); (2) origin if present; (3) the single remote if the repo has exactly one; (4) error otherwise (ask the user to configure). Centralize this in a helper in src/main/git/repo.ts rather than re-deriving per call site.

WSL repos. The existing main-process helpers route through isWslPath / parseWslPath (src/main/ipc/worktree-remote.ts:19). worktrees:resolvePrBase must use the same routing for its git fetch / rev-parse calls, not bare gitExecFileAsync on a raw path, or WSL repos will fail to resolve.

Draft restore. newWorkspaceDraft.baseBranch is persisted. On composer mount a restored baseBranch may reference a ref that no longer exists (PR closed, branch deleted since yesterday). Current main-process behavior will silently fall back to worktreeBaseRef / default branch rather than erroring — this is the same v1 hole tracked in Follow-up chores. Optional v1 nicety: run a cheap rev-parse --verify on mount (local repos only); if the ref is gone, clear baseBranch in the draft and show a one-line hint in the field ("Previous start ref no longer exists"). Don't block the user.

Accessibility. The popover must support keyboard-only operation: arrow keys within a tab, Tab / Shift+Tab between tabs, Enter to commit, Esc to close without committing. Reuse the existing Link-UI popover's keyboard hook rather than re-implementing.

GitHubWorkItem additions (src/shared/types.ts:390)

One new field is needed. GitHubWorkItem already carries branchName?: string (src/shared/types.ts:400), which src/main/github/client.ts:636 populates from headRefName for PR rows. Reuse branchName — do not introduce a second field meaning "PR head branch". Throughout this spec, reads of "the PR's head branch" on GitHubWorkItem refer to branchName.

isCrossRepository?: boolean // true = fork PR; disabled in picker

Verification: gh pr list in src/main/github/client.ts:280,401,610 currently requests number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName. Add headRepositoryOwner to the field list at all three sites; compute isCrossRepository in the mapper as item.headRepositoryOwner?.login !== <selected repo owner>. Mapper change lands at src/main/github/client.ts:636, where branchName is already set — this is the one place fork detection is computed.

Draft persistence (src/renderer/src/store/slices/ui.ts:64)

Extend newWorkspaceDraft with baseBranch?: string. Absence means "use the repo's effective base ref" — no null-plus-conversion step, shape matches CreateWorktreeArgs.baseBranch. linkedPR / linkedWorkItem are already persisted, so PR selections round-trip without further schema changes.

No new worktrees:create wire change. The existing contract already carries baseBranch.


Main-process changes

IPC handlers

  • src/main/ipc/worktrees.ts — wire the new worktrees:resolvePrBase handler (signature + algorithm defined in §Data model). Handler module may live adjacent if it grows.
  • src/main/ipc/worktree-remote.tsno changes. The existing || fallback chain is preserved as-is. The picker emits validated refs (branch rows from searchBaseRefs exist; PR refs are git fetched + rev-parsed inside resolvePrBase before the picker commits them to baseBranch), so in practice the picker never hands an unresolvable ref to create.

Base-ref resolution

No main-process branching on "kind". The renderer collapses every picker selection into a baseBranch string (and, for PR selections, linkedPR metadata). The existing args.baseBranch || repo.worktreeBaseRef || <detected default> chain in createLocalWorktree / createRemoteWorktree is unchanged.

Known limitation (acceptable for v1): if a picker-selected ref is deleted between selection and submit, create silently falls back instead of erroring. In practice this requires the remote branch to disappear in the seconds between picker commit and Create click — vanishingly rare. Tightening this into strict-when-explicit behavior is tracked as a follow-up chore (see Follow-up chores).

Metadata persistence

PR selections set linkedPR in the existing composer state. The existing post-create applyWorktreeMeta call (useComposerState.ts:880) already writes it — no new write path.

If metadata persistence fails after the git worktree already exists, log and continue. The worktree is still valid even if the link badge is missing.

GitHub data scope

  • local repos, PRs: existing gh:listWorkItems (cached — see caching rules below)
  • local repos, direct number lookup: existing gh:workItem (cached)
  • branches: existing repos:searchBaseRefs

No new GitHub IPC is required for local repos in v1.

Create-time validation

Unchanged. The picker pre-validates refs (branches via searchBaseRefs results, PR heads via resolvePrBase's fetch + rev-parse), so most bad paths are caught before submit. The one remaining hole — ref deleted between commit and create — falls through to today's silent fallback; see Follow-up chores.


Caching rules (must read)

PR searches and number lookups hit the user's gh CLI quota. The picker must ride on the existing SWR caches in src/renderer/src/store/slices/github.ts; it must not introduce a parallel fetch path.

Required behavior:

  • Always call fetchWorkItems(repoPath, limit, query, options?) — never window.api.gh.listWorkItems(...) directly. The store already deduplicates in-flight requests (inflightWorkItemsRequests) and applies WORK_ITEMS_CACHE_TTL. Direct calls bypass both.
  • Use the prefetch path to warm shared keys. The cache key is (repoPath, limit, query). The picker's query must match the prefetch query exactly or cache hits won't share. Use:
    • Prefetch on composer mount (local repo): prefetchWorkItems(repoPath, 36, 'is:pr is:open').
    • Picker PR tab default list: fetchWorkItems(repoPath, 36, 'is:pr is:open').
    • Picker PR tab user query: fetchWorkItems(repoPath, 36, \is:pr is:open ${userQuery}`)` — queries debounced ~150ms (matches existing Link-UI debounce) so rapid typing collapses to one fetch.
  • Render cached results synchronously while revalidating. Use getCachedWorkItems(...) for the first paint so opening the popover is instant and costs zero API calls when the cache is fresh.
  • Direct-number lookup (#123, full URL) uses gh:workItem via its cache. Same SWR contract; the picker reads prCache/issueCache synchronously first.
  • Do not prefetch on every keystroke. Only prefetch (a) on composer mount and (b) on popover open. Search queries go through the debounced fetchWorkItems, which dedupes against the cache anyway.
  • PR resolver (worktrees:resolvePrBase) must reuse the renderer-side PR cache when available. The renderer passes the already-known headRefName and isCrossRepository to the resolver (as an optional hint); main skips the gh pr view call when the hint is present. Only the git fetch + git rev-parse steps always run, since remote refs can change.
  • Branch search (repos:searchBaseRefs) is git-local and cheap; it does not count against GitHub quota, so fetch-on-demand when the Branches tab becomes active is acceptable. Debounce ~150ms to avoid redundant git for-each-ref invocations on large repos.

Do not call prefetchWorkItems(repoPath, 'is:open') — the second argument is limit, not query, and this would silently prefetch a different cache key than the picker reads.

Audit existing prefetch callers. ui.ts:195 already calls prefetchWorkItems(repo.path, 36, presetToQuery(preset)). Confirm during implementation that at least one active task preset produces 'is:pr is:open' (the exact string the picker queries) so the sidebar prefetch and the picker fetch share a cache key. If no preset matches exactly, add a dedicated mount-time prefetch in the composer and do not rely on the sidebar's opportunistic warming.

Cache invalidation. The existing SWR caches are keyed by (repoPath, limit, query) and expire via WORK_ITEMS_CACHE_TTL. The picker does not call force: true on every open — that defeats the cache. It only forces a refresh on explicit user action (e.g. a "Refresh" control in the popover, if added later). Stale-within-TTL is acceptable for picker use.


Renderer changes

1. New components

  • src/renderer/src/components/new-workspace/StartFromField.tsx
    • popover trigger (pill + title + chevron)
  • src/renderer/src/components/new-workspace/StartFromPicker.tsx
    • tabs: Branches · Pull requests
    • search input debounced ~150ms
    • PR tab calls worktrees:resolvePrBase on selection; shows an inline error on fetch/resolve failure before the user submits
    • on selection, calls back into useComposerState with { baseBranch, linkedWorkItem? } — no new shared type

2. Popover state coverage

Each tab must render these states explicitly:

Flow Loading Empty Error Success
Branches skeleton rows "No branches match" inline error list
Pull requests skeleton rows (only if no cached data) "No open PRs" "gh not available — Branches tab still works" list (cached first, revalidated in background)

Cached results must paint immediately; the loading state appears only when nothing is cached. This makes the common case a zero-API-cost open.

3. Integrate into shared composer state

Add baseBranch?: string state in src/renderer/src/hooks/useComposerState.ts (reusing the existing linkedWorkItem / linkedPR state for PR selections — don't introduce parallel startFrom state). The hook already owns:

  • repo selection
  • linkedWorkItem + linkedPR (see useComposerState.ts:209,223)
  • auto-name behavior via lastAutoNameRef (useComposerState.ts:262)
  • full-page draft persistence
  • submit / submitQuick, with post-create applyWorktreeMeta already writing linked metadata (useComposerState.ts:880)

The modal wrapper should stay thin. NewWorkspaceComposerModal.tsx continues to pass through cardProps to NewWorkspaceComposerCard, while the card gets new props for rendering the field.

4. Picker data sources

  • Branches tab:
    • window.api.repos.searchBaseRefs({ repoId, query })
  • PRs tab:
    • fetchWorkItems(repoPath, 36, 'is:pr is:open') for the default list
    • fetchWorkItems(repoPath, 36, \is:pr is:open ${userQuery}`)` for typed queries
    • getCachedWorkItems(...) for first paint

Use the selected repo object already derived in useComposerState; do not introduce a separate reposById dependency unless the store actually gains one.

Filter PR results to same-repo heads only (!isCrossRepository). Fork PRs render disabled with explanatory copy, not silently filtered, so the user understands why their PR isn't selectable.

Normalize PR queries before dispatching GitHub lookups. Route by shape:

  • bare number (123), #123, or a full GitHub PR URL for the selected repo → strip to the number and dispatch gh:workItem (reads prCache first per §Caching rules). getWorkItem returns type: 'pr' | 'issue'; when type !== 'pr', treat as no-match in the PR tab (number collides with an issue).
  • full GitHub PR URL for a different repo → silently fall back to free-text search. Do not hard-block; users paste URLs because they want the content.
  • anything else → pass through as a free-text query to fetchWorkItems(..., \is:pr is:open ${query}`)`.

5. Repo-change reset

On repo change:

  • reset baseBranch to undefined (so the field shows the new repo's effective base ref as placeholder)
  • clear any transient picker state tied to the previous repo
  • the field's trigger copy shows the reset inline (e.g. "Default branch — was PR #N") when a selection was cleared

The existing handleRepoChange callback (useComposerState.ts:819) already clears linkedIssue / linkedPR / linkedWorkItem inline; extend it to also clear baseBranch. One callback, not a new effect.

6. Naming behavior

When the picker selects a PR, it sets the existing linkedWorkItem state. The composer's existing auto-name path (which reacts to linkedWorkItem via getLinkedWorkItemSuggestedName and lastAutoNameRef) will update name iff name === '' || name === lastAutoNameRef.current — i.e. the name is still auto-managed. Once the user edits the name, subsequent selections leave it alone. This is the existing Link-UI rule; no new naming code.

7. Submission

Thread baseBranch through:

  • useComposerState submit paths (already constructs CreateWorktreeArgs — just add the field)
  • persisted newWorkspaceDraft
  • store createWorktree(...) → preload window.api.worktrees.create(...) → main CreateWorktreeArgs (field already exists)

linkedPR needs no new wiring — the post-create applyWorktreeMeta call already writes it.

8. Prefetch

On composer mount (local repo only), warm the PR cache:

prefetchWorkItems(repoPath, 36, 'is:pr is:open')

Do not call prefetchWorkItems(repoPath, 'is:open'); the second argument is limit, not query. Do not use a query string that differs from what the picker will fetch — mismatched keys produce a double fetch.

Branch results stay fetch-on-demand when the Branches tab becomes active.


Shortcut discoverability

Out of scope for this spec. A follow-up can add a split "+" button with CmdOrCtrl+Shift+N for a more explicit "Create from…" entry point.

Explicitly out of scope

  • Issues tab. Issues do not change the start ref; use the existing Link UI to link an issue.
  • Checking out an existing branch without -b. Orca's create flow always derives a new branch from the workspace name; "Start from PR" means branch from the PR head, not open the PR branch directly.
  • Fork PR start points. Disabled in v1.
  • SSH PR start points. Disabled in v1.

Edge cases

Case Behavior
User picks PR, then renames Name manually Manual name wins (existing lastAutoNameRef rule)
User picks PR, then picks a different PR without editing name Name updates to the new PR's suggestion
User picks PR from a fork picker disables it in v1; no create attempt
PR head branch has since been deleted at picker open picker surfaces resolve error before submit; create never attempted
PR head fetch fails (network/auth) picker surfaces the fetch error; selection does not commit
User picks branch, switches repo, switches back no cross-repo picker state is preserved
Offline / gh CLI missing PRs tab shows error state; Branches tab still works
Remote repo over SSH only Branches tab is enabled in v1; PRs tab disabled with explanatory copy
Repo has worktreeBaseRef set to non-default branch reset behavior uses that configured base ref
User pastes #123 or a full GitHub PR URL picker normalizes to the work item number and uses gh:workItem cache
Pasted number resolves to an issue, not a PR, in the PR tab treated as no-match; user sees empty-state copy
User pastes a PR URL for a different repo picker silently falls back to free-text search
Selected ref (branch or PR head) disappears between picker commit and create falls through to today's worktreeBaseRef / default-branch fallback (acceptable v1 hole; tracked in Follow-up chores)
Restored draft references a ref that no longer exists same silent fallback at create; optional v1 nicety clears the draft field with an inline hint on mount
Popover opened with fresh cache renders instantly from getCachedWorkItems; zero API calls
User selects PR, then quickly selects a different PR before first resolve returns last-click wins; stale resolve is discarded (AbortController / token)
User hits Create while resolvePrBase is still pending submit waits for the in-flight resolve; never submits with a stale baseBranch
User rapidly toggles between Branches and PRs tabs mid-fetch in-flight search requests for the prior tab are aborted; stale rows never render

Test plan

  • Unit / renderer
    • StartFromField renders the correct pill for each selection kind
    • repo change resets baseBranch and the field shows the reset inline
    • full-page draft persistence round-trips baseBranch (and existing linked-work-item fields)
    • PR selection updates the actual name state only while it remains auto-managed (name === '' || name === lastAutoNameRef.current)
    • PR selection mirrors into linked-work-item state; switching back to branch does not clear the link
    • SSH repos disable PR tab
    • cross-repo PRs are disabled with explanatory copy
    • #123 and full GitHub PR URLs normalize to number search
    • pasted cross-repo URLs fall back to free-text search (no hard error)
    • picker writes short name for local branch rows, <remote>/<name> for remote-tracking rows
    • PR selection calls worktrees:resolvePrBase and threads the resolved ref into baseBranch
  • Caching
    • opening the PR tab with a fresh cache triggers zero window.api.gh.listWorkItems calls (assert via spy)
    • prefetch on composer mount and picker default fetch share the same cache key ((repoPath, 36, 'is:pr is:open'))
    • rapid typing in the PR search debounces to a single fetch
    • direct-number lookup (#123) reads prCache/issueCache synchronously before hitting gh:workItem
    • worktrees:resolvePrBase skips gh pr view when the renderer passes a cached headRefName hint
    • rapid re-selection aborts prior resolvePrBase; only the latest selection's result commits
    • submit while resolvePrBase is pending waits for it; never submits a stale baseBranch
  • Main-process
    • worktrees:resolvePrBase fetches via the repo's default remote (not hardcoded origin), returns resolved ref on success
    • worktrees:resolvePrBase returns a user-readable error when the remote branch is missing or fetch fails
    • worktrees:resolvePrBase rejects fork PRs
    • isCrossRepository is populated on GitHubWorkItem PR rows from headRepositoryOwner
  • Manual
    • quick create without touching the field behaves exactly as today
    • create from same-repo PR creates a new branch from the PR head and shows the PR badge
    • SSH repo shows branch start points only
    • changing repo mid-flow resets the field and shows the inline reset copy
    • opening the picker a second time within the cache TTL makes zero network calls

Rollout

Single PR. No feature flag. The change is additive and backward-compatible — baseBranch is already optional on CreateWorktreeArgs, and linkedPR metadata is already written by existing code.

Files touched

src/shared/types.ts                                                  (add isCrossRepository to GitHubWorkItem; branchName already carries PR head)
src/main/github/client.ts                                            (add headRepositoryOwner to gh pr list field set; populate isCrossRepository)
src/main/ipc/worktrees.ts                                            (new worktrees:resolvePrBase handler)
src/main/git/repo.ts                                                 (default-remote helper used by resolvePrBase)
src/preload/index.ts                                                 (invoke worktrees:resolvePrBase)
src/preload/api-types.d.ts                                           (type worktrees.resolvePrBase)
src/renderer/src/hooks/useComposerState.ts                           (baseBranch state + repo-change reset)
src/renderer/src/components/NewWorkspaceComposerCard.tsx             (render the field)
src/renderer/src/components/new-workspace/StartFromField.tsx         (new: pill trigger)
src/renderer/src/components/new-workspace/StartFromPicker.tsx        (new: tabs + picker; SWR via fetchWorkItems/getCachedWorkItems)
src/renderer/src/store/slices/ui.ts                                  (baseBranch in newWorkspaceDraft)
src/renderer/src/lib/new-workspace.ts                                (if a small display helper is needed)

Estimated effort: ~1.5 engineering days — new worktrees:resolvePrBase IPC, isCrossRepository plumbing through the gh client and its mappers, picker + cancellation, URL normalization, prefetch key alignment, and renderer + main tests. Fully additive; no behavior change for existing flows.

Follow-up chores

File these as separate issues at ship time, not in this PR:

  • Strict-when-explicit base-ref validation. Today createLocalWorktree / createRemoteWorktree silently fall back when an explicit args.baseBranch is unresolvable. The Start-from picker avoids this in practice by pre-validating, but a ref deleted between selection and submit still falls through. Replace the || chain with strict-when-explicit / fallback-when-implicit; add SshGitProvider.verifyRef for the remote path. Standalone refactor, own test coverage, affects all callers of CreateWorktreeArgs.baseBranch.
  • Main-side default-remote derivation. createLocalWorktree / createRemoteWorktree derive remote as origin when baseBranch lacks a slash. Centralize default-remote resolution (push remote of default branch → origin → single remote) and share with resolvePrBase.