Take bb's public APIs where they exist - #18
Open
ariofrio wants to merge 13 commits into
Open
Conversation
ariofrio
force-pushed
the
ariofrio/public-apis-where-they-exist
branch
from
August 22, 2026 19:00
4e7623f to
85f399c
Compare
The sidebar's project icons picked the bubble glyph by comparing a row's id to `proj_personal`, a literal that lives in bb's own domain package and that no public API promises to keep. The same decision is made two files away in ThreadFilter, and there it reads `project.isPersonal` — the flag the SDK puts on every sidebar project. `buildProjectIconMap` now takes the personal project's id and the caller reads it from bb, so the plugin holds no opinion about what that id is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The options menu wrote its two toggles by PUTting `/api/v1/plugins/ thread-stages/settings` from the browser. That route exists, but the plugin contract never mentions it, so the plugin also carried its own status-code handling and a fallback for a body that turns out not to be JSON. `useSettings()` reads and does not write, which puts the write on the server, where `bb.sdk.plugins.updateSettings` is the documented call. The menu now goes through an `updateSettings` RPC method whose input schema turns an unknown key into `invalid_input` before bb ever sees it, and the hand-rolled fetch leaves with `sidebar-settings.ts`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar read its project icons by POSTing the Icons plugin's own rpc route from the browser. The route shape is documented, but reaching into a neighbour with it is not, and it put another plugin's id and method in the frontend along with hand-parsed envelopes. `bb.sdk.plugins.callRpc` is the call bb ships for this, so `listProjectIcons` now makes it on the server and mirrors the neighbour's response in a schema that is deliberately not strict — the Icons plugin may grow fields, and an extra key is not this sidebar's business. `fetchProjectIcons` takes a loader instead of doing its own transport, so what happens when the neighbour is absent is now a test rather than a comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+Shift+S started a side chat by POSTing the Side chat plugin's own rpc route from the browser, with that plugin's id, method, and input shape spelled out in the frontend — including an `anchorText: ""` whose meaning belongs to the neighbour, not to a keyboard shortcut. bb ships `bb.sdk.plugins.callRpc` for one plugin to call another, so the call moves to the server behind this plugin's own `createSideChat`, which takes the one thing the shortcut actually knows: the thread the user is on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both plugins replay bb's own New thread command by synthesizing the keystroke bb listens for, which means reading bb's keybinding table first. They read it by fetching `/api/v1/system/config` from the browser — a route the plugin contract never points a plugin at, and one whose whole shape they then had to guard against by hand. `bb.sdk.system.config()` is the documented read, so each plugin now asks for it over its own `listAppKeybindings`, whose schema keeps the three fields a delegate uses and leaves bb's `when` rule to bb. The delegates themselves are untouched: they still take a `fetchConfig` returning `unknown` and still parse defensively, because a keybinding table is bb's to change. Synthesizing the keystroke remains private. This halves what that costs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filing the last Idle thread leaves nowhere to go, so the chord opens a new
one. It did that by writing bb's own `bb.root-compose.project-id`, faking the
storage event that makes bb notice, and then synthesizing bb's New thread
keystroke — three private steps to say "compose here".
`openNewThread({ projectId, focusPrompt })` is one public call that does all
three, and it is what bb's own sidebar calls. It is a hook, and the chords run
in a content script, so the mounted list lends it down: while the sidebar
draws, the chord uses bb's action, and without it the old keystroke still
opens the composer, now without arranging bb's state behind its back.
Routing the other destination needed to know which project is personal,
because that one routes without a project segment and the project path would
land on the composer instead. The server knows this publicly — a project's
`kind` — so it now answers with a null project rather than an id the frontend
had to recognize, and the chord's first behavioral test covers it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icons the personal project gets — a chat bubble, and no picker at all — were decided by comparing an id to `proj_personal`. That literal lives in bb's own domain package, no public API promises it, and the plugin was using it for an authorization decision: which project the user may not restyle. A project's `kind` is public, so the server reads it once per run and answers `listIcons` with the id bb reports. `setIcon` refuses that project rather than that string, the sidebar's content script — which has no hooks to ask with — takes the id from the same response, and the thread header asks the SDK directly, where `isPersonal` was already sitting on every sidebar project. `defaultIcon` and `isEditable` now take the personal project instead of recognizing it, so a project whose id merely looks personal keeps its icon, which is what the two new tests hold them to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ariofrio
force-pushed
the
ariofrio/public-apis-where-they-exist
branch
from
August 22, 2026 19:12
85f399c to
21c47d5
Compare
The chords run from a content script, so they fire wherever you are, but the composer step goes through bb's own "new thread in this project" action, and this plugin can only reach that while it is drawing the sidebar list. Set Settings → Sidebar to bb's built-in list and the difference shows up in one place: emptying Idle opens the composer on the project you last used instead of on none. That is a small enough gap to live with and a confusing enough one to hit unexplained, so it goes next to the sentence that promises the behavior, and into the release note that ships the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the side-chat call to the server dropped a step. The old frontend routine made two round trips — fork the thread, then persist its panel tab through this plugin's `ensureSideChatTab` — and the rewrite kept only the first. What was left wrote the tab to this client's storage and nowhere else. bb treats its own tab list as authoritative: `reconcileFixedPanelTabsState` rebuilds a thread's panel from the server's tabs whenever the two differ, and a `plugin-panel` tab is one it persists. Opening a thread at all commits bb's own Info and Git diff tabs to the server, so there is no fresh-thread case where the local list wins: the side chat's tab was dropped on every thread, sometimes before its composer was ever focused, leaving a hidden fork with nothing pointing at it. The next ⇧⌘L would fork another. The persist now happens inside `createSideChat`, next to the call it belongs to, which also drops the second round trip the frontend used to make. `ensureSideChatTab` had no caller left and goes with it, and `validateSideChat` goes back to pruning rows that exist. Four reviewers found this independently, one with a runtime trace; none of the tests did, because nothing covered what the shortcut leaves behind. One does now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more findings from the same reviews, none of them breakage today, all of them a way this branch could break quietly later. A chord moved the thread before it worked out where to send you, so a failed project lookup left the thread filed and the caller told the move had failed. The routing now resolves first: either the chord happens and the destination is right, or nothing happens at all. Both keybinding passthroughs validated bb's table as one array, so a single row bb changed would have cost the whole table and silently reverted the shortcut to its hardcoded default. They now drop the row they cannot read, the way the delegate reading them always has. Drawing icons used to be a local read that could not fail; asking bb which project is personal made it a call that can. A failed ask now costs the bubble on one row rather than every icon in the sidebar, while writing an icon still refuses rather than guessing. And `updateSettings` accepted the two settings that existed when it was written, of six. A control wired to any of the other four would have failed validation with a toast; a test now holds the RPC's keys to the plugin's own. The README's caveat said "with bb's built-in list selected", which is narrower than the truth — the composer step also falls back in the moment before the sidebar has loaded. Both it and the changeset now say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mirrored Icons schema said it was lenient because it allowed extra keys. That is the wrong axis: the fields it pins are the ones that plugin owns and may change. A third owner kind, or a color that stops being a string, and the whole answer fails to parse — costing every project icon in the sidebar, where before the diff the sidebar simply ignored a row it did not recognize. The envelope is now loose enough to reach a per-row filter, and the rows are read one at a time, which is what the consumer does with them anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar list lends the chords bb's "new thread here" action through a module-level slot, and cleared it unconditionally on unmount. bb mounts one thread list, so today that is the same instance either way — but a second mount followed by the first one's teardown would clear a slot still being used, and the chord would quietly stop preselecting for the rest of the session. Clearing only what is still ours costs a comparison. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eight commits that take bb's documented APIs everywhere these plugins had been reaching around them. No behavior changes — this is about how much of bb's insides three plugins hold onto while doing the same job.
This is the first phase of a coupling audit across all five plugins. The audit classified every call against bb's own
builtin-skills/bb-plugin-authoring/SKILL.md: public (documented there), experimental (theexperimental_-prefixed surface it documents but marks "Experimental: seedocs/api_to_audit.md"), and private (real, undocumented, ours to lose). This PR is only the private→public moves that were available today.Why this matters
The private surface is the part that breaks on a bb upgrade, silently and at a distance — a renamed
data-testid, a localStorage key that grows a field, a route that moves. Three of these were worse than the average:id === "proj_personal". That literal lives in bb'spackages/domain/src/project.ts; no public API promises it.bb.root-compose.project-id, dispatching a fakeStorageEventso bb's jotai atom would notice, then synthesizing bb's New thread keystroke. Three private steps to say "compose here."What changed
"proj_personal"→project.isPersonalfrom the sidebar SDKPUT /api/v1/plugins/…/settings→bb.sdk.plugins.updateSettingsbb.sdk.plugins.callRpcside-chatplugin'screateSideChatfetch("/api/v1/system/config")in both plugins →bb.sdk.system.config()StorageEvent+ synthetic keystroke →openNewThread({ projectId, focusPrompt })kind, resolved once per runTwo things a reviewer should push on
Every settings and cross-plugin call gained a server hop.
useSettings()reads but never writes, and there is no cross-plugin call on the app side at all —plugins.updateSettingsandplugins.callRpcexist only onbb.sdk. So each of these adds a thin RPC on our own server that makes the public call. That is a round trip we did not pay before, on user-initiated actions only (saving a setting, loading icons, starting a side chat). I think a documented call is worth a loopback hop; if you disagree aboutlistProjectIconsin particular — it runs on every sidebar project-set change — that is the one to argue about.The compose bridge has a fallback, and the fallback is still private.
openNewThreadis a hook; stage chords run in a content script, where hooks do not reach. The mounted sidebar list publishes the bound action into a module-level slot and the chord calls it. When the list is not mounted — someone switchedbb.sidebar.threadListProviderback to bb's own list — the chord falls back to bb's New thread command, so the composer still opens, just wherever bb left it rather than in the personal project. The alternative was keeping the private path as the fallback, which preserves the old behavior exactly and preserves the coupling exactly. I chose the degradation, and that is now a decision rather than a discovery: it is documented in the plugin's README next to the sentence that promises where the chords take you, and in the changeset that ships it.What stays private, deliberately
Missing keyboard shortcuts still writes
bb.root-compose.project-iditself: it has no persistent React surface to borrow a hook from, anduseBbNavigate().toCompose()takes noprojectId. Also untouched, because bb offers no equivalent: thread sections have no reorder anywhere in the SDK, REST, or CLI; there is no way to invoke a bb command (only to read the table, which this PR makes public);PluginContentScriptContexthands a script no navigation handle; no slot targets the thread header's title or a sidebar group label; anduseRealtimehard-filters on the calling plugin's id, so the cross-pluginbb.iconsbroadcast channel has no replacement.Those are the intended contents of a shim library, and — more usefully — the intended contents of five upstream API requests.
Verification
npm run release:checkin each touched plugin: thread-stages, missing-keyboard-shortcuts, icons — all exit 0 (lockfile verify, tests, typecheck,verify:types, build,verify:package).npm test: 27 pass, 0 fail.setWorkflowStagegets its first behavioral test, covering the personal-project routing rule.check:screenshotswill be redThe lock hashes each plugin's
src/, so any source change stales its shot even when no pixel can move. This branch stalesthread-stages,icons,missing-keyboard-shortcuts, andcollection. Recapturing needs macOS and the bb desktop app; happy to runnpm run screenshotsand push the result onto this branch before merge.Review round (three blind reviewers + one runtime report)
Three reviewers read this branch without my rationale, and a fourth thread hit the same defect from the other end while investigating why
npm run screenshotswas hanging. All four landed on one bug.e33e14fdropped a call. The oldcreateSideChatmade two round trips — fork the thread through the Side chat plugin, then persist the panel tab through this plugin'sensureSideChatTab. The rewrite kept only the first, so the tab was written tolocalStorageand nowhere else. bb rebuilds a thread's panel from its own server-side tab list (reconcileFixedPanelTabsState), and it commits Info/Git-diff tabs the first time any thread is opened — so the local list never wins and the side chat's tab was dropped on every thread, sometimes before its composer was focused. It also blocked the screenshot harness at shot 5 of 6, which is why no full capture existed on this branch.Fixed by moving the persist inside the server's
createSideChat, with a regression test on the handler. The orphanedensureSideChatTabis gone, andvalidateSideChat's cleanup prunes rows that exist again.Four more findings fixed in the same pass, none of them broken today:
listIconscould reject outrightupdateSettingsaccepted 2 of the plugin's 6 settingsengines.bbchecked, not bumped. Every API this branch newly uses —isPersonal,openNewThread,projects.list({includePersonal}),plugins.callRpc,plugins.updateSettings,system.config— exists atdesktop-v0.35.1, the oldest tag at or below both declared floors.Known and deliberately left: while the icons load, the personal project renders as an ordinary editable row and its picker's first pick is refused server-side. It self-corrects on the first response.
Written by Claude