Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/plugins.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [kubernetes-provider, workstreams]
plugin: [kubernetes-provider, thread-briefs, workstreams]
defaults:
run:
working-directory: plugins/${{ matrix.plugin }}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Plugins for [bb](https://getbb.app), maintained by Bit Complete.
|---|---|
| [workstreams](plugins/workstreams) | Maps every git checkout you have in flight into a zoomable board of cross-repo efforts |
| [kubernetes-provider](plugins/kubernetes-provider) | Machine provider that runs each project on its own long-lived pod plus persistent volume in the bb server's namespace |
| [thread-briefs](plugins/thread-briefs) | Gives every thread a durable goal / current state / next step brief, summarized outside the working chat |

Install one:

Expand Down
2 changes: 2 additions & 0 deletions plugins/thread-briefs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
74 changes: 74 additions & 0 deletions plugins/thread-briefs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Thread briefs

Threads in the bb sidebar are opaque — a truncated title, and re-entering one
means re-reading the transcript to remember what it was for. This plugin gives
every thread a short, durable **brief**, generated outside the working chat:

- **goal** — what the thread is actually trying to achieve
- **currentState** — what exists now, including half-done work
- **nextStep** — the single most concrete next action, or empty when done
- **blockedOn** — who or what it is waiting on
- **constraints** — facts learned in the thread that would break a naive re-plan

Plus a derived **stage** (discovery / planning / implementation / review) and
**status** (working / waiting-on-me / waiting-on-other / done).

## Install

```sh
bb plugin install git:https://github.com/bitcomplete/bb-plugins.git@main --plugin thread-briefs
bb plugin config thread-briefs set apiKey <key>
```

The summarizer talks to any OpenAI-compatible `/chat/completions` endpoint. See
[`skills/thread-briefs/SKILL.md`](skills/thread-briefs/SKILL.md) for every
setting, the regeneration triggers, and how to diagnose it.

## Where briefs show up

**Sidebar row** — a single glyph for the three statuses that are news:
waiting-on-you, blocked, and done. A thread that is merely being worked on keeps
bb's own indicator, because bb draws a plugin row status in place of its
unsent-draft pencil and displacing that everywhere would cost more than it says.

**Thread header** — a **Brief** control opens the full five fields (empty ones
are skipped), the derived status, a stage control for the manual override, and
Re-summarize. It works the same on mobile and desktop; nothing depends on hover.

## How it is built

| Concern | Mechanism |
| --- | --- |
| Trigger | `bb.events.on("thread.idle")` + a per-thread quiet-period debounce, with a `*/10 * * * *` sweep as the backstop |
| Summarizer input | `threads.conversationOutline()` head + tail with the middle elided, `threads.output()` for the last message in full, and the previous brief |
| Storage | `bb.storage.kv`, one row per thread at `brief:<threadId>` |
| Sidebar glyph | a content script's `experimental_setThreadRowStatus`, fed by an `experimental_appOverlay` that owns the rpc + realtime subscription |
| Header UI | `experimental_threadHeaderAction` with a portalled popover |

### Why a content script rather than a list fork

bb exposes exactly one slot that owns thread rows,
`app.slots.experimental_threadList`, and it is *exclusive* — a plugin either
replaces the whole sidebar list or touches none of it. The per-row hooks
(`useSidebarThreadDraft`, `useSidebarThreadRowStatus`, …) are for a replacement
list to *consume*, not injection points into bb's own list. Inline row
expansion therefore means forking `plugins/thread-list` (~28k lines) and
re-merging it forever, so this plugin uses the two additive surfaces bb
supports instead: a row glyph and a header popover.

### Why `thread.idle` rather than polling

`bb.events.on("thread.idle", …)` delivers the thread DTO on every transition
into idle, which is the turn-completion signal a poll would be approximating.
Polling remains only as the 10-minute sweep, for activity whose event never
arrived — a server restart, a plugin reload, or a turn that ended in `error`
rather than idle.

## Development

```sh
npm install
npm run typecheck
npm test
npm run build
```
275 changes: 275 additions & 0 deletions plugins/thread-briefs/app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor } from "@testing-library/react";
import {
loadPluginApp,
mountPluginContentScripts,
renderSlot,
type CapturedPluginApp,
} from "@get-bb/plugin-sdk/testing/app";
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
import type { BriefState, RowSignal } from "./contract.js";

const READY: BriefState = {
state: "ready",
brief: {
threadId: "thr_1",
goal: "Ship the thread-briefs plugin",
currentState: "Server and app written",
nextStep: "Push the branch",
// Empty fields must not render a heading.
blockedOn: "",
constraints: "bb has no additive per-row sidebar slot",
stage: "review",
status: "waiting-on-me",
stageOverride: null,
lastSummarizedAt: 1_000,
},
};

const sidebarThread = (
overrides: Partial<PluginSidebarThread>,
): PluginSidebarThread =>
({
id: "thr_1",
hasPendingInteraction: false,
...overrides,
}) as PluginSidebarThread;

let app: CapturedPluginApp | null = null;
const loadApp = async () => {
app ??= await loadPluginApp(() => import("./app.js"));
return app;
};

afterEach(() => {
vi.restoreAllMocks();
});

describe("registrations", () => {
it("registers the overlay, header action and content script", async () => {
const captured = await loadApp();
expect(captured.appOverlays.map((entry) => entry.id)).toEqual(["brief-sync"]);
expect(captured.threadHeaderActions.map((entry) => entry.id)).toEqual(["brief"]);
expect(captured.contentScripts.map((entry) => entry.id)).toEqual(["row-glyphs"]);
// Nothing may register a thread list: replacing bb's sidebar is out of scope.
expect(captured.threadLists).toEqual([]);
});
});

describe("the header popover", () => {
const render = async (options: {
getBrief?: () => BriefState;
setStageOverride?: (input: unknown) => BriefState;
refresh?: () => { queued: boolean };
}) => {
const captured = await loadApp();
return renderSlot(
captured.threadHeaderActions[0]!,
{ threadId: "thr_1", projectId: "proj_1", isCompactViewport: false },
{
rpc: {
getBrief: options.getBrief ?? (() => READY),
setStageOverride: options.setStageOverride ?? (() => READY),
refresh: options.refresh ?? (() => ({ queued: true })),
listRowSignals: () => ({ signals: [] }),
},
},
);
};

it("shows the populated fields and skips the empty ones", async () => {
const slot = await render({});
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));

expect(await slot.findByText("Ship the thread-briefs plugin")).toBeTruthy();
expect(await slot.findByText("Server and app written")).toBeTruthy();
expect(await slot.findByText("Push the branch")).toBeTruthy();
expect(slot.queryByText("Blocked on")).toBeNull();
expect(await slot.findByText("Waiting on you")).toBeTruthy();

slot.lifecycle.unmount();
});

it("does not fetch the brief until the popover is opened", async () => {
const slot = await render({});
await waitFor(() => expect(slot.inspection.rpcCalls).toEqual([]));
slot.lifecycle.unmount();
});

it("says so while a brief is still being summarized", async () => {
const slot = await render({ getBrief: () => ({ state: "summarizing" }) });
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));
expect(await slot.findByText("Summarizing…")).toBeTruthy();
slot.lifecycle.unmount();
});

it("surfaces the unconfigured message instead of an empty brief", async () => {
const slot = await render({
getBrief: () => ({ state: "unconfigured", message: "Add an API key." }),
});
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));
expect(await slot.findByText("Add an API key.")).toBeTruthy();
slot.lifecycle.unmount();
});

it("reports a thread with no next step as done rather than inventing one", async () => {
const slot = await render({
getBrief: () => ({
state: "ready",
brief: { ...READY.brief!, nextStep: "", status: "done" },
}),
});
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));
expect(await slot.findByText("No next step — this thread reads as done.")).toBeTruthy();
slot.lifecycle.unmount();
});

it("sets a manual stage", async () => {
const slot = await render({});
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));
fireEvent.click(await slot.findByRole("button", { name: "Planning" }));

await waitFor(() =>
expect(
slot.inspection.rpcCalls.some(
(call) =>
call.method === "setStageOverride" &&
(call.input as { stage: string }).stage === "planning",
),
).toBe(true),
);
slot.lifecycle.unmount();
});

it("clears the override when the active manual stage is picked again", async () => {
const overridden: BriefState = {
state: "ready",
brief: { ...READY.brief!, stage: "planning", stageOverride: "planning" },
};
const slot = await render({ getBrief: () => overridden });
fireEvent.click(await slot.findByRole("button", { name: "Thread brief" }));
fireEvent.click(await slot.findByRole("button", { name: /Planning/u }));

await waitFor(() =>
expect(
slot.inspection.rpcCalls.some(
(call) =>
call.method === "setStageOverride" &&
(call.input as { stage: string | null }).stage === null,
),
).toBe(true),
);
slot.lifecycle.unmount();
});
});

describe("sidebar row glyphs", () => {
const signal = (overrides: Partial<RowSignal> = {}): RowSignal => ({
threadId: "thr_1",
status: "done",
stage: "review",
label: "Done — Review",
...overrides,
});

/**
* The overlay owns the data and the content script owns the setter, so a
* realistic test mounts both against one loaded app.
*/
const mountBoth = async (options: {
signals: RowSignal[];
threads?: PluginSidebarThread[];
omitSetter?: boolean;
}) => {
const captured = await loadApp();
const scripts = await mountPluginContentScripts(captured, {
pluginId: "thread-briefs",
...(options.omitSetter === true
? { omitExperimentalThreadRowStatus: true }
: {}),
});
const slot = renderSlot(
captured.appOverlays[0]!,
{},
{
rpc: { listRowSignals: () => ({ signals: options.signals }) },
sidebarThreads: {
threads: options.threads ?? [sidebarThread({ id: "thr_1" })],
},
},
);
return { scripts, slot };
};

it("paints the done glyph on the row", async () => {
const { scripts, slot } = await mountBoth({ signals: [signal()] });

await waitFor(() =>
expect(scripts.inspection.getThreadRowStatus("thr_1")).toEqual({
icon: "CircleCheck",
label: "Done — Review",
tone: "success",
}),
);

slot.lifecycle.unmount();
await scripts.lifecycle.dispose();
});

it("leaves a merely working thread to bb's own indicator", async () => {
const { scripts, slot } = await mountBoth({
signals: [signal({ status: "working", label: "Working — Review" })],
});

await waitFor(() => expect(slot.inspection.rpcCalls.length).toBeGreaterThan(0));
expect(scripts.inspection.getThreadRowStatus("thr_1")).toBeNull();

slot.lifecycle.unmount();
await scripts.lifecycle.dispose();
});

it("upgrades a working thread whose row has a pending interaction", async () => {
const { scripts, slot } = await mountBoth({
signals: [signal({ status: "working", label: "Working — Review" })],
threads: [sidebarThread({ id: "thr_1", hasPendingInteraction: true })],
});

await waitFor(() =>
expect(scripts.inspection.getThreadRowStatus("thr_1")).toEqual({
icon: "MessageQuestion",
label: "Waiting on you — Review",
tone: "default",
}),
);

slot.lifecycle.unmount();
await scripts.lifecycle.dispose();
});

it("clears its glyphs on dispose", async () => {
const { scripts, slot } = await mountBoth({ signals: [signal()] });
await waitFor(() =>
expect(scripts.inspection.getThreadRowStatus("thr_1")).not.toBeNull(),
);

slot.lifecycle.unmount();
await scripts.lifecycle.dispose();

// No glyph may survive the generation, whether the host cleared it or the
// script's own disposer did.
expect(scripts.inspection.getThreadRowStatus("thr_1")).toBeNull();
});

it("does not throw on a host without the row-status setter", async () => {
const { scripts, slot } = await mountBoth({
signals: [signal()],
omitSetter: true,
});
expect(scripts.inspection.mountedIds).toEqual(["row-glyphs"]);
expect(scripts.inspection.threadRowStatusCalls).toEqual([]);

slot.lifecycle.unmount();
await scripts.lifecycle.dispose();
});
});
Loading
Loading