#4509 · Thread panel PR selection is transient
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The GitHub thread panel displays a selected pull request until the panel is remounted. The selection handler only changes local React state. A focused mounted-panel test shows that choosing a PR sends no save request on trusted main, and the same test fails in a second clean checkout. The server can resolve saved plugin links, but this picker has no path to write one.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Picking a PR shows it without saving a thread association. | Verified | The mounted panel test records no link call after a click; the handler only calls setSelected. |
| Returning to the panel loses the manual selection. | Verified by code path | Fresh component state starts empty and re-resolves from the server, which has no new link. A full browser tab-switch recording was not needed for this functional persistence failure. |
| The server reads branch PRs and existing plugin links. | Verified | pullForThread checks the environment first, then stored PR links. |
| Existing threads without a branch PR cannot acquire a saved PR from this panel. | Verified | The picker exposes no persistence call or matching write RPC. |
3. Environment
- Trusted bb main commit:
ea49535e06e83a0df0b11b56b8280cc2e1f5addd; GitHub plugin 0.2.1. - Linux x86_64, Node 26.8.1, pnpm 9.15.0, Vitest 4.1.1.
- No provider, live server, port, or data directory was used. The mounted-panel test runs under jsdom with a fake plugin RPC boundary.
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run buildpassed in each checkout.
4. Minimal reproduction
- Check out
ea49535e06e83a0df0b11b56b8280cc2e1f5adddfrom trustedorigin/main. Run the frozen install and Turbo build above. - Copy the focused test into
plugins/github/selected-pull.test.tsx. - From the repository root, run
pnpm exec turbo run test --filter=bb-plugin-github -- selected-pull.test.tsx.
The test mounts the actual plugin thread panel, gives it one PR, clicks that row, and records attempted link requests. Expected: one request identifying thread thr-1 and PR get-bb/bb#42. Actual:
AssertionError: expected [] to deeply equal [ { threadId: 'thr-1', repo: 'get-bb/bb', number: 42 } ]
Test Files 1 failed (1)
Tests 1 failed (1)
Complete reproduction test:
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { act } from "react";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
const app = await loadPluginApp(() => import("./app"));
describe("GitHub app navigation", () => {
it("saves a selected pull request for the thread before showing it", async () => {
const saved: Array<{ threadId: string; repo: string; number: number }> = [];
const slot = renderSlot(
app.threadPanelActions[0]!,
{ threadId: "thr-1", params: null },
{
rpc: {
pullForThread: () => ({ pull: null }),
listItems: () => ({
items: [{
repo: "get-bb/bb",
number: 42,
kind: "pr",
title: "A pull request",
state: "OPEN",
author: "octocat",
labels: [],
assignees: [],
url: "https://github.com/get-bb/bb/pull/42",
body: "",
updatedAt: "2026-08-20T00:00:00.000Z",
}],
}),
linkPullToThread: (input: { threadId: string; repo: string; number: number }) => {
saved.push(input);
return { ok: true };
},
getPull: () => Promise.reject(new Error("Detail not needed for this test")),
listLinks: () => ({ links: {} }),
},
},
);
const pick = await slot.findByRole("button", { name: /A pull request/ });
await act(async () => pick.click());
expect(saved).toEqual([{ threadId: "thr-1", repo: "get-bb/bb", number: 42 }]);
slot.lifecycle.unmount();
});
});
5. Root cause
The picker handler only performs a local state update:
onPick={(repo, number) => setSelected({ repo, number, environmentId: null })}
The panel mount effect initializes fresh state and reads pullForThread. The server resolver returns a branch PR first and otherwise searches stored link:pr:… records. The link writer is used when a review thread is spawned, but there is no picker write RPC. The transient selection therefore cannot survive remounting or appear in linked-thread pills.
6. Proposed fix and simple-fix gate
Add a supported thread-to-PR link mutation shared by UI, SDK RPC, and CLI; persist through the plugin's existing link mechanism; make replacement and removal update both the thread panel and PR linked-thread list. Define whether a manual selection overrides a branch-derived PR and what unlinking that branch-derived association means. The existing resolver always returns a branch PR first, so simply adding a save call would leave change and unlink broken. That policy decision and its persistence semantics exceed this run's simple-fix gate; no fix branch or PR was pushed.
7. Related issues
No linked open pull request was found for #4509 in GitHub issue metadata or the current open PR list. No related issue was confirmed for this specific persistence path.
8. Verification
The same agent created a second clean temporary checkout at ea49535e06e83a0df0b11b56b8280cc2e1f5addd, installed and built it separately, copied the same reproduction artifact, and ran the same focused Turbo test. It failed with the same expected [] assertion. No report claim needed correction. The separate checkout used no BB runtime data, provider, server, or network port.
9. Appendix
Commands run in each checkout:
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-github -- selected-pull.test.tsx
Both builds passed; both focused tests failed at the missing save request. The issue description was treated as untrusted claims; no issue-supplied code, URL, or branch was executed.