Skip to content

Feat: One worktree, all its PRs -- subagent pull requests are no longer invisible - #606

Open
cheapsteak wants to merge 26 commits into
mainfrom
multi-pr-per-worktree
Open

Feat: One worktree, all its PRs -- subagent pull requests are no longer invisible#606
cheapsteak wants to merge 26 commits into
mainfrom
multi-pr-per-worktree

Conversation

@cheapsteak

Copy link
Copy Markdown
Owner

Summary

A worktree can produce more than one pull request — a long session opens one, gets it reviewed, starts the next piece of work on a fresh branch, and opens another; subagents open their own. TBD showed exactly one of them.

This was not merely a display limit. TBD discovered a worktree's PR by matching the worktree's head branch against the viewer's authored PRs, so a subagent's PR — typically on a branch the worktree never had checked out — was invisible by construction, not by omission. No amount of polling would ever find it.

  • A worktree now owns a list of PRs, held as durable bindings in a new worktree_pull_request table (migration v70). Three discovery sources converge on it: a PostToolUse/Bash hook that binds PRs from gh pr create output, the existing branch matcher, and manual tbd pr attach / detach.
  • The toolbar control keeps its single-PR behavior at N=1 — label #412, click opens the tab, ⌘-click the browser — and becomes a worst-state icon plus N PRs opening a dropdown when there are several. The status bar grows a chip per PR, and the sidebar dot takes the same worst-state rule so the three surfaces cannot disagree.
  • Auto-archive got stricter, not looser. It now fires only when every non-detached binding is terminal, at least one merged, and at least one merged binding is the worktree's own work (head branch among its branch candidates, or its number matching Worktree.prNumber).

Why there's no feature flag

The repo convention is that autonomous behavior ships behind a default-off flag. This ships unflagged because the new merge rule is strictly stronger than the one it replaces: identical at one binding, and harder to satisfy at more. The old rule fired when the worktree's own PR merged; the new one fires when its own PR merged and every other PR it opened is finished too.

