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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ All settings live in **Settings → Extensions → Advisor**.
| Review timeout | 2 minutes | 30 seconds to 10 minutes. Exceeding it reports unavailable, never a pass. |
| Transcript budget | 60,000 characters | 20,000 to 120,000. |

### Enable Advisor for one thread

Use the Advisor switch beside the model controls in an existing thread's
composer. A thread's choice overrides **Enable advisor** in either direction:
you can turn one thread off while the default is on, or turn one thread on
while the default is off. New threads follow the global setting.

Switching a thread off stops automatic reviews, advice injection, and automatic
corrective turns, including a correction from a review already in progress.
**Review now** and **Fix in new turn** remain available when explicitly requested.
An agent session that still has the review tool receives an explicit skipped
review response when it calls the tool while the thread is off.

The same controls are available from the CLI. Omit the thread id when running
inside that thread. Use `follow` to clear its override and track the global
setting again:

```sh
bb advisor enable [thread-id]
bb advisor disable [thread-id]
bb advisor follow [thread-id]
```

### Reviewer model, per machine

The model section loads the live provider/model catalog independently from
Expand Down
179 changes: 178 additions & 1 deletion app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";

Expand Down Expand Up @@ -74,6 +74,183 @@ function panel(reviews: unknown[], extra: Record<string, unknown> = {}) {
};
}

// Looked up by id rather than by position: the switch and the pending-advice
// banner are separate registrations, and an index would silently follow a
// reorder into the wrong one.
const switchCustomization = app.composerCustomizations.find(
(customization) => customization.id === "advisor-switch",
)!;
const toggleSlot = switchCustomization.actions![0]!;
const threadComposer = {
scope: { kind: "thread", threadId: "t1" } as const,
};
describe("advisor thread switch", () => {
it("reports the effective state of a thread that follows the default", async () => {
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: {
threadToggle: () => ({
enabled: false,
override: null,
globalEnabled: false,
}),
},
});

const control = await q(slot).findByRole("switch");
// The switch shows what actually happens in this thread, not whether the
// user picked it — a thread following a disabled default reads as off.
expect(control.getAttribute("aria-checked")).toBe("false");
expect(control.getAttribute("aria-label")).toBe("Advisor off");
});

it("writes an explicit override for this thread when clicked", async () => {
let stored: { enabled: boolean | null } = { enabled: null };
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: {
threadToggle: () => ({
enabled: stored.enabled ?? true,
override: stored.enabled,
globalEnabled: true,
}),
setThreadToggle: (input) => {
stored = { enabled: input.enabled };
return {
enabled: input.enabled ?? true,
override: input.enabled,
globalEnabled: true,
};
},
},
});

fireEvent.click(await q(slot).findByRole("switch"));

await waitFor(() =>
expect(q(slot).getByRole("switch").getAttribute("aria-checked")).toBe(
"false",
),
);
expect(
slot.inspection.rpcCalls.filter((call) => call.method === "setThreadToggle"),
).toEqual([
{ method: "setThreadToggle", input: { threadId: "t1", enabled: false } },
]);
});

it("snaps back instead of claiming a switch the server rejected", async () => {
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: {
threadToggle: () => ({
enabled: true,
override: null,
globalEnabled: true,
}),
setThreadToggle: () => {
throw new Error("plugin unavailable");
},
},
});

fireEvent.click(await q(slot).findByRole("switch"));

// The advisor still runs, so the surface must not show an "off" switch.
await waitFor(() => expect(q(slot).queryByRole("switch")).toBeNull());
expect(
(await q(slot).findByRole("button")).getAttribute("title"),
).toContain("plugin unavailable");
});

it("names what it does on hover without waiting for the native tooltip", async () => {
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: {
threadToggle: () => ({
enabled: true,
override: null,
globalEnabled: true,
}),
},
});

expect((await q(slot).findByRole("tooltip")).textContent).toBe("Advisor on");
// A native `title` alongside it would surface a second, slower duplicate.
expect(q(slot).getByRole("switch").getAttribute("title")).toBeNull();
});

it("renders nothing in a composer scope it has no answer for", async () => {
const slot = renderSlot(toggleSlot, {}, {
composer: {
scope: {
kind: "side-chat",
projectId: "p1",
parentThreadId: "t1",
tabId: "tab1",
childThreadId: null,
} as const,
},
rpc: {
threadToggle: () => ({
enabled: true,
override: null,
globalEnabled: true,
}),
},
});

await waitFor(() => expect(slot.container.firstChild).toBeNull());
expect(slot.inspection.rpcCalls).toEqual([]);
});

it("does not offer the switch in a new-thread composer", async () => {
expect(switchCustomization.scopes).toEqual(["thread"]);
const slot = renderSlot(toggleSlot, {}, {
composer: { scope: { kind: "new-thread", projectId: "p1" } },
});
expect(slot.container.firstChild).toBeNull();
expect(slot.inspection.rpcCalls).toEqual([]);
});

it("ignores another click until the server confirms a toggle", async () => {
let enabled = true;
let finish: (() => void) | undefined;
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: {
threadToggle: () => ({ enabled, override: enabled, globalEnabled: true }),
setThreadToggle: async (input) => {
await new Promise<void>((resolve) => { finish = resolve; });
enabled = input.enabled;
return { enabled, override: enabled, globalEnabled: true };
},
},
});
const control = await q(slot).findByRole("switch") as HTMLButtonElement;
fireEvent.click(control);
expect(control.disabled).toBe(true);
fireEvent.click(control);
expect(slot.inspection.rpcCalls.filter((call) => call.method === "setThreadToggle"))
.toHaveLength(1);
await act(async () => { finish!(); });
await waitFor(() => expect(control.disabled).toBe(false));
expect(control.getAttribute("aria-checked")).toBe("false");
});

