fix: address review findings (#1078)
- fix: address review findings - wip - wip
This commit is contained in:
parent
a2928e62be
commit
aa10811780
|
|
@ -0,0 +1,355 @@
|
|||
# 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:256–260`) 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`
|
||||
```ts
|
||||
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`.
|
||||
|
||||
```ts
|
||||
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.ts` — **no changes**. The existing `||` fallback chain is preserved as-is. The picker emits validated refs (branch rows from `searchBaseRefs` exist; PR refs are `git fetch`ed + `rev-parse`d 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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```text
|
||||
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`.
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
As Orca scales to support multiple parallel agents and tasks, users frequently need to switch between dozens of active worktrees. Navigating via the sidebar becomes inefficient at scale.
|
||||
|
||||
This document outlines the design for a "Quick Jump to Worktree" feature: a globally accessible Command Palette-style dialog that allows users to search across all their active worktrees by name, repository, comment, PR metadata, and issue metadata, and jump to them instantly. This feature is intended to be the central, beating heart of navigation within Orca.
|
||||
This document describes the shipped "Quick Jump" palette in Orca: a globally accessible Command Palette-style dialog that lets users jump across active worktrees, open browser tabs that live inside worktrees, and create a new worktree from typed input. Search covers worktree metadata (name, branch, repo, comment, PR metadata, issue metadata) and browser metadata (page title, URL, worktree, repo).
|
||||
|
||||
## 2. User Experience (UX)
|
||||
|
||||
|
|
@ -26,12 +26,13 @@ To establish this palette as the central "Switch Worktree" action in Orca, `**Cm
|
|||
When the shortcut is pressed, a modal dialog appears at the center top of the screen (similar to VS Code's palette or Spotlight).
|
||||
|
||||
- **Input:** A text input focused automatically.
|
||||
- **List:** A scrollable list of worktrees, constrained to `max-h-[min(400px,60vh)]` to prevent the palette from overflowing the viewport when many worktrees are present.
|
||||
- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown in recent-sort order. The data source is `worktreesByRepo`, filtered by `!w.isArchived` (same filter applied by `computeVisibleWorktreeIds` in `visible-worktrees.ts`). The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view. No truncation — the list is scrollable and the expected count (<200) does not require pagination.
|
||||
- **Sorting (Recent Semantics):** The palette **always** uses `recent` sort order regardless of the sidebar's current `sortBy` setting. Alphabetical or repo-grouped sort would be a poor default for a "jump to" palette — recency is what the user almost always wants. Internally, this means calling `buildWorktreeComparator` from `smart-sort.ts` with `sortBy: 'recent'`. This gives the same smart-sort signals as the sidebar in recent mode: active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recency (`lastActivityAt`), with the same cold-start fallback to persisted `sortOrder` until live PTY state is available (see the `!hasAnyLivePty` branch in `getVisibleWorktreeIds()`).
|
||||
- **List:** A scrollable list constrained to `max-h-[min(460px,62vh)]` to prevent the palette from overflowing the viewport when many results are present.
|
||||
- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown first, ordered by Orca's smart sort. If browser tabs also exist, they appear as a secondary section preview below the worktree list. The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view.
|
||||
- **Sorting (Smart Semantics):** The palette uses Orca's smart worktree ordering via `sortWorktreesSmart(...)`, not plain recency. In practice this prioritizes active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recent activity, with a cold-start fallback to persisted `sortOrder` until any PTY is live.
|
||||
- **Visual Hierarchy & Highlights:** Because search covers multiple fields simultaneously, the list items must visually clarify *why* a result matched. If the match is inside a comment, display a truncated snippet of that comment centered around the matched range, with the matching text highlighted.
|
||||
- **Multi-repo disambiguation:** Each list item always displays the repository name (e.g., `stablyai/orca`) alongside the worktree name. This is required because the palette spans all repos — without it, two worktrees named "main" from different repos would be indistinguishable.
|
||||
- **Empty State:** Two cases: (1) If the user has 0 non-archived worktrees, display "No active worktrees. Create one to get started." (2) If worktrees exist but none match the search query, display "No worktrees match your search." Both use `<Command.Empty>`.
|
||||
- **Cross-surface scope:** The palette is not limited to worktrees. It also surfaces browser tabs, and when the user types a string that matches no worktree results it offers a "Create worktree" action using the current query.
|
||||
- **Empty State:** Two cases: (1) If the user has no active worktrees and no browser tabs, display "No active worktrees or browser tabs". (2) If items exist but none match the search query, display "No results match your search." Both use `<Command.Empty>`.
|
||||
- **Search fields:** The search input will match against:
|
||||
- Worktree `displayName`
|
||||
- Worktree `branch`, normalized via `branchName()` to strip the `refs/heads/` prefix (e.g., `refs/heads/feature/auth-fix` → `feature/auth-fix`)
|
||||
|
|
@ -41,21 +42,22 @@ When the shortcut is pressed, a modal dialog appears at the center top of the sc
|
|||
- Linked issue number/title. The issue number comes from `w.linkedIssue`; the title comes from `issueCache` (cache key: `${repo.path}::${w.linkedIssue}`). Number matching works even without a cache hit; title matching requires the cache entry to be populated.
|
||||
- **Cache freshness caveat:** PR and issue data is populated by `refreshGitHubForWorktree`, which runs on worktree activation, and by `refreshAllGitHub`, which runs on window re-focus (`visibilitychange`). On startup, `initGitHubCache` loads previously persisted PR/issue data from disk, so worktrees fetched in prior sessions start with warm caches. Worktrees that have never been activated, were not covered by a `refreshAllGitHub` pass, and have no persisted cache entry will have empty caches — PR/issue title search will silently miss them. This is acceptable: the gap is limited to brand-new worktrees between creation and the next activation or window re-focus cycle. Number-based matching (e.g., `#304`) always works because it checks `w.linkedPR` / `w.linkedIssue` directly, without the cache.
|
||||
- `**#`-prefix handling:** A leading `#` in the query is stripped before matching PR/issue numbers (e.g., `#304` matches number `304`), with a guard against bare `#` which would produce an empty string and match everything. This mirrors the existing `matchesSearch()` behavior.
|
||||
- **Browser search fields:** Browser page title, URL/secondary text, worktree name, and repo name.
|
||||
- **Navigation:** `Up` / `Down` arrows to navigate the list, `Enter` to select. `Escape` closes the modal.
|
||||
|
||||
## 3. Technical Architecture
|
||||
|
||||
### 3.1 UI Components
|
||||
|
||||
Orca uses `shadcn/ui`. We will add the **Command** component, which wraps the `cmdk` library.
|
||||
Orca uses `shadcn/ui` and ships the **Command** component, which wraps the `cmdk` library.
|
||||
|
||||
**New dependency:** `cmdk` (~4KB gzipped) will be added as a direct dependency in `package.json`. It is already present in `node_modules` as a transitive dependency, but not directly importable.
|
||||
**Dependency:** `cmdk` is a direct dependency in `package.json`.
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest add command
|
||||
```
|
||||
|
||||
Note: `dialog.tsx` already exists in `src/renderer/src/components/ui/`. The shadcn `CommandDialog` uses Radix Dialog internally; verify it shares the same Radix instance to avoid duplicate bundles. If the installed `cmdk` version pins a different `@radix-ui/react-dialog` than the existing `dialog.tsx`, align `dialog.tsx` to the shadcn-installed version to prevent a double-bundled Radix.
|
||||
Note: `CommandDialog` uses Radix Dialog internally. Orca keeps this inside the shared `components/ui/command.tsx` wrapper so both palettes use the same dialog primitives and styling hooks.
|
||||
|
||||
**z-index:** The `CommandDialog` must use `z-50` or higher to reliably overlay the terminal and sidebar, consistent with `QuickOpen.tsx` which uses `z-50` on its fixed overlay container.
|
||||
|
||||
|
|
@ -64,49 +66,26 @@ Note: `dialog.tsx` already exists in `src/renderer/src/components/ui/`. The shad
|
|||
|
||||
### 3.2 Keyboard Shortcut
|
||||
|
||||
The shortcut follows the **same renderer-side `keydown` pattern** already used by `Cmd+P` (QuickOpen) and `Cmd+1–9` (worktree jump) in `App.tsx`.
|
||||
The shipped shortcut uses a **hybrid main-process + renderer architecture**:
|
||||
|
||||
The existing `onKeyDown` handler in `App.tsx` (inside a `useEffect`) has two zones: shortcuts registered **before** the `isEditableTarget` guard fire from any focus context including xterm.js and contentEditable elements; shortcuts **after** the guard only fire from non-editable targets. `Cmd+P` and `Cmd+1–9` are in the pre-guard zone. `Cmd+J` must also be placed there so it works when a terminal has focus — no main-process `before-input-event` interception is needed.
|
||||
1. The main window listens in `before-input-event` and resolves the chord through the shared window shortcut policy.
|
||||
2. On `toggleWorktreePalette`, the main process sends `ui:toggleWorktreePalette` to the renderer.
|
||||
3. The renderer toggles `activeModal === 'worktree-palette'` in `useIpcEvents.ts`.
|
||||
|
||||
**Implementation:** Add a new branch to the existing `onKeyDown` handler in `App.tsx`, before the `isEditableTarget` guard:
|
||||
This extra main-process hop is required because browser guests and other embedded Chromium surfaces can keep keyboard focus inside a guest `webContents`, bypassing the renderer's `window`-level `keydown` listener. A renderer-only implementation would fail from browser-tab focus.
|
||||
|
||||
```tsx
|
||||
// Cmd/Ctrl+J — toggle worktree jump palette
|
||||
if (mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j') {
|
||||
e.preventDefault()
|
||||
if (worktreePaletteVisible) {
|
||||
setWorktreePaletteVisible(false)
|
||||
} else {
|
||||
closeModal()
|
||||
setQuickOpenVisible(false)
|
||||
setWorktreePaletteVisible(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
```
|
||||
**Toggle semantics:** If the palette is already open, the shortcut closes it; otherwise it opens it. There is no `activeWorktreeId` or `activeView` guard, so the palette is available from settings, from landing states with no active worktree, and from browser focus.
|
||||
|
||||
**Toggle semantics:** If the palette is already open, `Cmd+J` closes it (matching the toggle behavior users expect from palette shortcuts). The overlay mutual-exclusion clearing (`closeModal`, `setQuickOpenVisible(false)`) only runs on open, not on close.
|
||||
|
||||
**No `activeWorktreeId` or `activeView` guard:** Unlike `Cmd+P` (which requires both `activeView !== 'settings'` and `activeWorktreeId !== null`), the palette has neither guard. Users should be able to open the palette even when no worktree is active (e.g., fresh session with repos but no worktree selected yet) or from the settings view. The escape/cancel path must handle `previousWorktreeId === null` gracefully — focus falls to the document body.
|
||||
|
||||
**Overlay mutual exclusion:** The codebase has three independent overlay state systems: `activeModal` (union type in `ui.ts`), `quickOpenVisible` (boolean in `editor.ts`), and the new `worktreePaletteVisible` (boolean in `ui.ts`). All three must be mutually exclusive — only one overlay can be open at a time. The mechanism:
|
||||
|
||||
1. `**Cmd+J` handler** (palette open): Before setting `worktreePaletteVisible(true)`, call `closeModal()` (dismisses any active modal) and `setQuickOpenVisible(false)` (dismisses QuickOpen).
|
||||
2. `**Cmd+P` handler** (QuickOpen open): Before setting `quickOpenVisible(true)`, call `setWorktreePaletteVisible(false)`. (It already calls `closeModal()` implicitly by not conflicting with the modal system.)
|
||||
3. `**openModal()` wrapper**: Extend `openModal` in `ui.ts` to also call `setWorktreePaletteVisible(false)` when opening a modal. This covers all modal-open paths (Cmd+N, delete confirmation, etc.) without requiring each callsite to know about the palette. `quickOpenVisible` lives in the editor slice, so `openModal` cannot directly clear it from within the UI slice. This is safe because of how QuickOpen's focus model works: QuickOpen auto-focuses its `<input>` on mount (via `requestAnimationFrame` in a `useEffect`), and `isEditableTarget` returns `true` for `<input>` elements. Therefore, all keyboard-triggered `openModal` paths (`Cmd+N`, etc.) that are gated behind `isEditableTarget` will not fire while QuickOpen has focus. Mouse-triggered `openModal` paths (e.g., `WorktreeCard` double-click calling `openModal('edit-meta')`) fire on the sidebar, which is visually behind the QuickOpen overlay — the click would first dismiss QuickOpen via its backdrop `onClick` handler, closing it before the modal opens.
|
||||
|
||||
This prevents z-index stacking and confusing multi-overlay states.
|
||||
|
||||
**Tech debt note:** Three independent overlay state systems (`activeModal`, `quickOpenVisible`, `worktreePaletteVisible`) is O(n²) in the number of overlay types — every new overlay must know about all others. A follow-up issue should be filed to unify them into a single `activeOverlay` union type, but this is out of scope for the current feature.
|
||||
**Overlay mutual exclusion:** The current app models both Quick Open and the worktree palette inside the existing `activeModal` union in `ui.ts` (`'quick-open'` and `'worktree-palette'`). This keeps the two command palettes mutually exclusive without needing separate booleans.
|
||||
|
||||
**Menu registration:** Register a `View -> Open Worktree Palette` entry in `register-app-menu.ts` for discoverability, consistent with Section 2.1. The entry must use a **display-only shortcut hint** — do **not** set `accelerator: 'CmdOrCtrl+J'`. In Electron, menu accelerators intercept key events at the main-process level *before* the renderer's `keydown` handler fires (this is how `CmdOrCtrl+,` for Settings works — its `click` handler runs in the main process via `onOpenSettings`). If `CmdOrCtrl+J` were registered as a real accelerator, the renderer `keydown` handler would never see the event, and the overlay mutual-exclusion logic (which runs in the renderer) would be bypassed. Instead, show the shortcut text in the menu label (e.g., `label: 'Open Worktree Palette\tCmdOrCtrl+J'`) without binding `accelerator`, matching the pattern used by `Cmd+P` (QuickOpen), which has no menu entry at all and relies solely on the renderer handler.
|
||||
|
||||
### 3.3 State Management
|
||||
|
||||
- **Visibility state:** Add `worktreePaletteVisible: boolean` and `setWorktreePaletteVisible: (v: boolean) => void` to the UI slice (`store/slices/ui.ts`). Note: the existing `quickOpenVisible` lives in the editor slice, not UI. The palette visibility belongs in UI because it is a global navigation concern, not editor-specific state.
|
||||
- **Visibility state:** The palette is represented by `activeModal === 'worktree-palette'` in the UI slice. Quick Open similarly uses `activeModal === 'quick-open'`.
|
||||
- **Palette session state:** `query` and `selectedIndex` are ephemeral to the palette component and should live in React component state (not Zustand). They reset on every open.
|
||||
- **Render optimization:** When `worktreePaletteVisible === false`, the `<CommandDialog>` should not render its children. The shadcn `CommandDialog` unmounts content when `open={false}` by default, which is sufficient.
|
||||
- **Recent-sort ordering:** Always use `recent` sort regardless of the sidebar's `sortBy` setting. The cold/warm branching logic currently lives in the fallback path of `getVisibleWorktreeIds()` in `visible-worktrees.ts`: it checks `hasAnyLivePty` from `tabsByWorktree`, and if cold-start (no live PTYs yet), falls back to persisted `sortOrder` descending with alphabetical `displayName` fallback; otherwise it calls `buildWorktreeComparator('recent', ...)`. Note: `getVisibleWorktreeIds()` is only the Cmd+1–9 fallback — the primary sidebar sort happens inside `WorktreeList`'s render pipeline via `sortEpoch`. To avoid duplicating the cold/warm branching in the palette, extract a `sortWorktreesRecent(worktrees, tabsByWorktree, repoMap, prCache)` helper in `smart-sort.ts` that encapsulates the cold/warm detection and returns the sorted array. Both the `getVisibleWorktreeIds()` fallback path and the palette import this shared helper.
|
||||
- **Render optimization:** When the modal is closed, `CommandDialog` unmounts its content, which is sufficient.
|
||||
- **Ordering:** Worktree results are fed through `sortWorktreesSmart(...)`, and browser results are ordered relative to that same worktree ordering so both sections feel consistent.
|
||||
|
||||
### 3.4 Data Layer & Search
|
||||
|
||||
|
|
@ -118,7 +97,7 @@ The palette needs access to all worktrees known to Orca.
|
|||
|
||||
The sidebar already has a `matchesSearch()` function in `worktree-list-groups.ts` that does **substring matching** (`includes(q)`) against displayName, branch, repo, comment, PR, and issue fields. The palette search builds on this foundation but extends it. Note: `branchName()` (used to strip `refs/heads/` prefixes) is currently exported from `worktree-list-groups.ts` — a sidebar-specific module that imports Lucide icons (`CircleCheckBig`, `CircleDot`, etc.) at the top level. Importing `branchName` from it would pull the entire module (including unused icon components) into the palette's bundle. `smart-sort.ts` has its own duplicate: `branchDisplayName()` doing the identical `branch.replace(/^refs\/heads\//, '')`. Extract `branchName()` to a shared utility (`lib/git-utils.ts`) in Phase 1, and update `worktree-list-groups.ts` and `smart-sort.ts` to import from there. This is a 3-line function — the extraction is trivial and avoids the bundle bloat.
|
||||
|
||||
1. **Matching strategy: substring, not fuzzy.** Use the same case-insensitive substring matching as `matchesSearch()`. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not appropriate here — worktree names and comments are short enough that substring search provides good recall without false positives.
|
||||
1. **Matching strategy: substring, not fuzzy.** Use case-insensitive substring matching for worktrees and browser entries. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not used here.
|
||||
2. **Structured match metadata:** Unlike `matchesSearch()` (which returns `boolean`), the palette search helper returns a result object:
|
||||
|
||||
```ts
|
||||
|
|
@ -162,7 +141,7 @@ type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField
|
|||
</Command.Item>
|
||||
```
|
||||
|
||||
6. **Performance:** Keep `value` compact (`worktree.id`) and do not stuff full comments into `keywords`. For the expected worktree count (<200), synchronous filtering on every keystroke is fast enough — no debounce is needed. If worktree counts exceed 500 or filter times exceed 16ms (one frame), add list virtualization via `@tanstack/react-virtual` (already a project dependency). The search contract (`PaletteMatch[]` in, `<Command.Item>` out) does not change either way.
|
||||
6. **Performance:** Keep `value` compact and do not stuff full comments into `keywords`. The current implementation debounces the query by 150ms before recomputing result sets. That keeps mixed worktree + browser searching cheap without materially hurting responsiveness at Orca's current scale.
|
||||
|
||||
### 3.5 Action (Worktree Activation)
|
||||
|
||||
|
|
@ -189,7 +168,7 @@ The palette should match what `Cmd+1–9` does today (the closest analog: jumpin
|
|||
1. **Set `activeRepoId`:** If the target worktree's `repoId` differs from the current `activeRepoId`, call `setActiveRepo(repoId)`. This keeps session persistence and the "Create Worktree" repo pre-selection accurate. Sidebar clicks skip this because they operate within a single repo group; the palette does not have that constraint.
|
||||
2. **Switch `activeView`:** If `activeView` is `'settings'`, set it to `'terminal'` so the main content area renders the worktree surface. `Cmd+1–9` does not handle this because it refuses to fire at all from the settings view (gated on `activeView !== 'settings'` in the `onKeyDown` handler); the palette intentionally has no such guard so users can jump to a worktree directly from settings.
|
||||
3. **Call `setActiveWorktree(worktreeId)`:** This runs Orca's existing activation sequence: sets `activeWorktreeId`, restores per-worktree editor state (`activeFileId`, `activeTabType`, `activeBrowserTabId`), restores the last-active terminal tab, clears unread state, bumps dead PTY generations, and triggers `refreshGitHubForWorktree` to ensure PR/issue/checks data is current for the newly active worktree.
|
||||
4. **Ensure a focusable surface:** If the worktree has no terminal tabs (i.e., `tabsByWorktree[worktreeId]` is empty), call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). This handles worktrees that were created externally (e.g., via CLI or IPC push) and never opened in the UI. The function already no-ops when tabs exist, so the guard is `existingTabs.length > 0` inside the function itself.
|
||||
4. **Ensure a focusable surface:** If the worktree has no renderable tabs, call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). The helper now decides this via the reconciled tab model, not by checking whether the legacy terminal-tab array is empty.
|
||||
5. **Reveal in sidebar:** Call `revealWorktreeInSidebar(worktreeId)` to ensure the selected worktree is visible (handles collapsed groups and scroll position).
|
||||
6. **Close the palette.**
|
||||
|
||||
|
|
@ -210,12 +189,11 @@ Callsite-specific extras that remain inline after calling the shared helper:
|
|||
|
||||
The helper derives `repoId` internally via `findWorktreeById(worktreesByRepo, worktreeId)` (`worktree-helpers.ts:45`) — the caller only passes `worktreeId`. If the worktree is not found (e.g., deleted between palette open and select), the helper returns early without side effects.
|
||||
|
||||
#### Focus management (v1 — simple strategy)
|
||||
#### Focus management
|
||||
|
||||
- **On select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active surface (terminal xterm instance or Monaco editor) for the target worktree. The first rAF waits for React to commit the state change (palette closes); the second waits for the target worktree's surface layout to settle after Radix Dialog unmounts. Use `onCloseAutoFocus` on the `CommandDialog` with `e.preventDefault()` to prevent Radix from stealing focus to the trigger element. **Fragility note:** the double-rAF is a pragmatic v1 choice — it assumes Radix unmounts within two frames, which depends on the CSS transition duration and reduced-motion settings. If this proves unreliable, replace with a short `setTimeout` matching the actual animation duration or listen for the dialog's `onAnimationEnd`.
|
||||
- **On escape:** Same double-rAF approach, but focus the active surface for the *current* worktree (the one that was active before the palette opened). Track `previousWorktreeId` as a ref inside the component. If `previousWorktreeId` is `null` (no worktree was active when the palette opened), skip the focus call — focus falls to the document body.
|
||||
- **Degradation:** If the target surface is not mounted in time (e.g., cold worktree that was created externally and has never been opened — its terminal is still spawning after `ensureWorktreeHasInitialTerminal`), the focus call silently no-ops and focus falls to the document body. The user can click to focus. This is the **common case for externally-created worktrees**, not just a rare edge case — but it is acceptable for v1 because the worktree content still renders correctly; only auto-focus is lost.
|
||||
- **Future improvement:** A full `focusReturnTarget` system that records the exact xterm/editor/UI element and a `pendingFocus` state for async mount scenarios. This is deferred because the codebase has no existing focus-tracking infrastructure and the simple strategy covers the common case.
|
||||
- **On worktree select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active terminal/editor surface for the target worktree. `onCloseAutoFocus` calls `preventDefault()` so Radix does not steal focus.
|
||||
- **On browser-tab select:** Restore focus to the selected browser page, preferring the address bar for blank/new pages and the webview for loaded pages.
|
||||
- **On escape/cancel:** Restore focus to the previously active browser page when the palette was opened from browser context; otherwise fall back to the terminal/editor surface for the previously active worktree. If no worktree was active, focus falls to the document body.
|
||||
|
||||
### 3.6 Accessibility
|
||||
|
||||
|
|
@ -231,42 +209,29 @@ The `cmdk` library provides built-in ARIA support:
|
|||
- Announce filtered result count changes to screen readers via an `aria-live="polite"` region (e.g., "3 worktrees found").
|
||||
- Match-field badges (e.g., `Branch`, `Comment`) should include `aria-label` text so screen readers convey why the result matched.
|
||||
|
||||
## 4. Implementation Phases
|
||||
## 4. Implementation Status
|
||||
|
||||
**Phase 1: Component, Shortcut & Data**
|
||||
The core design is implemented:
|
||||
|
||||
- Add `cmdk` via `pnpm dlx shadcn@latest add command`.
|
||||
- Extract `branchName()` to `lib/git-utils.ts`; update imports in `worktree-list-groups.ts` and `smart-sort.ts` (consolidating the duplicate `branchDisplayName()`).
|
||||
- Extract `sortWorktreesRecent()` helper in `smart-sort.ts` (encapsulates cold/warm branching from `getVisibleWorktreeIds()`); update `getVisibleWorktreeIds()` to use it.
|
||||
- Create `WorktreeJumpPalette.tsx`, mount in `App.tsx`.
|
||||
- Add `worktreePaletteVisible` to the UI slice.
|
||||
- Add `Cmd/Ctrl+J` toggle handler to the existing `onKeyDown` in `App.tsx`.
|
||||
- Wire real worktree data from `worktreesByRepo` (filtered by `!isArchived`) with sidebar-consistent recent ordering and both empty states (no worktrees / no search results).
|
||||
- Handle startup race: if `worktreesByRepo` is empty but repos exist (data still loading), show a "Loading worktrees..." state instead of the misleading "No active worktrees" empty state. Guard: `Object.keys(worktreesByRepo).length === 0 && repos.length > 0`. Note: `worktreesByRepo` is populated per-repo as individual `fetchWorktrees` calls complete, so once any repo's worktrees arrive, the guard flips to showing partial results — this is intentional (partial results are more useful than a spinner) but means the list may grow incrementally during the first few seconds after launch.
|
||||
- Define and implement the search result model: `PaletteMatch` with matched field, character ranges, and comment snippet extraction.
|
||||
- Render with `shouldFilter={false}` and the manual search helper.
|
||||
- Visual baseline: follow shadcn `CommandDialog` defaults. Use the same palette width as `QuickOpen.tsx` (`w-[660px] max-w-[90vw]`). Item rows show worktree name, repo label, and a muted match-field badge. Active/highlighted item uses `bg-accent`. Detailed visual polish (match highlighting, snippet rendering) is deferred to Phase 3.
|
||||
- `cmdk` is a direct dependency and Orca ships a shared `CommandDialog`.
|
||||
- `WorktreeJumpPalette.tsx` is mounted at the app root.
|
||||
- The palette opens through main-process shortcut forwarding plus renderer IPC toggle handling.
|
||||
- Worktree activation is routed through `activateAndRevealWorktree(...)`.
|
||||
- Search supports comment snippets, PR/issue metadata, browser pages, and a create-worktree action.
|
||||
- Menu discoverability is implemented with a display-only `View -> Open Worktree Palette` hint and no accelerator binding.
|
||||
|
||||
**Phase 2: Activation & Focus**
|
||||
## 5. Remaining Gaps / Future Work
|
||||
|
||||
- Extract `activateAndRevealWorktree` shared helper in `worktree-activation.ts` per Section 3.5.
|
||||
- Wire the palette to use the shared helper. Refactor `AddRepoDialog` and `AddWorktreeDialog` to use it as well.
|
||||
- Defensive select handler: before activating, verify the target worktree still exists in `worktreesByRepo`. If deleted between palette open and selection, show a toast and no-op instead of setting `activeWorktreeId` to a stale ID.
|
||||
- Implement v1 focus management (`requestAnimationFrame` + `onCloseAutoFocus` prevention).
|
||||
- Handle escape/cancel with `previousWorktreeId` ref.
|
||||
- Register display-only `View -> Open Worktree Palette` menu entry (shortcut hint in label, no `accelerator` binding) per Section 3.2.
|
||||
|
||||
**Phase 3: Polish**
|
||||
|
||||
- Accessibility: `aria-live` result count announcements, badge `aria-label` text.
|
||||
- Visual polish: match highlighting, comment snippet rendering, field badges.
|
||||
- Add broader integration coverage for the mixed worktree/browser palette behavior; current tests focus on search helper behavior and shortcut/menu plumbing.
|
||||
- Evaluate whether the 150ms debounce should become adaptive if the palette eventually indexes substantially more browser pages.
|
||||
- Consider unifying the worktree and browser result models further if future result types are added.
|
||||
|
||||
**Future work (out of scope)**
|
||||
|
||||
- Evaluate migrating `QuickOpen.tsx` (currently a custom overlay with manual keyboard handling) to `cmdk`/`CommandDialog` for visual and behavioral consistency with the palette. This is a separate project — `QuickOpen` has its own fuzzy matching, file-loading, and keyboard handling that would need reworking.
|
||||
- Unify the three overlay state systems (`activeModal`, `quickOpenVisible`, `worktreePaletteVisible`) into a single `activeOverlay` union type (see tech debt note in Section 3.2).
|
||||
- Add richer end-to-end coverage for palette interactions launched from browser focus, including focus restoration after browser-tab selection and dismissal.
|
||||
|
||||
## 5. Alternatives Considered
|
||||
## 6. Alternatives Considered
|
||||
|
||||
- `**Cmd+O` (Open):** Standard app semantic, but less honest for this feature because the palette switches between existing worktrees rather than opening a new file or workspace. Rejected in favor of `Cmd+J`, which better matches the action users are taking.
|
||||
- `**Ctrl+E` (Explore):** Initially considered for Windows/Linux. Rejected because `Ctrl+E` is "end of line" in bash/zsh readline — stealing it in a terminal-heavy app breaks shell navigation muscle memory.
|
||||
|
|
@ -274,5 +239,4 @@ The `cmdk` library provides built-in ARIA support:
|
|||
- `**Cmd+1...9` (Direct jumping):** Doesn't scale past 9 worktrees and requires the user to memorize sidebar positions. Already implemented as a complementary feature.
|
||||
- `**Cmd+K`:** Rejected due to conflict with "Clear Terminal".
|
||||
- `**Cmd+P`:** Rejected because it is already used for file searching (`QuickOpen.tsx`).
|
||||
- **Main-process `before-input-event` interception:** Initially proposed for the keyboard shortcut to bypass xterm focus. Rejected because the existing renderer-side `keydown` handler (used by `Cmd+P`, `Cmd+1–9`, etc.) already fires before the `isEditableTarget` guard and works from terminal focus. Adding main-process interception would require a new IPC channel and multi-window targeting logic for no benefit.
|
||||
|
||||
- **Renderer-only shortcut handling:** Initially attractive because it mirrors simpler shortcuts, but rejected for the shipped palette. Browser guests can keep keyboard focus inside a separate `webContents`, so a renderer-only `window` listener would miss `Cmd+J` / `Ctrl+Shift+J` from browser-tab focus.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { join, basename } from 'path'
|
||||
|
|
@ -222,46 +223,104 @@ async function getDefaultBaseRefAsync(path: string): Promise<string | null> {
|
|||
return null
|
||||
}
|
||||
|
||||
export async function searchBaseRefs(path: string, query: string, limit = 25): Promise<string[]> {
|
||||
const normalizedQuery = normalizeRefSearchQuery(query)
|
||||
if (!normalizedQuery) {
|
||||
return []
|
||||
/**
|
||||
* Resolve the default push remote for a repo.
|
||||
* Order: remote configured on the current default branch → origin → the single
|
||||
* remote when the repo has exactly one → error.
|
||||
*/
|
||||
export async function getDefaultRemote(path: string): Promise<string> {
|
||||
const defaultRef = await getDefaultBaseRefAsync(path)
|
||||
// Why: getDefaultBaseRefAsync returns null when no default branch can be
|
||||
// detected (e.g. a brand-new repo with no commits on origin). Guard so we
|
||||
// don't crash on .includes(); fall through to the remote-list heuristics.
|
||||
const defaultBranch = defaultRef
|
||||
? defaultRef.includes('/')
|
||||
? defaultRef.split('/').slice(1).join('/')
|
||||
: defaultRef
|
||||
: null
|
||||
|
||||
if (defaultBranch) {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['config', '--get', `branch.${defaultBranch}.remote`],
|
||||
{ cwd: path }
|
||||
)
|
||||
const value = stdout.trim()
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
} catch {
|
||||
// Fall through: branch has no explicit remote configured.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
[
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)',
|
||||
'--sort=-committerdate',
|
||||
`refs/remotes/origin/*${normalizedQuery}*`,
|
||||
`refs/heads/*${normalizedQuery}*`
|
||||
],
|
||||
{ cwd: path }
|
||||
)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const refs = stdout
|
||||
const { stdout } = await gitExecFileAsync(['remote'], { cwd: path })
|
||||
const remotes = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line !== 'origin/HEAD')
|
||||
.filter((line) => {
|
||||
if (seen.has(line)) {
|
||||
return false
|
||||
}
|
||||
seen.add(line)
|
||||
return true
|
||||
})
|
||||
.slice(0, Math.max(1, limit))
|
||||
|
||||
return refs
|
||||
} catch {
|
||||
return []
|
||||
.filter(Boolean)
|
||||
if (remotes.includes('origin')) {
|
||||
return 'origin'
|
||||
}
|
||||
if (remotes.length === 1) {
|
||||
return remotes[0]
|
||||
}
|
||||
if (remotes.length === 0) {
|
||||
throw new Error('Repo has no configured git remotes.')
|
||||
}
|
||||
throw new Error(
|
||||
`Repo has multiple remotes (${remotes.join(', ')}) and no default is configured. Set branch.<default>.remote.`
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Failed to resolve default remote for repo.')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRefSearchQuery(query: string): string {
|
||||
return query.trim().replace(/[*?[\]\\]/g, '')
|
||||
export const BASE_REF_SEARCH_ARGS = [
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)',
|
||||
'--sort=-committerdate',
|
||||
'refs/remotes/origin/',
|
||||
'refs/heads/'
|
||||
]
|
||||
|
||||
/**
|
||||
* Filter the raw `for-each-ref` stdout produced by BASE_REF_SEARCH_ARGS
|
||||
* down to a deduped, limited list of refs that substring-match `query`.
|
||||
*
|
||||
* Why: `for-each-ref` pattern globs are prefix-matched per path segment,
|
||||
* not free-form substring globs — `refs/heads/*foo*` does not match
|
||||
* `refs/heads/FooBar`. So we list all branch refs and filter in JS.
|
||||
*/
|
||||
export function filterBaseRefSearchOutput(stdout: string, query: string, limit: number): string[] {
|
||||
const needle = query.trim().toLowerCase()
|
||||
const seen = new Set<string>()
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line !== 'origin/HEAD')
|
||||
.filter((line) => (needle ? line.toLowerCase().includes(needle) : true))
|
||||
.filter((line) => {
|
||||
if (seen.has(line)) {
|
||||
return false
|
||||
}
|
||||
seen.add(line)
|
||||
return true
|
||||
})
|
||||
.slice(0, Math.max(1, limit))
|
||||
}
|
||||
|
||||
export async function searchBaseRefs(path: string, query: string, limit = 25): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(BASE_REF_SEARCH_ARGS, { cwd: path })
|
||||
return filterBaseRefSearchOutput(stdout, query, limit)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function hasGitRefAsync(path: string, ref: string): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ describe('listWorkItems', () => {
|
|||
'--limit',
|
||||
'10',
|
||||
'--json',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--assignee',
|
||||
|
|
@ -163,7 +163,7 @@ describe('listWorkItems', () => {
|
|||
'--limit',
|
||||
'10',
|
||||
'--json',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
|
|
@ -261,7 +261,7 @@ describe('listWorkItems', () => {
|
|||
'--limit',
|
||||
'10',
|
||||
'--json',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner',
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
|
|
|
|||
|
|
@ -131,7 +131,44 @@ function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
|
|||
}
|
||||
}
|
||||
|
||||
function mapPullRequestWorkItem(item: Record<string, unknown>): MainWorkItem {
|
||||
function extractHeadOwnerLogin(item: Record<string, unknown>): string | null {
|
||||
// gh CLI `pr list --json headRepositoryOwner` shape: { login }
|
||||
if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) {
|
||||
const login = (item.headRepositoryOwner as { login?: unknown }).login
|
||||
if (typeof login === 'string' && login.trim()) {
|
||||
return login
|
||||
}
|
||||
}
|
||||
// REST API `pull_request` shape: head.repo.owner.login
|
||||
if (typeof item.head === 'object' && item.head !== null) {
|
||||
const repo = (item.head as { repo?: unknown }).repo
|
||||
if (typeof repo === 'object' && repo !== null) {
|
||||
const owner = (repo as { owner?: unknown }).owner
|
||||
if (typeof owner === 'object' && owner !== null) {
|
||||
const login = (owner as { login?: unknown }).login
|
||||
if (typeof login === 'string' && login.trim()) {
|
||||
return login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapPullRequestWorkItem(
|
||||
item: Record<string, unknown>,
|
||||
baseOwnerLogin: string | null = null
|
||||
): MainWorkItem {
|
||||
// Why: fork PRs are disabled in the Start-from picker. We compare the PR head's
|
||||
// owner to the selected repo's owner; when baseOwnerLogin is unknown we default
|
||||
// to false so non-picker call sites see the same shape as before.
|
||||
const headOwnerLogin = extractHeadOwnerLogin(item)
|
||||
// Why: only emit isCrossRepository when we actually know the head owner. If
|
||||
// the gh response lacks `headRepositoryOwner` (older callers, tests without
|
||||
// that fixture, or gh not returning it), leave the field undefined instead
|
||||
// of falsely claiming "not a fork".
|
||||
const isCrossRepository =
|
||||
headOwnerLogin !== null && baseOwnerLogin !== null ? headOwnerLogin !== baseOwnerLogin : null
|
||||
return {
|
||||
id: `pr:${String(item.number)}`,
|
||||
type: 'pr',
|
||||
|
|
@ -169,7 +206,8 @@ function mapPullRequestWorkItem(item: Record<string, unknown>): MainWorkItem {
|
|||
baseRefName:
|
||||
typeof item.base === 'object' && item.base !== null && 'ref' in item.base
|
||||
? String((item.base as { ref?: unknown }).ref ?? '')
|
||||
: String(item.baseRefName ?? '')
|
||||
: String(item.baseRefName ?? ''),
|
||||
...(isCrossRepository !== null ? { isCrossRepository } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +221,7 @@ function buildWorkItemListArgs(args: {
|
|||
const fields =
|
||||
kind === 'issue'
|
||||
? 'number,title,state,url,labels,updatedAt,author'
|
||||
: 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
|
||||
: 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner'
|
||||
const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list']
|
||||
const out = [...command, '--limit', String(limit), '--json', fields]
|
||||
|
||||
|
|
@ -274,8 +312,8 @@ async function listRecentWorkItems(
|
|||
.filter((item) => !('pull_request' in item))
|
||||
.map(mapIssueWorkItem)
|
||||
|
||||
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map(
|
||||
mapPullRequestWorkItem
|
||||
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map((item) =>
|
||||
mapPullRequestWorkItem(item, ownerRepo.owner)
|
||||
)
|
||||
|
||||
return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
|
||||
|
|
@ -304,7 +342,7 @@ async function listRecentWorkItems(
|
|||
'--state',
|
||||
'open',
|
||||
'--json',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
|
@ -313,8 +351,8 @@ async function listRecentWorkItems(
|
|||
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[]).map(
|
||||
mapIssueWorkItem
|
||||
)
|
||||
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map(
|
||||
mapPullRequestWorkItem
|
||||
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map((item) =>
|
||||
mapPullRequestWorkItem(item, null)
|
||||
)
|
||||
|
||||
return sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
|
||||
|
|
@ -350,7 +388,9 @@ async function listQueriedWorkItems(
|
|||
const args = buildWorkItemListArgs({ kind: 'pr', ownerRepo, limit, query })
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(args, { cwd: repoPath })
|
||||
return (JSON.parse(stdout) as Record<string, unknown>[]).map(mapPullRequestWorkItem)
|
||||
return (JSON.parse(stdout) as Record<string, unknown>[]).map((item) =>
|
||||
mapPullRequestWorkItem(item, ownerRepo?.owner ?? null)
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
@ -408,6 +448,7 @@ export async function getWorkItem(repoPath: string, number: number): Promise<Mai
|
|||
{ cwd: repoPath }
|
||||
)
|
||||
const pr = JSON.parse(prResult.stdout) as Record<string, unknown>
|
||||
const prHeadOwner = extractHeadOwnerLogin(pr)
|
||||
return {
|
||||
id: `pr:${String(pr.number)}`,
|
||||
type: 'pr',
|
||||
|
|
@ -443,7 +484,11 @@ export async function getWorkItem(repoPath: string, number: number): Promise<Mai
|
|||
baseRefName:
|
||||
typeof pr.base === 'object' && pr.base !== null && 'ref' in pr.base
|
||||
? String((pr.base as { ref?: unknown }).ref ?? '')
|
||||
: undefined
|
||||
: undefined,
|
||||
// Why: only emit isCrossRepository when we actually know the head
|
||||
// owner. Falsely claiming "not a fork" would let the picker try a
|
||||
// normal-PR fetch against a fork head and fail.
|
||||
...(prHeadOwner !== null ? { isCrossRepository: prHeadOwner !== ownerRepo.owner } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +557,7 @@ export async function getWorkItem(repoPath: string, number: number): Promise<Mai
|
|||
'view',
|
||||
String(number),
|
||||
'--json',
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName'
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner'
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
|
@ -540,6 +585,10 @@ export async function getWorkItem(repoPath: string, number: number): Promise<Mai
|
|||
: null,
|
||||
branchName: String(item.headRefName ?? ''),
|
||||
baseRefName: String(item.baseRefName ?? '')
|
||||
// Why: ownerRepo is null on this path so we can't compare head vs base
|
||||
// owners. Leave isCrossRepository undefined rather than guessing —
|
||||
// falsely claiming "not a fork" would let the picker try a normal-PR
|
||||
// fetch against a fork head and fail.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import {
|
|||
getGitUsername,
|
||||
getRepoName,
|
||||
getBaseRefDefault,
|
||||
searchBaseRefs
|
||||
searchBaseRefs,
|
||||
BASE_REF_SEARCH_ARGS,
|
||||
filterBaseRefSearchOutput
|
||||
} from '../git/repo'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
|
|
@ -428,21 +430,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return []
|
||||
}
|
||||
try {
|
||||
const result = await provider.exec(
|
||||
[
|
||||
'for-each-ref',
|
||||
'--format=%(refname:short)',
|
||||
'--sort=-committerdate',
|
||||
`refs/remotes/origin/*${args.query}*`,
|
||||
`refs/heads/*${args.query}*`
|
||||
],
|
||||
repo.path
|
||||
)
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, limit)
|
||||
const result = await provider.exec(BASE_REF_SEARCH_ARGS, repo.path)
|
||||
return filterBaseRefSearchOutput(result.stdout, args.query, limit)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import { rm } from 'fs/promises'
|
||||
|
|
@ -7,6 +8,8 @@ import { deleteWorktreeHistoryDir } from '../terminal-history'
|
|||
import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types'
|
||||
import { removeWorktree } from '../git/worktree'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { getWorkItem } from '../github/client'
|
||||
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
|
|
@ -38,6 +41,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
ipcMain.removeHandler('worktrees:listAll')
|
||||
ipcMain.removeHandler('worktrees:list')
|
||||
ipcMain.removeHandler('worktrees:create')
|
||||
ipcMain.removeHandler('worktrees:resolvePrBase')
|
||||
ipcMain.removeHandler('worktrees:remove')
|
||||
ipcMain.removeHandler('worktrees:updateMeta')
|
||||
ipcMain.removeHandler('worktrees:persistSortOrder')
|
||||
|
|
@ -139,6 +143,107 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:resolvePrBase',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}
|
||||
): Promise<{ baseBranch: string } | { error: string }> => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
return { error: 'Repo not found' }
|
||||
}
|
||||
// Why: remote SSH repos are out of scope in v1. The picker already
|
||||
// disables its PR tab for them — this guard belt-and-suspenders it.
|
||||
if (repo.connectionId) {
|
||||
return { error: 'PR start points are not supported for remote repos yet.' }
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { error: 'Folder mode does not support creating worktrees.' }
|
||||
}
|
||||
|
||||
let headRefName = args.headRefName?.trim() ?? ''
|
||||
let isCrossRepository = args.isCrossRepository === true
|
||||
|
||||
// Skip the gh lookup when both hints are present (picker already has them).
|
||||
if (!headRefName) {
|
||||
const item = await getWorkItem(repo.path, args.prNumber)
|
||||
if (!item || item.type !== 'pr') {
|
||||
return { error: `PR #${args.prNumber} not found.` }
|
||||
}
|
||||
headRefName = (item.branchName ?? '').trim()
|
||||
if (!headRefName) {
|
||||
return { error: `PR #${args.prNumber} has no head branch.` }
|
||||
}
|
||||
if (item.isCrossRepository === true) {
|
||||
isCrossRepository = true
|
||||
}
|
||||
}
|
||||
|
||||
let remote: string
|
||||
try {
|
||||
remote = await getDefaultRemote(repo.path)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||||
}
|
||||
|
||||
// Why: fork PR heads live on a remote we don't have configured, so
|
||||
// `git fetch <remote> <headRefName>` would fail. GitHub exposes every
|
||||
// PR head (fork or same-repo) as refs/pull/<N>/head on the upstream
|
||||
// repo. Fetch that and snapshot the SHA — the new worktree branch is
|
||||
// derived from the workspace name, so there's no tracking ref to set
|
||||
// up, which makes SHA semantics ("branch from this commit") cleaner
|
||||
// than returning a ref that would go stale on force-push.
|
||||
if (isCrossRepository) {
|
||||
const pullRef = `refs/pull/${args.prNumber}/head`
|
||||
try {
|
||||
await gitExecFileAsync(['fetch', remote, pullRef], { cwd: repo.path })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
error: `Failed to fetch ${pullRef}: ${message.split('\n')[0]}`
|
||||
}
|
||||
}
|
||||
let sha: string
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], {
|
||||
cwd: repo.path
|
||||
})
|
||||
sha = stdout.trim()
|
||||
} catch {
|
||||
return { error: `Could not resolve fork PR #${args.prNumber} head after fetch.` }
|
||||
}
|
||||
if (!sha) {
|
||||
return { error: `Empty SHA resolving fork PR #${args.prNumber} head.` }
|
||||
}
|
||||
return { baseBranch: sha }
|
||||
}
|
||||
|
||||
try {
|
||||
await gitExecFileAsync(['fetch', remote, headRefName], { cwd: repo.path })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
error: `Failed to fetch ${remote}/${headRefName}: ${message.split('\n')[0]}`
|
||||
}
|
||||
}
|
||||
|
||||
const remoteRef = `${remote}/${headRefName}`
|
||||
try {
|
||||
await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path })
|
||||
} catch {
|
||||
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
|
||||
}
|
||||
|
||||
return { baseBranch: remoteRef }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:remove',
|
||||
async (_event, args: { worktreeId: string; force?: boolean }) => {
|
||||
|
|
|
|||
|
|
@ -319,6 +319,12 @@ export type PreloadApi = {
|
|||
baseBranch?: string
|
||||
setupDecision?: 'inherit' | 'run' | 'skip'
|
||||
}) => Promise<CreateWorktreeResult>
|
||||
resolvePrBase: (args: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}) => Promise<{ baseBranch: string } | { error: string }>
|
||||
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
|
||||
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
|
||||
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@ const api = {
|
|||
setupDecision?: 'inherit' | 'run' | 'skip'
|
||||
}): Promise<unknown> => ipcRenderer.invoke('worktrees:create', args),
|
||||
|
||||
resolvePrBase: (args: {
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<{ baseBranch: string } | { error: string }> =>
|
||||
ipcRenderer.invoke('worktrees:resolvePrBase', args),
|
||||
|
||||
remove: (args: { worktreeId: string; force?: boolean }): Promise<void> =>
|
||||
ipcRenderer.invoke('worktrees:remove', args),
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
|
|||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TuiAgent } from '../../../shared/types'
|
||||
import type { GitHubWorkItem, TuiAgent } from '../../../shared/types'
|
||||
import StartFromField from '@/components/new-workspace/StartFromField'
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
|
||||
|
|
@ -44,6 +45,13 @@ type NewWorkspaceComposerCardProps = {
|
|||
onCreate: () => void
|
||||
note: string
|
||||
onNoteChange: (value: string) => void
|
||||
baseBranch: string | undefined
|
||||
onBaseBranchChange: (next: string | undefined) => void
|
||||
onBaseBranchPrSelect: (baseBranch: string, item: GitHubWorkItem) => void
|
||||
baseBranchLinkedPrNumber: number | null
|
||||
selectedRepoPath: string | null
|
||||
selectedRepoIsRemote: boolean
|
||||
startFromResetHint: string | null
|
||||
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
|
||||
requiresExplicitSetupChoice: boolean
|
||||
setupDecision: 'run' | 'skip' | null
|
||||
|
|
@ -180,6 +188,13 @@ export default function NewWorkspaceComposerCard({
|
|||
onCreate,
|
||||
note,
|
||||
onNoteChange,
|
||||
baseBranch,
|
||||
onBaseBranchChange,
|
||||
onBaseBranchPrSelect,
|
||||
baseBranchLinkedPrNumber,
|
||||
selectedRepoPath,
|
||||
selectedRepoIsRemote,
|
||||
startFromResetHint,
|
||||
setupConfig,
|
||||
requiresExplicitSetupChoice,
|
||||
setupDecision,
|
||||
|
|
@ -348,6 +363,19 @@ export default function NewWorkspaceComposerCard({
|
|||
inside the overflow-hidden drawer above. Without it the ring
|
||||
gets clipped on the right edge when the field is focused. */}
|
||||
<div className="space-y-4 px-1 pt-1">
|
||||
{repoId ? (
|
||||
<StartFromField
|
||||
repoId={repoId}
|
||||
repoPath={selectedRepoPath}
|
||||
isRemoteRepo={selectedRepoIsRemote}
|
||||
baseBranch={baseBranch}
|
||||
baseBranchLinkedPrNumber={baseBranchLinkedPrNumber}
|
||||
onBaseBranchChange={onBaseBranchChange}
|
||||
onBaseBranchPrSelect={onBaseBranchPrSelect}
|
||||
resetHint={startFromResetHint}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Note</label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronDown, GitBranch, GitPullRequest } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GitHubWorkItem } from '../../../../shared/types'
|
||||
import StartFromPicker, { type StartFromSelection } from './StartFromPicker'
|
||||
|
||||
type StartFromFieldProps = {
|
||||
repoId: string
|
||||
repoPath: string | null
|
||||
isRemoteRepo: boolean
|
||||
baseBranch: string | undefined
|
||||
baseBranchLinkedPrNumber: number | null
|
||||
onBaseBranchChange: (next: string | undefined) => void
|
||||
onBaseBranchPrSelect: (baseBranch: string, item: GitHubWorkItem) => void
|
||||
/** Transient inline hint, e.g. "was PR #8778" after a repo switch reset. */
|
||||
resetHint?: string | null
|
||||
}
|
||||
|
||||
export default function StartFromField({
|
||||
repoId,
|
||||
repoPath,
|
||||
isRemoteRepo,
|
||||
baseBranch,
|
||||
baseBranchLinkedPrNumber,
|
||||
onBaseBranchChange,
|
||||
onBaseBranchPrSelect,
|
||||
resetHint
|
||||
}: StartFromFieldProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hintVisible, setHintVisible] = useState(Boolean(resetHint))
|
||||
const [defaultBaseRef, setDefaultBaseRef] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setHintVisible(Boolean(resetHint))
|
||||
}, [resetHint])
|
||||
|
||||
// Resolve the actual default ref (e.g. "origin/main") so the trigger can
|
||||
// show a concrete branch name instead of the vague phrase "Default branch".
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setDefaultBaseRef(null)
|
||||
void window.api.repos
|
||||
.getBaseRefDefault({ repoId })
|
||||
.then((ref) => {
|
||||
if (!stale) {
|
||||
setDefaultBaseRef(ref)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setDefaultBaseRef(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [repoId])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selection: StartFromSelection): void => {
|
||||
setHintVisible(false)
|
||||
if (selection.kind === 'default') {
|
||||
onBaseBranchChange(undefined)
|
||||
return
|
||||
}
|
||||
if (selection.kind === 'branch') {
|
||||
onBaseBranchChange(selection.baseBranch)
|
||||
return
|
||||
}
|
||||
onBaseBranchPrSelect(selection.baseBranch, selection.item)
|
||||
},
|
||||
[onBaseBranchChange, onBaseBranchPrSelect]
|
||||
)
|
||||
|
||||
const labelPrimary =
|
||||
baseBranchLinkedPrNumber !== null
|
||||
? `PR #${baseBranchLinkedPrNumber}`
|
||||
: baseBranch
|
||||
? baseBranch
|
||||
: (defaultBaseRef ?? 'Default branch')
|
||||
const isDefault = baseBranchLinkedPrNumber === null && !baseBranch
|
||||
const Icon = baseBranchLinkedPrNumber !== null ? GitPullRequest : GitBranch
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted-foreground">Start from</label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 text-xs shadow-xs transition-[color,box-shadow] outline-none hover:bg-muted/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{labelPrimary}</span>
|
||||
{isDefault && defaultBaseRef ? (
|
||||
<span className="shrink-0 text-[10px] font-normal text-muted-foreground">
|
||||
(default)
|
||||
</span>
|
||||
) : null}
|
||||
{hintVisible && resetHint ? (
|
||||
<span className="truncate text-[10px] font-normal text-muted-foreground">
|
||||
— {resetHint}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0" sideOffset={4}>
|
||||
<StartFromPicker
|
||||
repoId={repoId}
|
||||
repoPath={repoPath}
|
||||
isRemoteRepo={isRemoteRepo}
|
||||
currentBaseBranch={baseBranch}
|
||||
onSelect={handleSelect}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,483 @@
|
|||
/* eslint-disable max-lines -- Why: the Start-from picker keeps Branches +
|
||||
Pull requests tab logic, SWR read/write, URL normalization, and stale-resolve
|
||||
cancellation co-located so the popover's state machine stays inspectable in
|
||||
one place. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { GitBranch, GitPullRequest, LoaderCircle, Search } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeGitHubLinkQuery } from '@/lib/github-links'
|
||||
import type { RepoSlug } from '@/lib/github-links'
|
||||
import type { GitHubWorkItem } from '../../../../shared/types'
|
||||
|
||||
export type StartFromSelection =
|
||||
| { kind: 'default' }
|
||||
| { kind: 'branch'; baseBranch: string }
|
||||
| { kind: 'pr'; baseBranch: string; item: GitHubWorkItem }
|
||||
|
||||
type StartFromPickerProps = {
|
||||
repoId: string
|
||||
repoPath: string | null
|
||||
/** Whether the selected repo is a remote SSH repo. PR tab is disabled for remote repos in v1. */
|
||||
isRemoteRepo: boolean
|
||||
onSelect: (selection: StartFromSelection) => void
|
||||
onClose: () => void
|
||||
currentBaseBranch: string | undefined
|
||||
}
|
||||
|
||||
type PickerTab = 'branches' | 'prs'
|
||||
|
||||
const PR_LIST_QUERY = 'is:pr is:open'
|
||||
const PR_LIST_LIMIT = 36
|
||||
|
||||
export default function StartFromPicker({
|
||||
repoId,
|
||||
repoPath,
|
||||
isRemoteRepo,
|
||||
onSelect,
|
||||
onClose,
|
||||
currentBaseBranch
|
||||
}: StartFromPickerProps): React.JSX.Element {
|
||||
const { fetchWorkItems, getCachedWorkItems } = useAppStore(
|
||||
useShallow((s) => ({
|
||||
fetchWorkItems: s.fetchWorkItems,
|
||||
getCachedWorkItems: s.getCachedWorkItems
|
||||
}))
|
||||
)
|
||||
|
||||
const [tab, setTab] = useState<PickerTab>('branches')
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [repoSlug, setRepoSlug] = useState<RepoSlug | null>(null)
|
||||
|
||||
// Branches tab state
|
||||
const [branches, setBranches] = useState<string[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
|
||||
// PR tab state
|
||||
const [prItems, setPrItems] = useState<GitHubWorkItem[] | null>(() => {
|
||||
if (!repoPath) {
|
||||
return null
|
||||
}
|
||||
return getCachedWorkItems(repoPath, PR_LIST_LIMIT, PR_LIST_QUERY)
|
||||
})
|
||||
const [prsLoading, setPrsLoading] = useState(false)
|
||||
const [prsError, setPrsError] = useState<string | null>(null)
|
||||
const [directPrItem, setDirectPrItem] = useState<GitHubWorkItem | null>(null)
|
||||
const [directLoading, setDirectLoading] = useState(false)
|
||||
|
||||
const [resolving, setResolving] = useState(false)
|
||||
const [resolveError, setResolveError] = useState<string | null>(null)
|
||||
// Why: a per-click token so late resolves are discarded if the user selects
|
||||
// another PR (or closes) before the first fetch/rev-parse completes.
|
||||
const resolveTokenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(query), 150)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Resolve slug for URL-mismatch detection.
|
||||
useEffect(() => {
|
||||
if (!repoPath) {
|
||||
setRepoSlug(null)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void window.api.gh
|
||||
.repoSlug({ repoPath })
|
||||
.then((slug) => {
|
||||
if (!stale) {
|
||||
setRepoSlug(slug)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setRepoSlug(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [repoPath])
|
||||
|
||||
// Branches fetch (debounced, only when active).
|
||||
useEffect(() => {
|
||||
if (tab !== 'branches') {
|
||||
return
|
||||
}
|
||||
const trimmed = debouncedQuery.trim()
|
||||
let stale = false
|
||||
setBranchesLoading(true)
|
||||
void window.api.repos
|
||||
.searchBaseRefs({ repoId, query: trimmed || '', limit: 30 })
|
||||
.then((results) => {
|
||||
if (!stale) {
|
||||
setBranches(results)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBranches([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [tab, debouncedQuery, repoId])
|
||||
|
||||
const normalizedPrQuery = useMemo(
|
||||
() => normalizeGitHubLinkQuery(debouncedQuery, repoSlug),
|
||||
[debouncedQuery, repoSlug]
|
||||
)
|
||||
|
||||
// PR list fetch (cached-first).
|
||||
useEffect(() => {
|
||||
if (tab !== 'prs' || isRemoteRepo || !repoPath) {
|
||||
return
|
||||
}
|
||||
const trimmed = debouncedQuery.trim()
|
||||
const directNumber = normalizedPrQuery.directNumber
|
||||
|
||||
if (directNumber !== null) {
|
||||
return // handled by the direct-lookup effect
|
||||
}
|
||||
|
||||
const q =
|
||||
trimmed && !normalizedPrQuery.repoMismatch
|
||||
? `${PR_LIST_QUERY} ${normalizedPrQuery.query}`
|
||||
: PR_LIST_QUERY
|
||||
|
||||
const cached = getCachedWorkItems(repoPath, PR_LIST_LIMIT, q)
|
||||
if (cached !== null) {
|
||||
setPrItems(cached.filter((i) => i.type === 'pr'))
|
||||
}
|
||||
|
||||
let stale = false
|
||||
setPrsLoading(cached === null)
|
||||
setPrsError(null)
|
||||
void fetchWorkItems(repoId, repoPath, PR_LIST_LIMIT, q)
|
||||
.then((items) => {
|
||||
if (!stale) {
|
||||
setPrItems(items.filter((i) => i.type === 'pr'))
|
||||
setPrsLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!stale) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load PRs.'
|
||||
setPrsError(message)
|
||||
setPrsLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [
|
||||
tab,
|
||||
isRemoteRepo,
|
||||
repoId,
|
||||
repoPath,
|
||||
debouncedQuery,
|
||||
normalizedPrQuery.directNumber,
|
||||
normalizedPrQuery.query,
|
||||
normalizedPrQuery.repoMismatch,
|
||||
fetchWorkItems,
|
||||
getCachedWorkItems
|
||||
])
|
||||
|
||||
// Direct-number PR lookup.
|
||||
useEffect(() => {
|
||||
if (tab !== 'prs' || isRemoteRepo || !repoPath) {
|
||||
return
|
||||
}
|
||||
const directNumber = normalizedPrQuery.directNumber
|
||||
if (directNumber === null) {
|
||||
setDirectPrItem(null)
|
||||
setDirectLoading(false)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
setDirectLoading(true)
|
||||
void window.api.gh
|
||||
.workItem({ repoPath, number: directNumber })
|
||||
.then((item) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const gh = item as GitHubWorkItem | null
|
||||
// Why: a `#N` that collides with an issue must render as no-match in
|
||||
// the PR tab, not silently swap the selection to an issue.
|
||||
setDirectPrItem(gh && gh.type === 'pr' ? gh : null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setDirectPrItem(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setDirectLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [tab, isRemoteRepo, repoPath, normalizedPrQuery.directNumber])
|
||||
|
||||
const handleBranchSelect = useCallback(
|
||||
(ref: string) => {
|
||||
onSelect({ kind: 'branch', baseBranch: ref })
|
||||
onClose()
|
||||
},
|
||||
[onClose, onSelect]
|
||||
)
|
||||
|
||||
const handlePrSelect = useCallback(
|
||||
async (item: GitHubWorkItem) => {
|
||||
if (item.type !== 'pr') {
|
||||
return
|
||||
}
|
||||
const token = ++resolveTokenRef.current
|
||||
setResolving(true)
|
||||
setResolveError(null)
|
||||
try {
|
||||
const result = await window.api.worktrees.resolvePrBase({
|
||||
repoId,
|
||||
prNumber: item.number,
|
||||
...(item.branchName ? { headRefName: item.branchName } : {}),
|
||||
...(item.isCrossRepository !== undefined
|
||||
? { isCrossRepository: item.isCrossRepository }
|
||||
: {})
|
||||
})
|
||||
if (token !== resolveTokenRef.current) {
|
||||
return
|
||||
}
|
||||
if ('error' in result) {
|
||||
setResolveError(result.error)
|
||||
setResolving(false)
|
||||
return
|
||||
}
|
||||
onSelect({ kind: 'pr', baseBranch: result.baseBranch, item })
|
||||
setResolving(false)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (token !== resolveTokenRef.current) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Failed to resolve PR head.'
|
||||
setResolveError(message)
|
||||
setResolving(false)
|
||||
}
|
||||
},
|
||||
[onClose, onSelect, repoId]
|
||||
)
|
||||
|
||||
const handleDefaultSelect = useCallback(() => {
|
||||
onSelect({ kind: 'default' })
|
||||
onClose()
|
||||
}, [onClose, onSelect])
|
||||
|
||||
const visiblePrItems = useMemo(() => {
|
||||
if (normalizedPrQuery.directNumber !== null) {
|
||||
return directPrItem ? [directPrItem] : []
|
||||
}
|
||||
return prItems ?? []
|
||||
}, [directPrItem, normalizedPrQuery.directNumber, prItems])
|
||||
|
||||
return (
|
||||
<div className="flex w-[420px] flex-col overflow-hidden">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PickerTab)} className="gap-0">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="branches" className="gap-1.5 text-xs">
|
||||
<GitBranch className="size-3.5" />
|
||||
Branches
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="prs"
|
||||
disabled={isRemoteRepo}
|
||||
className="gap-1.5 text-xs"
|
||||
title={
|
||||
isRemoteRepo ? 'PR start points not supported for remote repos yet' : undefined
|
||||
}
|
||||
>
|
||||
<GitPullRequest className="size-3.5" />
|
||||
Pull requests
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{currentBaseBranch !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDefaultSelect}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Use default
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'branches' ? 'Search branches…' : 'Search PRs, paste #N or URL…'}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resolveError ? (
|
||||
<div className="mx-3 mb-2 rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[11px] text-destructive">
|
||||
{resolveError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="branches" className="px-1 pb-2">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{branchesLoading && branches.length === 0 ? (
|
||||
<PickerLoadingRows />
|
||||
) : branches.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{query.trim() ? 'No branches match' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
branches.map((refName) => (
|
||||
<BranchRow
|
||||
key={refName}
|
||||
refName={refName}
|
||||
active={refName === currentBaseBranch}
|
||||
onSelect={() => handleBranchSelect(refName)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="prs" className="px-1 pb-2">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{isRemoteRepo ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
PR start points aren't supported for remote repos yet.
|
||||
</div>
|
||||
) : normalizedPrQuery.repoMismatch && normalizedPrQuery.directNumber === null ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
URL targets a different repo; searching by text instead.
|
||||
</div>
|
||||
) : prsError ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{prsError.includes('gh') ? 'gh not available — Branches tab still works' : prsError}
|
||||
</div>
|
||||
) : (prsLoading || directLoading) && visiblePrItems.length === 0 ? (
|
||||
<PickerLoadingRows />
|
||||
) : visiblePrItems.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{normalizedPrQuery.directNumber !== null
|
||||
? `No open PR #${normalizedPrQuery.directNumber}`
|
||||
: 'No open PRs'}
|
||||
</div>
|
||||
) : (
|
||||
visiblePrItems.map((item) => (
|
||||
<PrRow
|
||||
key={`${item.type}-${item.number}`}
|
||||
item={item}
|
||||
disabled={resolving}
|
||||
onSelect={() => void handlePrSelect(item)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{resolving ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
Resolving PR head…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PickerLoadingRows(): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-1 px-2 py-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-7 animate-pulse rounded bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchRow({
|
||||
refName,
|
||||
active,
|
||||
onSelect
|
||||
}: {
|
||||
refName: string
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition hover:bg-muted/60',
|
||||
active && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{refName}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PrRow({
|
||||
item,
|
||||
disabled,
|
||||
onSelect
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
disabled: boolean
|
||||
onSelect: () => void
|
||||
}): React.JSX.Element {
|
||||
const isFork = item.isCrossRepository === true
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-xs transition hover:bg-muted/60',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent'
|
||||
)}
|
||||
title={isFork ? 'Fork PR — will branch from a snapshot of the PR head' : undefined}
|
||||
>
|
||||
<GitPullRequest className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">#{item.number}</span>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
{item.branchName ? (
|
||||
<span className="mt-0.5 block truncate font-mono text-[10px] text-muted-foreground">
|
||||
{item.branchName}
|
||||
{isFork ? ' · fork' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -92,6 +92,21 @@ export type ComposerCardProps = {
|
|||
onCreate: () => void
|
||||
note: string
|
||||
onNoteChange: (value: string) => void
|
||||
baseBranch: string | undefined
|
||||
onBaseBranchChange: (next: string | undefined) => void
|
||||
/** Called when a PR is selected in the Start-from picker. Updates both
|
||||
* baseBranch and linkedWorkItem/linkedPR in one pass. */
|
||||
onBaseBranchPrSelect: (baseBranch: string, item: GitHubWorkItem) => void
|
||||
/** PR number selected via the Start-from picker (when applicable). Used so the
|
||||
* field can render "PR #N" copy. */
|
||||
baseBranchLinkedPrNumber: number | null
|
||||
/** Absolute path of the selected repo, used by Start-from picker for SWR. */
|
||||
selectedRepoPath: string | null
|
||||
/** True when the selected repo is a remote SSH repo; disables the PR tab in v1. */
|
||||
selectedRepoIsRemote: boolean
|
||||
/** Transient inline hint shown next to the Start-from trigger after a repo
|
||||
* switch resets a prior selection (e.g. "was PR #8778"). Null when none. */
|
||||
startFromResetHint: string | null
|
||||
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
|
||||
requiresExplicitSetupChoice: boolean
|
||||
setupDecision: 'run' | 'skip' | null
|
||||
|
|
@ -150,7 +165,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setRightSidebarTab: s.setRightSidebarTab,
|
||||
closeModal: s.closeModal,
|
||||
openSettingsPage: s.openSettingsPage,
|
||||
openSettingsTarget: s.openSettingsTarget
|
||||
openSettingsTarget: s.openSettingsTarget,
|
||||
prefetchWorkItems: s.prefetchWorkItems
|
||||
}))
|
||||
)
|
||||
const {
|
||||
|
|
@ -163,7 +179,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setRightSidebarTab,
|
||||
closeModal,
|
||||
openSettingsPage,
|
||||
openSettingsTarget
|
||||
openSettingsTarget,
|
||||
prefetchWorkItems
|
||||
} = actions
|
||||
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
|
|
@ -227,6 +244,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null
|
||||
})
|
||||
const [baseBranch, setBaseBranch] = useState<string | undefined>(
|
||||
persistDraft ? newWorkspaceDraft?.baseBranch : undefined
|
||||
)
|
||||
// Why: when a repo switch wipes a prior Start-from selection, surface the
|
||||
// reset inline (e.g. "was PR #8778") so the change is recoverable visually
|
||||
// instead of slipping past the user. Cleared on any subsequent selection.
|
||||
const [startFromResetHint, setStartFromResetHint] = useState<string | null>(null)
|
||||
// Why: the long-form composer's agent selection is a required TuiAgent (not
|
||||
// null/blank), so 'blank' preferences from global settings must collapse to
|
||||
// the Claude default here — the blank-terminal affordance only lives in the
|
||||
|
|
@ -420,12 +444,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
linkedWorkItem,
|
||||
agent: tuiAgent,
|
||||
linkedIssue,
|
||||
linkedPR
|
||||
linkedPR,
|
||||
...(baseBranch !== undefined ? { baseBranch } : {})
|
||||
})
|
||||
}, [
|
||||
persistDraft,
|
||||
agentPrompt,
|
||||
attachmentPaths,
|
||||
baseBranch,
|
||||
linkedIssue,
|
||||
linkedPR,
|
||||
linkedWorkItem,
|
||||
|
|
@ -516,6 +542,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
}, [repoId])
|
||||
|
||||
// Why: warm the Start-from picker's PR cache on composer mount and whenever
|
||||
// the selected repo changes so opening the picker paints instantly from
|
||||
// cache. Local repos only — remote SSH repos disable the PR tab in v1.
|
||||
useEffect(() => {
|
||||
if (!selectedRepo?.path || selectedRepo.connectionId) {
|
||||
return
|
||||
}
|
||||
prefetchWorkItems(selectedRepo.id, selectedRepo.path, 36, 'is:pr is:open')
|
||||
}, [prefetchWorkItems, selectedRepo?.connectionId, selectedRepo?.id, selectedRepo?.path])
|
||||
|
||||
// Per-repo: resolve repo slug for GH URL mismatch detection.
|
||||
useEffect(() => {
|
||||
if (!selectedRepo) {
|
||||
|
|
@ -858,12 +894,47 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
|
||||
const handleRepoChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (value === repoId) {
|
||||
setRepoId(value)
|
||||
return
|
||||
}
|
||||
// Why: capture a short descriptor of the prior Start-from selection so
|
||||
// the field can render an inline reset (e.g. "was PR #8778") after the
|
||||
// repo changes and the selection is wiped.
|
||||
let hint: string | null = null
|
||||
if (linkedWorkItem?.type === 'pr' && baseBranch) {
|
||||
hint = `was PR #${linkedWorkItem.number}`
|
||||
} else if (baseBranch) {
|
||||
hint = `was ${baseBranch}`
|
||||
}
|
||||
setRepoId(value)
|
||||
setLinkedIssue('')
|
||||
setLinkedPR(null)
|
||||
setLinkedWorkItem(null)
|
||||
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
|
||||
// selection is meaningless in the new repo. Resetting to undefined
|
||||
// makes the field fall back to the new repo's effective base ref.
|
||||
setBaseBranch(undefined)
|
||||
setStartFromResetHint(hint)
|
||||
},
|
||||
[setRepoId]
|
||||
[baseBranch, linkedWorkItem, repoId, setRepoId]
|
||||
)
|
||||
|
||||
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
|
||||
setBaseBranch(next)
|
||||
setStartFromResetHint(null)
|
||||
}, [])
|
||||
|
||||
const handleBaseBranchPrSelect = useCallback(
|
||||
(nextBaseBranch: string, item: GitHubWorkItem): void => {
|
||||
setBaseBranch(nextBaseBranch)
|
||||
setStartFromResetHint(null)
|
||||
// Why: per spec, a PR selection in the Start-from picker is also a
|
||||
// linkedWorkItem assignment. Reuse applyLinkedWorkItem so auto-name and
|
||||
// linkedPR state stay in a single code path.
|
||||
applyLinkedWorkItem(item)
|
||||
},
|
||||
[applyLinkedWorkItem]
|
||||
)
|
||||
|
||||
const handleOpenAgentSettings = useCallback((): void => {
|
||||
|
|
@ -912,7 +983,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
undefined,
|
||||
baseBranch,
|
||||
(resolvedSetupDecision ?? 'inherit') as SetupDecision
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
|
@ -966,6 +1037,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
setCreating(false)
|
||||
}
|
||||
}, [
|
||||
baseBranch,
|
||||
clearNewWorkspaceDraft,
|
||||
createWorktree,
|
||||
applyWorktreeMeta,
|
||||
|
|
@ -1019,7 +1091,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
undefined,
|
||||
baseBranch,
|
||||
(resolvedSetupDecision ?? 'inherit') as SetupDecision
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
|
@ -1067,6 +1139,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
},
|
||||
[
|
||||
applyWorktreeMeta,
|
||||
baseBranch,
|
||||
clearNewWorkspaceDraft,
|
||||
createWorktree,
|
||||
fallbackCreatureName,
|
||||
|
|
@ -1132,6 +1205,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
createDisabled,
|
||||
creating,
|
||||
onCreate: () => void submit(),
|
||||
baseBranch,
|
||||
onBaseBranchChange: handleBaseBranchChange,
|
||||
onBaseBranchPrSelect: handleBaseBranchPrSelect,
|
||||
baseBranchLinkedPrNumber:
|
||||
linkedWorkItem?.type === 'pr' && baseBranch ? linkedWorkItem.number : null,
|
||||
selectedRepoPath: selectedRepo?.path ?? null,
|
||||
selectedRepoIsRemote: Boolean(selectedRepo?.connectionId),
|
||||
startFromResetHint,
|
||||
note,
|
||||
onNoteChange: setNote,
|
||||
setupConfig,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export type UISlice = {
|
|||
agent: TuiAgent
|
||||
linkedIssue: string
|
||||
linkedPR: number | null
|
||||
// Why: repo-scoped start ref selected via the "Start from" picker.
|
||||
// Absent means "use the repo's effective base ref".
|
||||
baseBranch?: string
|
||||
} | null
|
||||
openTaskPage: (data?: UISlice['taskPageData']) => void
|
||||
closeTaskPage: () => void
|
||||
|
|
|
|||
|
|
@ -401,6 +401,10 @@ export type GitHubWorkItem = {
|
|||
author: string | null
|
||||
branchName?: string
|
||||
baseRefName?: string
|
||||
// Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner).
|
||||
// The Start-from picker disables fork PRs in v1 because the create flow cannot
|
||||
// safely resolve a fork head from headRefName alone.
|
||||
isCrossRepository?: boolean
|
||||
/** Why: required because the cross-repo view merges items from every selected
|
||||
* repo — the table row's repo pill and the "open in browser" fallback need
|
||||
* to know which repo an item came from. Stamped by the renderer fetcher
|
||||
|
|
|
|||
Loading…
Reference in New Issue