That claim is load-bearing, so it was tested rather than assumed. Two review rounds found paths where it was false, and both are closed:

  • A branch-matched binding outlived the evidence for it — the head-ref and cross-repo heals cleared the cache but not the table, so a disproved binding kept driving the worktree and could archive it. Heals now delete the branch-source binding too (only that source: a hook binding is evidence a session created the PR, a manual one is the user's explicit statement).
  • Hook binding attributes a PR to whichever worktree ran gh pr create, which is not necessarily the worktree whose branch it's on. Without the ownership condition, a worktree whose own branch had no PR at all would tear itself down the moment a subagent's PR merged — something branch matching could never do. That condition is why the rule is now genuinely conservative.

Design notes

  • Detach writes a tombstone, not a delete. A deleted row would be re-created by the next poll, so the user's decision has to be recorded to be durable. Only an explicit attach revives one; the provenance seed re-runs every poll and is barred from reviving.
  • The hook gate fails closed. It tokenizes rather than substring-matches, because the two errors cost different amounts: a missed bind costs one tbd pr attach, a false bind can archive a worktree. Quoted 'gh pr create' is an argument; gh -R owner/repo pr create is a command; heredoc bodies are data, not commands. The prefilter means an unrelated Bash call costs a cat and a grep, never a tbd spawn.
  • This parses hook payload JSON, not terminal output. The no-TUI-scraping rule bans inferring state from a rendered screen and points at machine interfaces like hook payloads — which is exactly what this reads.
  • Worktree.prStatus keeps working for existing readers, written with the worst-state status — except when that status is .merged, which would let hydrate restore an already-merged baseline at daemon start and permanently lose a retried auto-archive.

Spec: docs/specs/2026-08-10-multi-pr-per-worktree-design.md. Prior art surveyed before designing: Claude Desktop's GitHubPrManager (bind-at-creation from tool output, per-session prs[], cap 20), vibe-kanban (pull_requests table, archive waits for all PRs), GitButler (per-branch review cache, resync rather than trust), and Cursor's confirmed head-branch mis-attribution bug.

Test plan

  • 436 tests across 27 PR-related suites pass; full suite 5582 tests with only the known load-flake families (ProviderEventsSupervisor, CodexUsageFetcherLifecycle, MarkdownStylesheet/ThemeStore, ArchivedWorktreeSearch/AppearanceDebounce), each verified passing in isolation.
  • swiftlint --strict clean across 696 files.
  • Safety-critical guards are mutation-checked, not just asserted — the ownership condition, the tombstone-revival bars (both of them), the heredoc gate, the all-resolved rule, and the archive/hibernate idempotency the double-fire path relies on. Each was broken deliberately, confirmed to turn exactly the expected tests red, and restored.
  • Live, needs eyes: a one-PR worktree behaves exactly as before; attaching a second shows 2 PRs and a dropdown listing both; detaching the last one clears the toolbar, chip and sidebar dot; the +N overflow chip opens; hovering a chip then switching selection doesn't stick the pointing-hand cursor.

🤖 Generated with Claude Code

🔀 Multi PR Per Worktree

@tbd-claude-reviewer

This comment has been minimized.

@tbd-claude-reviewer

Copy link
Copy Markdown

🧌 Changes requested

This is a large, well-structured PR (multi-PR-per-worktree binding) with a spec that closely tracks the implementation, and most of the design's own claims held up under verification — dedup, tombstones, cap, cross-repo rejection, the ownership-arm merge rule, and the nonisolated binding refresh path all checked out. Two issues need attention before merge, plus a small spec-drift note.

HIGH — gh -R owner/repo pr create never binds via the hook

Sources/TBDDaemon/Hooks/ClaudeHookOverlay.swift:118

The hook's shell prefilter grep -qE 'gh[[:space:]]+pr[[:space:]]+create' requires gh to be followed directly by pr create, so it never matches gh -R owner/repo pr create, --repo, or --hostname invocations (reproduced directly: NOMATCH). Because of the && short-circuit, tbd pr bind --from-hook — and the tokenizer in PRBindingExtractor built specifically to skip these flags — never runs for that command shape. This contradicts the design doc's explicit claim that gh -R owner/repo pr create counts as a bindable command, and defeats hook binding for exactly the case it exists to cover: a subagent's PR on a branch its own worktree never checked out. No test exercises the real grep text against a flagged command — the existing test only checks substring containment, which is trivially true.

MEDIUM — Merge-transition fan-out can double-fire on first-ever-merged discovery

Sources/TBDDaemon/PR/MergedTransitionDispatcher.swift:63

On a poll pass where a worktree's PR is first discovered already merged (branch match or provenance-number seed), onMergedTransition/observedMerge fires before the binding row exists (empty-bindings, un-bound-fallback path), and then refreshBindingStatusesevaluate fires the same fan-out again once the binding is created later in the same pass — because the two paths use independent once-only guard sets (unboundMergeFired vs. allResolvedFired) despite the code's comment that the actor's state is "the only once-only guard." This is the expected path for every pre-existing worktree right after this feature ships, or any worktree whose PR merges while the daemon is down. Currently no user-visible duplicate was found — AutoArchiveOnMergeCoordinator/AutoHibernateOnMergeCoordinator both happen to be idempotent against a second call — but that's incidental to those two coordinators, not a documented or tested property of the trigger, and no test exercises the exact combined wiring production uses.

MEDIUM — Spec's Status bar section doesn't mention the browser-vs-tab split

docs/specs/2026-08-10-multi-pr-per-worktree-design.md:272

The final commit made status-bar PR chips open the system browser while the toolbar keeps opening an in-app tab (a reasonable, justified split per the commit message), but only the RPC section of the spec was updated to match — the "UI surfaces" → Status bar paragraph still doesn't describe this divergence from the Toolbar paragraph right above it, which does document its own open behavior.

Minor items

None raised this round.

0 specialist findings were filtered out as invalid or persnickety during merge — all 3 (2 correctness, 1 conventions) survived as-is.

Finding dispositions
  • correctness-1 — kept. Independently reproduced the grep miss against gh -R owner/repo pr create.
  • correctness-2 — kept. Independently traced the double-fire wiring; real but currently masked by coordinator idempotency.
  • conventions-1 — kept. Narrow spec drift left by the PR's final commit, real but low-impact.
Review diagnostics

No tool calls failed or were denied during this review — the correctness and conventions specialists both completed cleanly, and the orchestrator's spot-check verification (shell grep repro, wiring trace via Grep/Read) also completed without errors.

Posted by the claude-review check — the review of this PR's diff at patch-id f788d781a876a890250fbbf05d2b243f97c726f8. A newer review comment supersedes this one.

Adds the CLI surface over the pr.bindings/pr.attach/pr.detach RPC methods
from Task 5: `tbd pr list` prints one line per binding, `attach`/`detach`
take a bare number, `#number`, or full PR URL, and `bind --from-hook` is
the PostToolUse hook entry point that reads a Claude Code tool-result
payload from stdin and binds any PR a `gh pr create` reported. `bind`
always exits 0 and stays silent on the empty path so it never disturbs
the Bash call it observes.
Adds a PostToolUse/Bash entry to the Claude hook overlay that greps the
tool payload for `gh pr create` and, only on a match, pipes it to
`tbd pr bind --from-hook`. The grep prefilter (-E, not the GNU-only \+
form) keeps every other Bash call across the fleet from spawning a tbd
process, and `|| true` guarantees the hook can never fail the tool call
it observes.

Updates the pre-existing AskUserQuestion PostToolUse-count assertion,
which is now one of two entries in that array.
Adds a binding-keyed refresh path beside the existing worktree-keyed one:
refreshBindings groups bindings by (host, owner, repo) and reuses
numberedPRQuery / parseNumberedPRNodes / mapStateAndReason /
fetchCheckSignals, keeping the previous status on any transient failure.

fetchAll now also returns its surviving branch matches as ParsedPRURLs so
the poll can bind them with source .branch; the worktree-keyed cache,
head-ref heals and merged-transition path are untouched.

The poll persists each binding's status and writes the worst of a
worktree's bindings into Worktree.prStatus, skipping .merged so the
merge-while-daemon-down re-fire is preserved.
Auto-archive and auto-hibernate now run behind AllResolvedMergeTrigger: every
non-detached binding terminal and at least one merged (PRBinding.allResolved),
edge-triggered so a poll fires it once. Un-bound worktrees keep the single-PR
behavior through observedMerge. The archive/hibernate precedence and the
never-persist-.merged recovery guarantee are unchanged.
A worktree created from a PR row carries `Worktree.prNumber` and got no
binding, so its PR was invisible to `tbd pr list`, to the toolbar dropdown
and to the status-bar chips — and for a fork PR the stored number is the
only handle that exists, because a fork head never appears in the
viewer-authored batch and branch matching can therefore never find it.

`PRBindingCoordinator.seedProvenance` writes that binding with source
`manual`, and the `pr.list` poll reconciles it for every worktree that has
a number and no row. Seeding runs on the poll rather than at creation
because worktrees that predate bindings need it too, and because a new
worktree's `prStatus` is populated by this same poll — so the binding
appears exactly when the PR does.

The trap the seam exists for: `manual` is the one source permitted to clear
a tombstone, so a plain `bind(source: .manual)` reconciled every poll would
undo a `tbd pr detach` within seconds. `seedProvenance` only ever writes the
FIRST row for an identity; anything already on record, tombstone included,
is left as it is.
… toolbar row does

The multi-PR work shipped three click targets that disagreed. The toolbar's
split button and its dropdown rows opened an in-app webview tab (reusing an
existing tab for the same URL, ⌘-click for the browser), while the status-bar
chips and the `+N` overflow rows went straight to the default browser — not a
decision, just the consequence of that logic being `private` to `ContentView`.

Hoist it to `AppState.openPR(url:number:worktreeID:inBrowser:)` and point the
toolbar and the chips at it. The toolbar's behaviour is unchanged; the chips
move to match. `inBrowser` defaults to the ⌘ state at the CALL site, which keeps
the modifier check next to the click that carries it and lets tests drive both
arms without synthesizing an NSEvent.

The reuse rule is the part worth pinning, so it comes out as a pure
`AppState.webviewTabIndex(in:showing:)` — 5 new tier-1 tests cover create,
reuse, a second distinct PR, per-worktree scoping, and the decision itself.

The sidebar row still opens the browser. That predates this work and changing
it is out of scope.
…reate" binding a PR

Two defects in the PR-binding shared code.

The worst-state order contradicted the design: `changesRequested` outranked
`blocked`, where the spec's order is checks failing, blocked, changes
requested, pending, mergeable, draft. One severity table drives the toolbar
icon, the sidebar dot and the `Worktree.prStatus` column, so the inversion was
visible in all three. A test named "prefers blocked over pending over
mergeable" had the inversion baked into its third assertion, which is how it
survived review; it now asserts its own name, pins the blocked /
changes-requested pair explicitly, and walks the whole chain.

The `gh pr create` gate was a substring match, wrong in both directions.
`git log --grep 'gh pr create'` counted as a creation event, so any PR URL in
that command's output bound — and an already-merged one satisfies
`allResolved`, auto-archiving a worktree that never opened a PR. Meanwhile
`gh -R owner/name pr create`, the normal way to target another repo, matched
nothing and silently bound no PR.

The command is now tokenized: quoted runs stay one word, the string is cut into
segments at `;`, `&`, `|` and newline, and a segment counts only when its first
word is `gh` (or a path ending in `/gh`) and its subcommand path — flags and
their values skipped — is exactly `pr create`. Pragmatic, not a shell parser:
an unusual construction fails closed.
…ache

Four defects in the multi-PR poll, all about what one pass does in what order.

**A heal never reached the binding table.** `refreshBindingGroup` re-queries a
stored binding purely by (host, owner, repo, number) and never re-validates it,
so a `branch` binding written by an earlier pass survived every heal: the
head-ref heal and the cross-repo poisoned-cache heal both cleared and persisted
the cache clear while the row kept driving the worktree's icon and, on merge,
satisfied `allResolved` and auto-archived a worktree that was merely tracking
someone else's PR. `fetchAll` now returns a `PollOutcome` with both what it
discovered and what it disproved, and the poll removes the corresponding
binding.

Only `branch` bindings, and by **hard delete rather than tombstone**. A `hook`
binding is direct evidence that this session ran the `gh pr create`, and a
`manual` binding is the user's explicit statement; neither may be undone by an
inference drawn from branch names. And a heal's evidence can itself be wrong (a
stale `repo.defaultBranch`, a momentarily narrower candidate list, a remote
pointed away and back), so a tombstone would block the correct binding forever,
silently, with no user gesture behind it — while a delete costs at most one
poll, because a heal is part of re-discovery and re-derives its verdict every
pass. Durability is what a *detach* needs, not a heal.

