#3190 · Cross-tab root composer project reset
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The root composer's selected project is persisted in storage that subscribes to changes made by other tabs. If another tab receives a project ID absent from its settled but stale sidebar list, the composer converts that ID to the personal project and a synchronization effect writes the fallback back to shared storage. A focused component test reproduced that write on the trusted main commit, and the same failure occurred in a second clean checkout. This lets a stale tab undo a valid selection made elsewhere.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A stale tab can replace another tab's project selection with the personal project. | Verified | The regression test dispatches the same-origin storage event with a project absent from the stale tab's settled project list. Storage.prototype.setItem records one write of proj_personal. |
| The behavior is caused by the combination of cross-tab storage subscription, unknown-project normalization, and write-back. | Verified | Source tracing at the base commit identifies all three links, and the dynamic test reaches the final write. |
| The server can still contain the selected project while the stale tab lacks it. | Unverified | The minimal test intentionally isolates the client cache and storage boundary; no live server was needed to reproduce the defect. |
| The selection is visible for only a few frames before reverting. | Unverified | The unit-level reproduction verifies the state transition and write, not browser paint timing. |
3. Environment
- Trusted bb commit:
accd5595926b080a1e17d1ea9b2fa2d7d0505ac6 - OS: Darwin 25.6.0; Node: v22.22.3; Vitest: 4.1.1
- First checkout: existing isolated SlopCop worktree; second checkout: detached clean worktree at the same commit
- No server ports or data directory were used; this reproduction exercises the browser storage and React state path directly under jsdom.
4. Minimal reproduction
- Install and build the trusted checkout:
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Place the reproduction test file at
apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx. - Run from
apps/app:pnpm exec vitest run src/components/plugin/PluginNewThreadComposer.test.tsx --config vitest.config.ts
Focused test added to the existing integration harness:
it("ignores a root project selected by another tab", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
window.localStorage.setItem("bb.root-compose.project-id", "proj_1");
const router = createMemoryRouter(
[{ path: "/", element: <RootComposeView /> }],
{ initialEntries: ["/"] },
);
render(
<Provider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</Provider>,
);
await waitFor(() => {
expect(latestPromptBoxProps().project.value).toBe("proj_1");
});
const setItem = vi.spyOn(Storage.prototype, "setItem");
act(() => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "bb.root-compose.project-id",
oldValue: "proj_1",
newValue: "proj_other_tab",
storageArea: window.localStorage,
}),
);
});
await waitFor(() => {
expect(setItem).not.toHaveBeenCalledWith(
"bb.root-compose.project-id",
PERSONAL_PROJECT_ID,
);
});
setItem.mockRestore();
});
Expected: the stale tab does not write a fallback for a project selection originating in another tab.
Actual:
FAIL PluginNewThreadComposer seeding > ignores a root project selected by another tab AssertionError: expected "setItem" to not be called with arguments: [ 'bb.root-compose.project-id', 'proj_personal' ] Received: 1 call Test Files 1 failed (1) Tests 1 failed | 21 passed (22)
5. Root cause
root-compose-selection.ts:15–25 stores the selected project in a Jotai atom backed by createLocalStorageSyncStorage. That storage implementation supplies a subscription which listens for same-key storage events and forwards their values through the atom at browser-storage.ts:156–172.
NewThreadComposer.tsx:380–398 compares the requested project with the current tab's sidebar projects. Once that query is settled, an absent ID becomes the personal project. Finally, RootComposeView.tsx:695–698 notices that normalized value differs from the atom's remote value and persists it. The other tab receives that fallback through the same subscription, completing the clobber.
The deeper mismatch is scope: a composer's active project is tab-local navigation state, but its storage subscribes as if it were a user preference that should change live in every open tab.
6. Proposed fix (first principles)
Back the root composer project atom with the existing createTabScopedStorage helper. It preserves the last value through reloads in sessionStorage, seeds a new tab from localStorage, and deliberately has no storage-event subscription. This keeps persistence while preventing one open composer from adopting and normalizing another tab's selection. Add the failing test as the regression guard and clear session storage in its shared setup.
7. Verification
The same agent created a second detached checkout at accd5595926b080a1e17d1ea9b2fa2d7d0505ac6, performed the frozen install and full Turbo build, copied only the regression-test change, and reran the focused command. It failed identically: one forbidden write of proj_personal, with 1 failed and 21 passing tests. No report claim required correction after the second run.
8. Related issues
No related issue was independently established during this investigation.
9. Appendix
Commands run:
git fetch origin main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec vitest run src/components/plugin/PluginNewThreadComposer.test.tsx --config vitest.config.ts git worktree add --detach <clean-temp-path> accd5595926b080a1e17d1ea9b2fa2d7d0505ac6 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec vitest run src/components/plugin/PluginNewThreadComposer.test.tsx --config vitest.config.ts
The issue body and all GitHub-supplied content were treated as untrusted claims. No linked script, patch, branch, binary, or external URL was run.