it("reloads a following thread when the global default changes", async () => {
let enabled = true;
const slot = renderSlot(toggleSlot, {}, {
composer: threadComposer,
rpc: { threadToggle: () => ({ enabled, override: null, globalEnabled: enabled }) },
});
expect((await q(slot).findByRole("switch")).getAttribute("aria-checked")).toBe("true");
enabled = false;
await slot.behavior.emitRealtime("advisor-settings-changed", {});
await waitFor(() => expect(q(slot).getByRole("switch").getAttribute("aria-checked")).toBe("false"));
});
});

describe("advisor header badge", () => {
it("keeps advertising an open blocker after a later turn passes", async () => {
// The whole point: a clean turn does not close an earlier finding, and the
Expand Down
122 changes: 116 additions & 6 deletions app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ function SeverityGlyph({
* of truth.
*/
function useThreadAdvisor<
Method extends "threadReviews" | "threadBadge" | "pendingAdvice",
Method extends "threadReviews" | "threadBadge" | "pendingAdvice" | "threadToggle",
>(
// Null on a composer scope with no thread yet: the rpc requires a non-empty
// id, so calling with a placeholder would guarantee a validation error.
Expand Down Expand Up @@ -432,11 +432,115 @@ function badgeStanding(
};
}

/**
* The fix for the plugin's biggest blind spot: a post-turn finding is injected
* into the next turn's instructions, so without this banner the agent changes
* course and the human is never told why.
*/
/** Keep state scoped to the thread even when the host reuses a composer. */
function AdvisorThreadToggle() {
const { scope } = useComposer();
if (scope.kind !== "thread") return null;
return <ThreadAdvisorSwitch key={scope.threadId} threadId={scope.threadId} />;
}

function ThreadAdvisorSwitch({ threadId }: { threadId: string }) {
const rpc = useRpc<Contract>();
const { data: state, error, reload } = useThreadAdvisor(threadId, "threadToggle");
const [pending, setPending] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [writeError, setWriteError] = useState<string | null>(null);

// Threads without an override also change when the global default changes.
useRealtime(
"advisor-settings-changed",
useCallback(() => { void reload(); }, [reload]),
);

const toggle = useCallback(async () => {
if (state === null || saving) return;
const next = !state.enabled;
setPending(next);
setSaving(true);
setWriteError(null);
try {
await rpc.call("setThreadToggle", { threadId, enabled: next });
} catch (caught) {
setWriteError(caught instanceof Error ? caught.message : String(caught));
} finally {
await reload();
setPending(null);
setSaving(false);
}
}, [rpc, threadId, state, saving, reload]);

const problem = writeError ?? error;
if (problem) {
return (
<button
type="button"
title={`Advisor switch unavailable (${problem}). Click to retry.`}
aria-label="Advisor switch unavailable, click to retry"
onClick={() => {
setWriteError(null);
void reload();
}}
className="inline-flex h-7 shrink-0 items-center rounded-md border border-dashed border-border px-1.5 text-subtle-foreground hover:bg-state-hover"
>
<SeverityGlyph standing="unavailable" className="size-3.5 shrink-0" />
</button>
);
}

// Nothing to show until the state is known: a switch that renders "off" while
// it loads would misreport a thread the advisor is actually running in.
if (state === null) return null;

const enabled = pending ?? state.enabled;
const hint = enabled ? "Advisor on" : "Advisor off";

return (
<span className="group relative inline-flex shrink-0">
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={hint}
disabled={saving}
onClick={() => void toggle()}
className={`inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-1.5 hover:bg-state-hover ${
enabled
? "border-border text-foreground"
: "border-dashed border-border text-subtle-foreground"
}`}
>
<SeverityGlyph standing="pass" className="size-3.5 shrink-0" />
{/* Track and knob are sized so both insets are 1px in either position:
the 28px track less its two 1px borders leaves 26px, and a 12px knob
offset by 1px travels exactly 12px. */}
<span
aria-hidden="true"
className={`relative h-4 w-7 shrink-0 rounded-full border transition-colors ${
enabled
? "border-success bg-success"
: "border-border bg-surface-recessed"
}`}
>
<span
className={`absolute left-px top-px size-3 rounded-full bg-background transition-transform ${
enabled ? "translate-x-3" : "translate-x-0"
}`}
/>
</span>
</button>
{/* Paint-only hover hint. `title` is deliberately absent here: the native
tooltip would arrive a second later and duplicate this one. */}
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-card px-2 py-1 text-2xs text-foreground opacity-0 shadow-md transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
{hint}
</span>
</span>
);
}

/** Show pending advice before it is injected into the next turn. */
function AdvisorComposerBanner() {
const composer = useComposer();
const navigate = useBbNavigate();
Expand Down Expand Up @@ -1302,4 +1406,10 @@ export default definePluginApp((app) => {
scopes: ["thread"],
banners: [{ id: "pending-advice", chrome: "bare", component: AdvisorComposerBanner }],
});

app.composer.customize({
id: "advisor-switch",
scopes: ["thread"],
actions: [{ id: "thread-toggle", component: AdvisorThreadToggle }],
});
});
2 changes: 1 addition & 1 deletion dist/app.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/app.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dist/app.meta.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"sdkMajor": 0,
"sdkVersion": "0.4.21",
"sdkVersion": "0.4.87",
"artifactFormatVersion": 1,
"pluginId": "advisor",
"pluginVersion": "0.1.0",
"builtWith": {
"bbVersion": "0.36.0",
"pluginSdkVersion": "0.4.21"
"bbVersion": "0.43.1",
"pluginSdkVersion": "0.4.87"
}
}
Loading