**The `Worktree.prStatus` write compared against a pre-poll snapshot.**
`fetchAll` → `apply` → `onStatusPersist` writes that same column mid-pass, so a
worst-of-bindings verdict equal to the snapshot was skipped while the column
held something else — pinning a green icon over a bound PR whose checks were
failing, on every poll. It now compares against the current persisted value.
The skip-when-unchanged optimisation is preserved (an idle poll still issues no
UPDATE, asserted via `total_changes()`), as is the rule that `.merged` is never
written to that column.

**`headBranch` and `baseRef` were migrated, encoded, decoded and rendered but
never written**, so `tbd pr list` printed an em-dash on every row. `baseRefName`
joins the shared node field selection, and a refresh now reports both refs
alongside the status; a pass that resolved nothing reports neither, so a `gh`
outage cannot blank a branch name.

**`observedMerge` was permanently suppressed by `evaluate`.** They shared one
once-only set, and nothing re-armed a worktree that had left the bound
population — with every binding detached the poll's grouping no longer contains
it, so `evaluate` is never called and its re-arm never runs. Each path now owns
its own set and clears it on its own condition.

Smaller, same batch:

- `setDetached` returned `updateAll`'s MATCHED-row count, so `tbd pr detach` on
  an already-detached PR reported success. The current-state check moved into
  the predicate.
