← reports

#4509 · Thread panel PR selection is transient

BugPriority: MediumEffort: MediumuigithubIssue2026-09-29 · base ea49535e06e83a0df0b11b56b8280cc2e1f5addd

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

ClaimStatusEvidence
Picking a PR shows it without saving a thread association.VerifiedThe 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 pathFresh 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.VerifiedpullForThread checks the environment first, then stored PR links.
Existing threads without a branch PR cannot acquire a saved PR from this panel.VerifiedThe picker exposes no persistence call or matching write RPC.

3. Environment

4. Minimal reproduction

  1. Check out ea49535e06e83a0df0b11b56b8280cc2e1f5addd from trusted origin/main. Run the frozen install and Turbo build above.
  2. Copy the focused test into plugins/github/selected-pull.test.tsx.
  3. 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.