Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
88e746b
Let plugins open app routes, run app commands, and omit homepage titles
brsbl Oct 6, 2026
a7ac76b
Add a built-in Tips plugin with one contextual tip on the New thread …
brsbl Oct 6, 2026
8b4e770
Reserve bb--tips and limit app commands to navigation
brsbl Oct 6, 2026
3d862f0
Show three illustrated tips under the composer with a placeholder pre…
brsbl Oct 6, 2026
c728aca
Keep provider ids out of Tips and type the reset handler
brsbl Oct 6, 2026
861fbd7
Return literal ok results from every Tips RPC handler
brsbl Oct 6, 2026
98ffca2
Drop tile action labels, give Tips more room, and stage written-tip s…
brsbl Oct 6, 2026
123cbfb
Make Tips text-only cards with controls in a slim footer row
brsbl Oct 6, 2026
fd7ef7f
Give Tips cards an icon chip and a raised surface
brsbl Oct 6, 2026
95037e1
Set Tips further below the composer
brsbl Oct 6, 2026
c0866dc
Lighten Tips cards and icon chips
brsbl Oct 6, 2026
f28f5d4
Add paper and glossy diorama explorations to the Tips stories
brsbl Oct 6, 2026
35bc7b5
Add a floating diorama exploration to the Tips stories
brsbl Oct 6, 2026
4ffe20a
Remove the diorama explorations from the Tips stories
brsbl Oct 6, 2026
4582c4b
Tint each Tips icon chip with its own soft hue
brsbl Oct 6, 2026
33e3e45
Give every Tips card a small static illustration
brsbl Oct 6, 2026
529f521
Pass the accent to Tips illustration dots through their fill prop
brsbl Oct 6, 2026
12f94ff
Show Tips as a feed with Hide tips, on by default only for new installs
brsbl Oct 6, 2026
a852235
Match the Tips off message in the CLI test
brsbl Oct 6, 2026
0b14b72
Play each tip illustration once while its row is hovered or focused
brsbl Oct 6, 2026
3b0dac7
Keep the tips feed fresh per visit and add a typed tip system
brsbl Oct 6, 2026
6002e92
Rename Branch endpoints and align server tests with the per-visit feed
brsbl Oct 6, 2026
663310d
Lead the empty home with setup, ask for notifications in context, and…
brsbl Oct 8, 2026
5c9eca4
Keep the notification prompt clear of the composer
brsbl Oct 8, 2026
caff911
Mark the telemetry SDK method experimental and stop re-asking once pe…
brsbl Oct 8, 2026
61d56ba
Remove the welcome page so setup leads the empty home and the compose…
brsbl Oct 8, 2026
98a852d
Hold the empty home blank while setup data loads instead of flashing …
brsbl Oct 8, 2026
ae67534
Keep the home layout as on main so the welcome-page removal can land …
brsbl Oct 8, 2026
7746f91
Merge the checklist-led home and onboarding telemetry branch into Tips
brsbl Oct 8, 2026
4ef8dd6
Show tips only after setup and report anonymous tip events
brsbl Oct 8, 2026
8a8a1cb
List the setup flag among documented section props and await the tele…
brsbl Oct 8, 2026
ef2d0d2
Merge remote-tracking branch 'origin/main' into bb/checklist-led-home…
brsbl Oct 8, 2026
28d23b0
Retrigger CI after merging main
brsbl Oct 8, 2026
c9ac259
Merge the banner-only setup checklist into Tips
brsbl Oct 8, 2026
f425a95
Pass setupComplete where the setup checklist state is built by hand
brsbl Oct 8, 2026
a3d7a57
Move the notification prompt into the sidebar footer next to the upda…
brsbl Oct 8, 2026
cb51b9f
Read the sidebar notification prompt state at mount so a stale value …
brsbl Oct 8, 2026
d8412f5
Call them child threads in Tips
brsbl Oct 9, 2026
aeb4e12
Show the notification prompt as a sidebar card, let × skip one setup …
brsbl Oct 9, 2026
1b95688
Order tips by tier, then context and priority
brsbl Oct 9, 2026
24d12e5
Walk people through multi-step setup tips with a prompt
brsbl Oct 9, 2026
2c8b0d3
Merge remote-tracking branch 'origin/main' into bb/checklist-led-home…
brsbl Oct 9, 2026
ce1b134
Fix the telemetry event schema after dropping the checklist events
brsbl Oct 9, 2026
945f377
Add notification and plugin store tips and tighten walkthrough goals
brsbl Oct 9, 2026
f50d812
Merge #5222's setup guide gate into Tips
brsbl Oct 9, 2026
09b9215
Add the app route and command methods to the stable thread-action nav…
brsbl Oct 9, 2026
5531476
Tighten the setup guide: name the machine, clearer Projects and Devic…
brsbl Oct 10, 2026
03cd9dc
Describe outcomes instead of mechanisms in tip copy
brsbl Oct 10, 2026
323a3cd
Give the notification card room and a line illustration
brsbl Oct 10, 2026
26593ce
Make the notification card a shared sidebar nudge with a bell and mor…
brsbl Oct 10, 2026
1790567
Merge origin/main into the notification card branch
brsbl Oct 10, 2026
c3660e9
Add shared suggestion card and inline confirmation components
brsbl Oct 10, 2026
a2ccaa4
Merge the latest #5222 into Tips
brsbl Oct 10, 2026
7a1ec01
Build the Tips feed from the shared suggestion components
brsbl Oct 10, 2026
1ba44a4
Register the shared suggestion components and ask for playgrounds
brsbl Oct 10, 2026
73e4c4e
Publish the shared suggestion card, inline confirmation and sidebar n…
brsbl Oct 10, 2026
b75017c
Keep the registry comment's original escape
brsbl Oct 10, 2026
eea8cbc
Merge #5222's shared-ui registry entries into Tips
brsbl Oct 10, 2026
6b83bec
Move Tips onto the setup tracking foundation
brsbl Oct 12, 2026
08e780c
Merge setup tracking documentation into Tips
brsbl Oct 12, 2026
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
36 changes: 36 additions & 0 deletions .bb/skills/tips-authoring/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: tips-authoring
description: "Add or update a tip in bb's built-in Tips plugin end to end: copy, action, illustration from the diagram kit, eligibility, tests, stories, and PR."
---

# Author a Tips plugin tip

`plugins/tips/AUTHORING.md` is the canonical rulebook: fields, voice, action
types, illustration rules, and the checklist. Read it first; this skill only
orders the work.

1. **Pick the source.** Start from a changelog entry, a guide or blog page, or a
feature people miss. Set `source`, `addedAt` (the release it ships in), and
`reviewedAt`. Add `expiresAt` only for tips about a release or a limited-time
change.
2. **Write the copy** in `plugins/tips/catalog.ts` to AUTHORING.md's voice
and length rules: describe the outcome, not the mechanism. Add the id to `TIP_IDS` in `plugins/tips/contract.ts` and to
`TIP_TELEMETRY_IDS` in `packages/server-contract/src/api/system.ts`; tests
fail when the three lists differ.
3. **Choose one action type.** Use `open-plugin` only for plugins in
`apps/server/src/services/plugins/builtin-registry.ts`, and `open-page` only
for the plugin store or core Settings routes. For multi-step setup, use a walkthrough prompt
from `walkthroughPrompt(goal)`, which asks for a playground when that tool
is available and numbered steps otherwise; AUTHORING.md's Walkthrough
prompts section has the wording and rules.
4. **Draw the illustration** in `plugins/tips/illustrations.tsx` from
`diagram-kit.tsx` parts, with one accent and one named hover animation. Add a
kit part, plus a Diagram kit story cell, only when a second drawing needs it.
5. **Set `tier`, `eligible`, `retireWhen`, and any `boost`.** Use
`"unranked"` unless the owner placed the tip in a tier; AUTHORING.md's
Tiers section explains the order. Never record metrics in the repo. Add `engine.test.ts`
cases when the logic is new.
6. **Check it in Ladle.** Open the `plugins/Tips` Illustrations and Diagram kit
stories, and hover the new drawing in light and dark.
7. **Open or update the PR** with before and after screenshots of the New
thread feed from the branch dev app. Let CI run the tests.
1 change: 1 addition & 0 deletions apps/app/.ladle/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default {
"../../plugins/automations/*.stories.tsx",
".ladle/workflows.stories.tsx",
"../../plugins/provider-usage/*.stories.tsx",
"../../plugins/tips/*.stories.tsx",
],
defaultStory: "",
viteConfig: "./.ladle/vite.config.ts",
Expand Down
28 changes: 22 additions & 6 deletions apps/app/src/components/plugin/PluginHomepageSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@ import { useRouteState } from "@/hooks/useRouteState";
import { getPluginHomepageSectionAnchor } from "@/lib/plugin-homepage-section";
import { PluginSlotMount } from "./PluginSlotMount";

export function PluginHomepageSections() {
export function PluginHomepageSections({
setupComplete,
}: {
setupComplete: boolean;
}) {
const { homepageSections } = usePluginSlots();
if (homepageSections.length === 0) return null;
return <PluginHomepageSectionList sections={homepageSections} />;
return (
<PluginHomepageSectionList
sections={homepageSections}
setupComplete={setupComplete}
/>
);
}

function PluginHomepageSectionList({
sections,
setupComplete,
}: {
sections: readonly PluginHomepageSectionSlot[];
setupComplete: boolean;
}) {
const { projectId } = useRouteState();
return (
Expand All @@ -26,15 +37,20 @@ function PluginHomepageSectionList({
id={getPluginHomepageSectionAnchor(section.pluginId, section.id)}
className="space-y-3"
>
<h2 className="text-sm font-semibold text-foreground">
{section.title}
</h2>
{section.title !== undefined ? (
<h2 className="text-sm font-semibold text-foreground">
{section.title}
</h2>
) : null}
<PluginSlotMount
pluginId={section.pluginId}
slotKind="homepageSection"
slotId={section.id}
>
<section.component projectId={projectId ?? null} />
<section.component
projectId={projectId ?? null}
experimental_setupComplete={setupComplete}
/>
</PluginSlotMount>
</section>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
},
}));

vi.mock("@/components/onboarding/useSetupComplete", () => ({
useSetupComplete: () => true,
}));
vi.mock("@/hooks/queries/host-queries", () => ({
useHosts: () => ({
data: [
Expand Down
55 changes: 54 additions & 1 deletion apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,65 @@ describe("PluginHomepageSections", () => {
);
render(
<MemoryRouter initialEntries={["/"]}>
<PluginHomepageSections />
<PluginHomepageSections setupComplete />
</MemoryRouter>,
);
expect(screen.getByText("plugin broken crashed")).toBeDefined();
expect(screen.getByText("fine section body")).toBeDefined();
});

it("renders a heading only for sections that declare a title", () => {
function Body() {
return <div>untitled section body</div>;
}
setPluginSlotRegistrations(
"titled",
registrationSet({
homepageSections: [{ id: "a", title: "Titled", component: Body }],
}),
);
setPluginSlotRegistrations(
"untitled",
registrationSet({
homepageSections: [{ id: "b", component: Body }],
}),
);
render(
<MemoryRouter initialEntries={["/"]}>
<PluginHomepageSections setupComplete />
</MemoryRouter>,
);
expect(
screen.getAllByRole("heading").map((heading) => heading.textContent),
).toEqual(["Titled"]);
expect(screen.getAllByText("untitled section body")).toHaveLength(2);
});

it("tells sections whether bb setup is complete", () => {
function Setup({
experimental_setupComplete,
}: {
experimental_setupComplete?: boolean;
}) {
return <div>setup complete: {String(experimental_setupComplete)}</div>;
}
setPluginSlotRegistrations(
"setup",
registrationSet({ homepageSections: [{ id: "a", component: Setup }] }),
);
const view = render(
<MemoryRouter initialEntries={["/"]}>
<PluginHomepageSections setupComplete={false} />
</MemoryRouter>,
);
expect(screen.getByText("setup complete: false")).toBeDefined();
view.rerender(
<MemoryRouter initialEntries={["/"]}>
<PluginHomepageSections setupComplete />
</MemoryRouter>,
);
expect(screen.getByText("setup complete: true")).toBeDefined();
});
});

function ThreadDraftViewer({ threadId }: { threadId: string }) {
Expand Down
76 changes: 76 additions & 0 deletions apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ import {
resetPluginThreadRowStatusesForTest,
} from "@/lib/plugin-thread-row-status";
import {
removePluginSlotRegistrations,
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
type PluginRegistrationSet,
} from "@/lib/plugin-slots";
import { getComposerPlaceholderPreview } from "@/lib/composer-placeholder-previews";
import {
PluginComposerHostProvider,
type PluginComposerHost,
Expand Down Expand Up @@ -2690,6 +2692,80 @@ describe("PromptBoxInternal plugin composer actions", () => {
});
});

it("shows a plugin placeholder preview in place of the placeholder and clears it on unmount", async () => {
function PreviewAction() {
const composer = useComposer();
return (
<>
<button
type="button"
onClick={() =>
composer.experimental_setPlaceholderPreview("Try this prompt")
}
>
Preview prompt
</button>
<button
type="button"
onClick={() => composer.experimental_setPlaceholderPreview(null)}
>
Clear preview
</button>
</>
);
}
setPluginSlotRegistrations(
"previewer",
pluginRegistrationSet([
{
id: "tools",
actions: [{ id: "preview", component: PreviewAction }],
},
]),
);
const draft = emptyPromptDraftState();
const host: PluginComposerHost = {
scope: { kind: "new-thread", projectId: null },
textEffectKey: "promptbox-placeholder-preview-test",
getCurrent: () => draft,
subscribeDraft: () => () => {},
setDraft: vi.fn(),
focus: vi.fn(),
};
render(
<MemoryRouter>
<PluginComposerHostProvider value={host}>
<PromptBoxInternal
{...createPromptBoxProps({ placeholder: "Ask anything" })}
/>
</PluginComposerHostProvider>
</MemoryRouter>,
);
const editor = getPromptEditorElement();
expect(editor.getAttribute("data-placeholder")).toBe("Ask anything");

fireEvent.click(screen.getByRole("button", { name: "Preview prompt" }));
await waitFor(() => {
expect(editor.getAttribute("data-placeholder")).toBe("Try this prompt");
});
expect(editor.getAttribute("aria-label")).toBe("Ask anything");

fireEvent.click(screen.getByRole("button", { name: "Clear preview" }));
await waitFor(() => {
expect(editor.getAttribute("data-placeholder")).toBe("Ask anything");
});

fireEvent.click(screen.getByRole("button", { name: "Preview prompt" }));
await waitFor(() => {
expect(editor.getAttribute("data-placeholder")).toBe("Try this prompt");
});
act(() => removePluginSlotRegistrations("previewer"));
await waitFor(() => {
expect(editor.getAttribute("data-placeholder")).toBe("Ask anything");
});
expect(getComposerPlaceholderPreview(host.textEffectKey)).toBeNull();
});

it("remounts scoped actions and releases owned state when scope identity changes", () => {
const mounted = vi.fn();
const cleaned = vi.fn();
Expand Down
15 changes: 10 additions & 5 deletions apps/app/src/components/promptbox/PromptBoxInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
usePluginComposerViewModel,
} from "@/components/plugin/plugin-composer-host";
import { useComposerInputLock } from "@/lib/plugin-sdk-hooks";
import { useComposerPlaceholderPreview } from "@/lib/composer-placeholder-previews";
import {
COARSE_POINTER_PROMPT_ACTION_BUTTON_CLASS,
COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS,
Expand Down Expand Up @@ -1590,10 +1591,14 @@ export function PromptBoxInternal({
(value.trim().length === 0 &&
attachments.length === 0 &&
!pendingUploads?.length));
const effectivePlaceholder = showCompactLayout
const pluginComposerHost = usePluginComposerHost();
const placeholderPreview = useComposerPlaceholderPreview(
pluginComposerHost?.textEffectKey ?? null,
);
const hostPlaceholder = showCompactLayout
? (compact.placeholder ?? placeholder)
: placeholder;
const pluginComposerHost = usePluginComposerHost();
const effectivePlaceholder = placeholderPreview ?? hostPlaceholder;
const composerInputLocked = useComposerInputLock(
pluginComposerHost?.textEffectKey ?? null,
);
Expand Down Expand Up @@ -2028,7 +2033,7 @@ export function PromptBoxInternal({
immediatelyRender: false,
editorProps: {
attributes: {
"aria-label": effectivePlaceholder,
"aria-label": hostPlaceholder,
"data-placeholder": effectivePlaceholder,
...(onModifierSubmit
? { "aria-keyshortcuts": modifierSubmitShortcutAria() }
Expand Down Expand Up @@ -2403,11 +2408,11 @@ export function PromptBoxInternal({
placeholderRef.current = effectivePlaceholder;
if (!editor) return;

editor.view.dom.setAttribute("aria-label", effectivePlaceholder);
editor.view.dom.setAttribute("aria-label", hostPlaceholder);
editor.view.dom.setAttribute("data-placeholder", effectivePlaceholder);
editor.view.dom.setAttribute("enterkeyhint", editorEnterKeyHint);
editor.view.dispatch(editor.state.tr);
}, [editor, editorEnterKeyHint, effectivePlaceholder]);
}, [editor, editorEnterKeyHint, effectivePlaceholder, hostPlaceholder]);

useEffect(() => {
if (!editor) return;
Expand Down
37 changes: 37 additions & 0 deletions apps/app/src/components/settings/tips-catalog-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { isSettingsSectionId } from "./settings-sections";

const catalogModuleSchema = z.object({
TIP_CATALOG: z.array(
z.object({
id: z.string(),
action: z.object({ kind: z.string(), path: z.string().optional() }),
}),
),
});

const catalogPath = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../../../plugins/tips/catalog.ts",
);

describe("Tips catalog routes", () => {
it("points every open-page tip at the plugin store or a core Settings route", async () => {
const { TIP_CATALOG } = catalogModuleSchema.parse(
await import(pathToFileURL(catalogPath).href),
);
for (const tip of TIP_CATALOG) {
if (tip.action.kind !== "open-page") continue;
const { pathname } = new URL(tip.action.path ?? "", "http://bb.local");
if (pathname === "/plugins") continue;
const [, root, section, pluginId] = pathname.split("/");
expect(root, tip.id).toBe("settings");
if (section === undefined) continue;
expect(isSettingsSectionId(section), tip.id).toBe(true);
if (section === "plugins") expect(pluginId, tip.id).toBeDefined();
}
});
});
36 changes: 36 additions & 0 deletions apps/app/src/lib/composer-placeholder-previews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
getComposerPlaceholderPreview,
setComposerPlaceholderPreview,
} from "./composer-placeholder-previews";

describe("composer placeholder previews", () => {
it("shows the most recent preview and falls back as owners clear", () => {
const storageKey = "composer-placeholder-previews-latest";
const first = Symbol("first");
const second = Symbol("second");

setComposerPlaceholderPreview(storageKey, first, "First preview");
setComposerPlaceholderPreview(storageKey, second, "Second preview");
expect(getComposerPlaceholderPreview(storageKey)).toBe("Second preview");

setComposerPlaceholderPreview(storageKey, first, "First again");
expect(getComposerPlaceholderPreview(storageKey)).toBe("First again");

setComposerPlaceholderPreview(storageKey, first, null);
expect(getComposerPlaceholderPreview(storageKey)).toBe("Second preview");

setComposerPlaceholderPreview(storageKey, second, null);
expect(getComposerPlaceholderPreview(storageKey)).toBeNull();
});

it("keeps composers independent and ignores a null storage key", () => {
const owner = Symbol("owner");
setComposerPlaceholderPreview("composer-a", owner, "Only in A");
setComposerPlaceholderPreview(null, owner, "Nowhere");
expect(getComposerPlaceholderPreview("composer-a")).toBe("Only in A");
expect(getComposerPlaceholderPreview("composer-b")).toBeNull();
expect(getComposerPlaceholderPreview(null)).toBeNull();
setComposerPlaceholderPreview("composer-a", owner, null);
});
});
Loading
Loading