- A bare PR number whose repo cannot be resolved yet — the ordinary state of a
  worktree seconds after creation — collapsed into the generic "not a PR number
  or a GitHub PR URL" error. It now reports the `deferredUnknownRepo` deferral,
  so the CLI says "try again shortly".

Every one of the four is mutation-checked: the fix was reverted and the new
assertions shown going red.
…l-detached

Two user-land-facing corrections to the PR binding feature.

The `PostToolUse`/`Bash` hook carried no `timeout`. It is the first TBD hook
matching a universally-used tool, so Claude Code's 60 s default would let one
wedged daemon socket stall every Bash call every agent in the fleet runs. Three
seconds is far more than the happy path needs — the common case is a `grep` that
matches nothing and spawns nothing — and a missed binding is re-derived by
branch matching on the next poll.

The skill text said detaching a stale PR "is also how you unblock auto-archive",
which reads as an invitation to detach them all. A worktree with no live
bindings falls back to archiving on the next merge it observes (a documented
residual of the design), so that gesture does the opposite of what an agent
reaching for it intends. The useful advice stays; the text now names the
supported way to suppress auto-archive.
…bindings

Multi-PR moved every PR surface off `AppState.prStatuses` onto
`prBindings`, which silently dropped the toolbar control and the sidebar
indicator for a worktree that HAS a persisted status but NO bindings.
That is a resting state, not a startup blip: with `gh` unavailable or
unauthenticated the daemon still hydrates `Worktree.prStatus`, but no
bind can resolve a repo, so the bindings table stays empty forever and
the user's last-known PR state just disappears. The same happens
transiently on the first launch after upgrade.

`PRBindingPresentation.effectiveBindings` now decides in one place —
bindings when there are any, else the legacy status lifted into one
synthetic binding, else nothing — and the toolbar, the sidebar row and
the status-bar chips all read it through `AppState.effectivePRBindings`,
so they cannot disagree. The synthetic binding is value-stable (its id
is the worktree's UUID, its boundAt a fixed sentinel) because it feeds
ForEach identity, the split button's `.id` key and SwiftUI's own view
diffing.

Also:

- Fold `PRStatus.reason` and `headBranch` into `prSplitButtonID`. Both
  are rendered into every menu row title by `menuRows`, and AppKit
  materializes that NSMenu once — so "1 check failing" becoming "3 checks
  failing" under an unchanged `.checksFailed` left the stale title on
  screen. Two comments and one test asserted the opposite; they were
  wrong and are corrected.
- Run `refreshPRBindings`' per-worktree fetches concurrently in a task
  group and apply them as one batch, instead of 40 serial round trips
  per poll on a 40-worktree fleet. A failed fetch still keeps the
  previous value; an empty result still drops the key.
- Add the missing explicit `privacy:` on that path's logger call.
… stop heredoc bodies opening the bind gate

Two second-round review findings on multi-PR bindings.

H1 — the all-resolved trigger judged only the SET of bindings, never whose
work they were. A hook binding is spared the head-ref heal and refresh
re-queries it by number, so a PR a subagent opened on its own branch (or one
opened by `cd ../other-worktree && gh pr create`, which binds to the CURRENT
worktree) stayed attached forever. With that as a worktree's only binding, its
merge fired auto-archive on a worktree whose own branch never had a PR and
whose agent was still running — something the single-PR path could never do,
because no branch matched and no status ever moved. That invalidated the
"strictly more conservative" argument the feature ships unflagged on.

The trigger now additionally requires at least one MERGED binding to be the
worktree's own work: its head branch is one of the worktree's branch
candidates (`PRStatusManager.candidatesFor` — the same derivation the matcher
and the heal share), or its number is the worktree's `Worktree.prNumber`. The
number arm is required, not belt-and-braces: a fork PR row's head branch
belongs to the fork and matches nothing local. A merged binding with no
observed head branch satisfies neither arm — unknown holds the gate shut, as a
nil status already does. The rule is a pure function beside `allResolved`; the
poll passes the two facts in.

H4 — the `gh pr create` gate split on newlines, so a heredoc BODY line reading
`gh pr create …` opened it and any PR URL in the command's output bound. The
tokenizer now skips heredoc bodies to their terminator (quoted, unquoted, and
`<<-` with a tab-indented one), and keeps failing closed: an unterminated
heredoc, or a `<<` that is not one, loses a bind rather than inventing one.

Tests: 7 new trigger scenarios (own-branch merge fires; a foreign-branch
subagent merge does not; own + open, own + merged, own + closed; provenance
number; unobserved head branch; push-branch candidate), 4 pure ownership-rule
tests, 6 heredoc extractor tests. Mutation-checked: dropping the ownership
conjunct reddens exactly the two tests that assert it and nothing else.

Existing `AllResolvedTriggerTests` cases encoded the old weaker trigger — they
bound PRs with no head branch at all — so the harness now binds on the
worktree's own branch by default, which is what those cases always meant.
Five review findings on the multi-PR work.

**Detach was invisible to the app (regression from the legacy-status
fallback).** `effectiveBindings` fell back to the worktree's cached single
`prStatus` whenever the binding list was empty — but "empty" is exactly the
state `tbd pr detach` produces on a worktree's last PR, since tombstones are
excluded from `pr.bindings` and nothing ever clears `Worktree.prStatus`. The
toolbar split button, sidebar dot and status-bar chip kept showing the
detached PR indefinitely. The app could not tell "no bindings because binding
is impossible" (offline `gh`, the case the fallback exists for) from "no
bindings because the user removed them", so the distinction now travels on the
wire: `PRBindingsResult.detachedCount`, optional so older responses still
decode, populated from one `includeDetached: true` read. A non-zero count
suppresses the fallback.

**Unbounded concurrent blocking RPCs.** `refreshPRBindings` added one task-group
child per worktree — ~40 on a full fleet, every poll — and the comment
justifying it was wrong: `DaemonClient.sendRawAsync` escapes actor isolation
with `Task.detached` onto a `private nonisolated sendRaw` that opens a fresh
socket per request, so nothing serializes them. Nothing interleaves on the wire
either, but ~40 blocking `connect`/`recv` loops land on the cooperative pool,
bounded only by the 300 s recv deadline. The group is now windowed at 6 via
`mapConcurrently`, and the comment describes what actually holds. The
documented two-branch contract (a FAILED fetch keeps the previous value, an
EMPTY result drops the key) moves into `PRBindingRefresh.merge` and finally has
tests.

**A lone binding with an unparseable URL was a dead control** — it fell into the
several-PR shape while the menu still gated its rows on `count > 1`, so the
label read `#412` and nothing anywhere offered that PR. `prPrimaryActionURL`
now decides the branch, the menu renders rows whenever there is no primary
action, and the tooltip drops its "Open" promise.

**`prSplitButtonID` could be forged.** It joined free-text `reason` and
`headBranch` with `-` and `|`, and git permits `|` in a branch name; since
AppKit materializes the menu once per key, a collision freezes the menu on the
stale set. Components are escaped injectively, with an unreachable sentinel for
absent values so a literal "nil" stays distinct.

**The `+N` overflow chip's wording** implied it listed only the overflowed PRs;
it has always listed all of them. Wording fixed, behaviour unchanged.

Tests: +26 in TBDAppTests (2017 → 2043), +1 in TBDDaemonTests (2740 → 2741).
…is pass observed

Four small review findings on the multi-PR-per-worktree branch.

A detached-then-re-attached binding could not fire the merged-transition
fan-out again. `AllResolvedMergeTrigger.evaluate` clears its own once-only
guard, but the poll never calls `evaluate` for a worktree with no live
bindings — so a worktree that left the bound population kept a fired-guard
nothing could clear, and `tbd pr attach` was a silent no-op. The poll now
reports its whole polled population through `retainBound(polled:bound:)`
before the no-bindings early return, so leaving the bound population re-arms
the worktree. The doc comment already promised this.

The ownership check read a stale `headBranch` for one poll: the refreshed
binding handed to the trigger carried the pre-pass refs while the row got the
fresh ones. `PRBinding.withObservation(status:headBranch:baseRef:)` replaces
`withStatus` as the single fold — nil still means "not observed", never
"cleared" — and `RPCRouter.folding(_:onto:)` applies it to both the row and
the value the merge rule judges.

The fan-out can legitimately fire twice in one pass (the un-bound fallback
fires inside `fetchAll`, before the pass creates the binding that `evaluate`
then judges), which is harmless only because both coordinators are
idempotent. Two regression tests pin that: a doubled fan-out archives once and
notifies once, and a doubled fan-out with archive blocked by children parks
once and notifies once.

Finally, the hook prefilter's cost claim said "one grep" in the spec and in
`ClaudeHookOverlay`; the command is `payload=$(cat); printf … | grep -qE …`.
Both now say what actually runs — the intent (no `tbd` spawn, no daemon round
trip) is unchanged.
…he browser

The app only asked for `pr.bindings` on worktrees it already believed had a
PR — one with a branch-derived status, an existing binding, a tombstone, or
the current selection. A worktree whose only PR was bound by the
`gh pr create` hook sits on a branch it never checked out, so it appears in no
branch-derived status cache and that target set never named it: the PR stayed
invisible until the user selected the worktree once. Measured on a live daemon,
`pr.list` returns zero statuses, which collapses the set to the selection alone.

A new `pr.bindingsAll` takes no worktree parameter and reports the whole
binding table from one indexed read, so the app replaces its published maps
wholesale each poll instead of fanning out per worktree. That also gives a poll
one outcome rather than N: a failure keeps the previous maps intact, and a
worktree absent from a success loses its entry, which is how a detach is
observed. The windowed fan-out, its `PRBindingFetchOutcome` fold and the
`mapConcurrency` helper it needed all go away as dead code.

Separately, the status-bar PR chips and their `+N` overflow menu now open the
default browser rather than an in-app webview tab, agreeing with the sidebar
row indicator. The toolbar split button and its dropdown rows still route
through `AppState.openPR` and still open tabs — the status bar is an
at-a-glance strip where a click means "take me to GitHub", while the toolbar
is where a PR gets parked as a tab.
@cheapsteak
cheapsteak force-pushed the multi-pr-per-worktree branch from e22b1ed to ad08ae3 Compare August 11, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant