diff --git a/.bb/skills/tips-authoring/SKILL.md b/.bb/skills/tips-authoring/SKILL.md new file mode 100644 index 00000000000..13d3d990e01 --- /dev/null +++ b/.bb/skills/tips-authoring/SKILL.md @@ -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. diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index e48a7391434..cb90e4e1b8a 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -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", diff --git a/apps/app/src/components/plugin/PluginHomepageSections.tsx b/apps/app/src/components/plugin/PluginHomepageSections.tsx index 84bb3b5a654..983907586e8 100644 --- a/apps/app/src/components/plugin/PluginHomepageSections.tsx +++ b/apps/app/src/components/plugin/PluginHomepageSections.tsx @@ -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 ; + return ( + + ); } function PluginHomepageSectionList({ sections, + setupComplete, }: { sections: readonly PluginHomepageSectionSlot[]; + setupComplete: boolean; }) { const { projectId } = useRouteState(); return ( @@ -26,15 +37,20 @@ function PluginHomepageSectionList({ id={getPluginHomepageSectionAnchor(section.pluginId, section.id)} className="space-y-3" > -

- {section.title} -

+ {section.title !== undefined ? ( +

+ {section.title} +

+ ) : null} - + ))} diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index ec31014aee8..09ae69503e4 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -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: [ diff --git a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx index c932bc818c0..af62cddaf68 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -138,12 +138,65 @@ describe("PluginHomepageSections", () => { ); render( - + , ); 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
untitled section body
; + } + setPluginSlotRegistrations( + "titled", + registrationSet({ + homepageSections: [{ id: "a", title: "Titled", component: Body }], + }), + ); + setPluginSlotRegistrations( + "untitled", + registrationSet({ + homepageSections: [{ id: "b", component: Body }], + }), + ); + render( + + + , + ); + 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
setup complete: {String(experimental_setupComplete)}
; + } + setPluginSlotRegistrations( + "setup", + registrationSet({ homepageSections: [{ id: "a", component: Setup }] }), + ); + const view = render( + + + , + ); + expect(screen.getByText("setup complete: false")).toBeDefined(); + view.rerender( + + + , + ); + expect(screen.getByText("setup complete: true")).toBeDefined(); + }); }); function ThreadDraftViewer({ threadId }: { threadId: string }) { diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 20475dd2e1c..4e55a3eb680 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -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, @@ -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 ( + <> + + + + ); + } + 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( + + + + + , + ); + 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(); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 5c09c271ce3..06d6471d3ee 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -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, @@ -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, ); @@ -2028,7 +2033,7 @@ export function PromptBoxInternal({ immediatelyRender: false, editorProps: { attributes: { - "aria-label": effectivePlaceholder, + "aria-label": hostPlaceholder, "data-placeholder": effectivePlaceholder, ...(onModifierSubmit ? { "aria-keyshortcuts": modifierSubmitShortcutAria() } @@ -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; diff --git a/apps/app/src/components/settings/tips-catalog-routes.test.ts b/apps/app/src/components/settings/tips-catalog-routes.test.ts new file mode 100644 index 00000000000..1e750cf21bc --- /dev/null +++ b/apps/app/src/components/settings/tips-catalog-routes.test.ts @@ -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(); + } + }); +}); diff --git a/apps/app/src/lib/composer-placeholder-previews.test.ts b/apps/app/src/lib/composer-placeholder-previews.test.ts new file mode 100644 index 00000000000..b0315de84d5 --- /dev/null +++ b/apps/app/src/lib/composer-placeholder-previews.test.ts @@ -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); + }); +}); diff --git a/apps/app/src/lib/composer-placeholder-previews.ts b/apps/app/src/lib/composer-placeholder-previews.ts new file mode 100644 index 00000000000..f680da40205 --- /dev/null +++ b/apps/app/src/lib/composer-placeholder-previews.ts @@ -0,0 +1,79 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { createKeyedListeners } from "./keyed-listeners"; + +type ComposerPlaceholderPreviewOwner = string | symbol; + +interface ComposerPlaceholderPreviewEntry { + text: string; + sequence: number; +} + +let previewSequence = 0; + +const previewsByStorageKey = new Map< + string, + Map +>(); +const snapshotsByStorageKey = new Map(); +const previewListeners = createKeyedListeners(); + +export function getComposerPlaceholderPreview( + storageKey: string | null, +): string | null { + if (storageKey === null) return null; + return snapshotsByStorageKey.get(storageKey) ?? null; +} + +function latestPreview( + previews: + | Map + | undefined, +): string | null { + let latest: ComposerPlaceholderPreviewEntry | null = null; + for (const entry of previews?.values() ?? []) { + if (latest === null || entry.sequence > latest.sequence) latest = entry; + } + return latest?.text ?? null; +} + +export function setComposerPlaceholderPreview( + storageKey: string | null, + owner: ComposerPlaceholderPreviewOwner, + text: string | null, +): void { + if (storageKey === null) return; + let previews = previewsByStorageKey.get(storageKey); + if (text === null) { + if (previews?.delete(owner) !== true) return; + if (previews.size === 0) previewsByStorageKey.delete(storageKey); + } else { + if (previews === undefined) { + previews = new Map(); + previewsByStorageKey.set(storageKey, previews); + } + if (previews.get(owner)?.text === text) return; + previews.set(owner, { text, sequence: previewSequence++ }); + } + const previous = getComposerPlaceholderPreview(storageKey); + const next = latestPreview(previewsByStorageKey.get(storageKey)); + if (next === null) snapshotsByStorageKey.delete(storageKey); + else snapshotsByStorageKey.set(storageKey, next); + if (next !== previous) previewListeners.notify(storageKey); +} + +export function useComposerPlaceholderPreview( + storageKey: string | null, +): string | null { + const subscribe = useCallback( + (listener: () => void) => + storageKey === null + ? () => {} + : previewListeners.subscribe(storageKey, listener), + [storageKey], + ); + const getSnapshot = useCallback( + () => getComposerPlaceholderPreview(storageKey), + [storageKey], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/app/src/lib/plugin-app-definition.test.ts b/apps/app/src/lib/plugin-app-definition.test.ts index d39cb481bee..f5950d98de0 100644 --- a/apps/app/src/lib/plugin-app-definition.test.ts +++ b/apps/app/src/lib/plugin-app-definition.test.ts @@ -994,6 +994,18 @@ describe("collectPluginAppRegistrations", () => { }), /"component" must be/, ], + [ + "homepage section with an empty title", + () => + definePluginApp((app) => { + app.slots.homepageSection({ + id: "x", + title: "", + component: Component, + }); + }), + /slots\.homepageSection: "title" must be a non-empty string/, + ], [ "nav panel with a non-component headerContent", () => @@ -1117,6 +1129,16 @@ describe("collectPluginAppRegistrations", () => { expect(() => collectPluginAppRegistrations(build())).toThrow(message); }); + it("collects an untitled homepage section without a title key", () => { + const definition = definePluginApp((app) => { + app.slots.homepageSection({ id: "tips", component: Component }); + }); + const [section] = + collectPluginAppRegistrations(definition).homepageSections; + expect(section).toEqual({ id: "tips", component: Component }); + expect(section !== undefined && "title" in section).toBe(false); + }); + it("keeps a headerContent registration", () => { function Accessory() { return null; diff --git a/apps/app/src/lib/plugin-app-route.test.ts b/apps/app/src/lib/plugin-app-route.test.ts new file mode 100644 index 00000000000..5b6206a5ab0 --- /dev/null +++ b/apps/app/src/lib/plugin-app-route.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { normalizePluginAppRoute } from "./plugin-app-route"; + +const ORIGIN = "http://127.0.0.1:38886"; + +describe("normalizePluginAppRoute", () => { + it.each([ + ["/settings/mobile", "/settings/mobile"], + ["/settings/updates#whats-new", "/settings/updates#whats-new"], + [ + "/settings/plugins/bb--provider-usage?view=installed", + "/settings/plugins/bb--provider-usage?view=installed", + ], + ["/settings/../extensions", "/extensions"], + ])("keeps the in-app route %s", (path, expected) => { + expect(normalizePluginAppRoute(path, ORIGIN)).toBe(expected); + }); + + it.each([ + ["an absolute URL", "https://example.com/settings"], + ["a protocol-relative URL", "//example.com/settings"], + ["a relative path", "settings/mobile"], + ["a backslash path", "/\\example.com"], + ["a control character", "/settings\n/mobile"], + ["a javascript URL", "javascript:alert(1)"], + ["an API route", "/api/v1/system/config"], + ["the API root", "/api"], + ["an API route reached by traversal", "/settings/../api/v1/threads"], + ["an empty string", ""], + ["a non-string", 42], + ])("rejects %s", (_label, path) => { + expect(normalizePluginAppRoute(path, ORIGIN)).toBeNull(); + }); +}); diff --git a/apps/app/src/lib/plugin-app-route.ts b/apps/app/src/lib/plugin-app-route.ts new file mode 100644 index 00000000000..1a7fffb3417 --- /dev/null +++ b/apps/app/src/lib/plugin-app-route.ts @@ -0,0 +1,19 @@ +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u; + +export function normalizePluginAppRoute( + path: unknown, + origin: string, +): string | null { + if (typeof path !== "string" || !path.startsWith("/")) return null; + if (path.startsWith("//") || path.includes("\\")) return null; + if (CONTROL_CHARACTER_PATTERN.test(path)) return null; + let url: URL; + try { + url = new URL(path, origin); + } catch { + return null; + } + if (url.origin !== new URL(origin).origin) return null; + if (url.pathname === "/api" || url.pathname.startsWith("/api/")) return null; + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/apps/app/src/lib/plugin-composer-handle.test.ts b/apps/app/src/lib/plugin-composer-handle.test.ts index 3dd7db7c8fd..162f1cfa1da 100644 --- a/apps/app/src/lib/plugin-composer-handle.test.ts +++ b/apps/app/src/lib/plugin-composer-handle.test.ts @@ -149,6 +149,7 @@ function makeHandle(target: ComposerSource, pluginId = "demo") { composerHandleController(pluginId, target, { setTextEffect: () => {}, setInputLock: () => {}, + setPlaceholderPreview: () => {}, onSubmitted: () => () => {}, }), ); @@ -377,6 +378,7 @@ describe("composer handle", () => { { setTextEffect: () => {}, setInputLock: () => {}, + setPlaceholderPreview: () => {}, onSubmitted: (listener) => subscribeComposerSubmitted(scope, listener), }, @@ -410,6 +412,7 @@ describe("composer handle", () => { composerHandleController("demo", second.target, { setTextEffect: () => {}, setInputLock: () => {}, + setPlaceholderPreview: () => {}, onSubmitted: () => () => {}, }), ); diff --git a/apps/app/src/lib/plugin-composer-handle.ts b/apps/app/src/lib/plugin-composer-handle.ts index e1f7bd47283..4209a71901c 100644 --- a/apps/app/src/lib/plugin-composer-handle.ts +++ b/apps/app/src/lib/plugin-composer-handle.ts @@ -50,7 +50,7 @@ export type ComposerSource = Pick< type ComposerLifecycle = Pick< ComposerHandleController, - "setTextEffect" | "setInputLock" | "onSubmitted" + "setTextEffect" | "setInputLock" | "setPlaceholderPreview" | "onSubmitted" >; const composerSelectionSchema = z.object({ @@ -273,6 +273,8 @@ export function detachedComposerController( return composerHandleController(pluginId, host, { setTextEffect: () => warnNoLifecycle("setTextEffect"), setInputLock: () => warnNoLifecycle("setInputLock"), + setPlaceholderPreview: () => + warnNoLifecycle("experimental_setPlaceholderPreview"), onSubmitted: (listener) => subscribeComposerSubmitted(scope, listener), }); } diff --git a/apps/app/src/lib/plugin-sdk-app-navigation.test.tsx b/apps/app/src/lib/plugin-sdk-app-navigation.test.tsx new file mode 100644 index 00000000000..085d17a7635 --- /dev/null +++ b/apps/app/src/lib/plugin-sdk-app-navigation.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultAppSettings } from "@bb/domain"; +import { + AppCommandProvider, + useAppCommandHandler, +} from "@/components/commands/AppCommandProvider"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { pluginSdkAppImplementation } from "./plugin-sdk-app-impl"; + +vi.mock("@/hooks/queries/system-queries", async (importOriginal) => ({ + ...(await importOriginal()), + useSystemConfig: () => ({ + data: { generalSettings: { ...defaultAppSettings } }, + }), +})); + +afterEach(cleanup); + +function LocationProbe() { + const location = useLocation(); + return ( +
{`${location.pathname}${location.hash}`}
+ ); +} + +function NavigateProbe({ + action, + results, +}: { + action: ( + navigate: ReturnType, + ) => boolean; + results: boolean[]; +}) { + const navigate = pluginSdkAppImplementation.useBbNavigate(); + return ( + + ); +} + +function PaletteHandler({ onRun }: { onRun: () => void }) { + useAppCommandHandler("palette.open", () => { + onRun(); + return true; + }); + return null; +} + +function renderProbe( + action: ( + navigate: ReturnType, + ) => boolean, + onPaletteOpen: () => void = () => {}, +) { + const results: boolean[] = []; + render( + + + + + + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Run" })); + return results; +} + +describe("plugin SDK app routes and commands", () => { + it("navigates to an in-app route with its hash", () => { + const results = renderProbe((navigate) => + navigate.experimental_openAppRoute("/settings/updates#whats-new"), + ); + expect(results).toEqual([true]); + expect(screen.getByTestId("location").textContent).toBe( + "/settings/updates#whats-new", + ); + }); + + it("refuses an external URL without navigating", () => { + const results = renderProbe((navigate) => + navigate.experimental_openAppRoute("https://example.com/settings"), + ); + expect(results).toEqual([false]); + expect(screen.getByTestId("location").textContent).toBe("/"); + }); + + it("runs a built-in app command through its registered handler", () => { + const onPaletteOpen = vi.fn(); + const results = renderProbe( + (navigate) => navigate.experimental_runAppCommand("palette.open"), + onPaletteOpen, + ); + expect(results).toEqual([true]); + expect(onPaletteOpen).toHaveBeenCalledTimes(1); + }); + + it("rejects unknown, state-changing, and plugin command ids", () => { + const onPaletteOpen = vi.fn(); + const results = renderProbe( + (navigate) => + navigate.experimental_runAppCommand("palette.explode") || + navigate.experimental_runAppCommand("thread.archive") || + navigate.experimental_runAppCommand("question.select.1") || + navigate.experimental_runAppCommand("plugins.enterSafeMode") || + navigate.experimental_runAppCommand("plugin:demo/palette"), + onPaletteOpen, + ); + expect(results).toEqual([false]); + expect(onPaletteOpen).not.toHaveBeenCalled(); + }); + + it("reports false when no handler is mounted for the command", () => { + const results = renderProbe((navigate) => + navigate.experimental_runAppCommand("thread.search"), + ); + expect(results).toEqual([false]); + }); +}); diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index 188378ce1ca..995dc1e2560 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -30,7 +30,9 @@ import type { ExperimentalPluginFixedTabReference, JsonValue, } from "@get-bb/plugin-sdk"; -import { jsonValueSchema } from "@bb/domain"; +import { appCommandIdSchema, jsonValueSchema } from "@bb/domain"; +import { useAppCommandRunner } from "@/components/commands/AppCommandProvider"; +import { normalizePluginAppRoute } from "@/lib/plugin-app-route"; import { PluginSlotOwnershipContext, usePluginId, @@ -59,6 +61,7 @@ import { useSystemProviders } from "@/hooks/queries/system-queries"; import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; import { requestComposerFocus } from "@/lib/composer-focus-requests"; import { setComposerTextEffect } from "@/lib/composer-text-effects"; +import { setComposerPlaceholderPreview } from "@/lib/composer-placeholder-previews"; import { createKeyedListeners } from "@/lib/keyed-listeners"; import { usePromptDraftController, @@ -336,12 +339,18 @@ export function useBbContext(): BbContext { ); } +const PLUGIN_RUNNABLE_APP_COMMAND_IDS: ReadonlySet = new Set([ + "palette.open", + "thread.search", + "settings.open", +]); export function useBbNavigate(): BbNavigate { const pluginId = usePluginId(); const location = useLocation(); const openThreadPanelHandler = usePluginThreadPanelOpenHandler(); const navigate = useNavigate(); const appNavigation = useAppNavigationHost(); + const appCommands = useAppCommandRunner(); const store = useStore(); const queryClient = useQueryClient(); const isCompact = useIsCompactViewport(); @@ -448,6 +457,27 @@ export function useBbNavigate(): BbNavigate { }, [appNavigation], ); + const experimental_openAppRoute = useCallback< + BbNavigate["experimental_openAppRoute"] + >( + (path) => { + const route = normalizePluginAppRoute(path, window.location.origin); + if (route === null) return false; + void navigate(route); + return true; + }, + [navigate], + ); + const experimental_runAppCommand = useCallback< + BbNavigate["experimental_runAppCommand"] + >( + (commandId) => { + if (!PLUGIN_RUNNABLE_APP_COMMAND_IDS.has(commandId)) return false; + const command = appCommandIdSchema.safeParse(commandId); + return command.success && appCommands.dispatch(command.data, null); + }, + [appCommands], + ); const experimental_openTerminal = useCallback< BbNavigate["experimental_openTerminal"] >( @@ -471,6 +501,8 @@ export function useBbNavigate(): BbNavigate { openThreadPanel, experimental_openFileExternally, experimental_openFilePreview, + experimental_openAppRoute, + experimental_runAppCommand, experimental_openTerminal, openUrl, }), @@ -482,6 +514,8 @@ export function useBbNavigate(): BbNavigate { openThreadPanel, experimental_openFileExternally, experimental_openFilePreview, + experimental_openAppRoute, + experimental_runAppCommand, experimental_openTerminal, openUrl, ], @@ -742,6 +776,7 @@ export function useComposer(): PluginComposerApi { scopeOwnership.invalidate(); setComposerTextEffect(textEffectKey, pluginId, null, visualStateOwner); setComposerInputLock(textEffectKey, pluginId, false, visualStateOwner); + setComposerPlaceholderPreview(textEffectKey, visualStateOwner, null); }, [pluginId, scopeOwnership, textEffectKey, visualStateOwner]); const registerVisualStateOwner = useCallback(() => { slotOwnershipRegistry?.register(visualStateOwner, releaseVisualState); @@ -781,6 +816,14 @@ export function useComposer(): PluginComposerApi { visualStateOwner, ], ); + const setPlaceholderPreview = useCallback( + (text: string | null) => { + if (!scopeOwnership.isActive()) return; + if (text !== null) registerVisualStateOwner(); + setComposerPlaceholderPreview(textEffectKey, visualStateOwner, text); + }, + [registerVisualStateOwner, scopeOwnership, textEffectKey, visualStateOwner], + ); useEffect(() => { scopeOwnership.activate(); return () => { @@ -836,9 +879,17 @@ export function useComposer(): PluginComposerApi { composerHandleController(pluginId, source, { setTextEffect, setInputLock, + setPlaceholderPreview, onSubmitted, }), - [onSubmitted, pluginId, setInputLock, setTextEffect, source], + [ + onSubmitted, + pluginId, + setInputLock, + setPlaceholderPreview, + setTextEffect, + source, + ], ); const [binding, setBinding] = useState(() => createComposerHandleBinding(textEffectKey, controller), diff --git a/apps/app/src/lib/thread-actions/thread-action-registry.tsx b/apps/app/src/lib/thread-actions/thread-action-registry.tsx index 306298c132b..dc8afad1928 100644 --- a/apps/app/src/lib/thread-actions/thread-action-registry.tsx +++ b/apps/app/src/lib/thread-actions/thread-action-registry.tsx @@ -230,6 +230,10 @@ function useLatestNavigate(navigate: BbNavigate, pluginId: string): BbNavigate { latest.current.experimental_openFileExternally(...args), experimental_openTerminal: (...args) => latest.current.experimental_openTerminal(...args), + experimental_openAppRoute: (...args) => + latest.current.experimental_openAppRoute(...args), + experimental_runAppCommand: (...args) => + latest.current.experimental_runAppCommand(...args), })); return stable; } diff --git a/apps/app/src/views/RootComposeSecondaryContent.test.tsx b/apps/app/src/views/RootComposeSecondaryContent.test.tsx index 1400bc8f493..2c896ebca28 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.test.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.test.tsx @@ -198,6 +198,7 @@ function renderRootCompose(args: RenderRootComposeArgs) { undefined} secondaryPanel={createSecondaryPanel(renderArgs.isSecondaryPanelOpen)} @@ -219,6 +220,7 @@ function renderRootCompose(args: RenderRootComposeArgs) { undefined} secondaryPanel={createSecondaryPanel( diff --git a/apps/app/src/views/RootComposeSecondaryContent.tsx b/apps/app/src/views/RootComposeSecondaryContent.tsx index 457b28ef488..6d1770ce16a 100644 --- a/apps/app/src/views/RootComposeSecondaryContent.tsx +++ b/apps/app/src/views/RootComposeSecondaryContent.tsx @@ -44,6 +44,7 @@ interface RootComposeSecondaryContentProps { compactScrollContent: ReactNode; isCompactHomeLayout: boolean; contentClassName?: string; + setupComplete: boolean; isSecondaryPanelOpen: boolean; onToggleSecondaryPanel: () => void; secondaryPanel: RootSecondaryPanelProps; @@ -67,6 +68,7 @@ export function RootComposeSecondaryContent({ compactScrollContent, isCompactHomeLayout, contentClassName, + setupComplete, isSecondaryPanelOpen, onToggleSecondaryPanel, secondaryPanel, @@ -112,7 +114,7 @@ export function RootComposeSecondaryContent({ > {compactScrollContent} - + ) : ( @@ -127,7 +129,7 @@ export function RootComposeSecondaryContent({ > {children} {compactScrollContent} - + )} diff --git a/apps/app/src/views/RootComposeTips.stories.tsx b/apps/app/src/views/RootComposeTips.stories.tsx new file mode 100644 index 00000000000..2ebdb550fa8 --- /dev/null +++ b/apps/app/src/views/RootComposeTips.stories.tsx @@ -0,0 +1,175 @@ +import { useState, type ComponentType, type ReactNode } from "react"; +import { StoryCard, StoryRow } from "../../.ladle/story-card"; +import { StoryComposer } from "./mobile-home-story-fixtures"; + +export default { title: "views/New Thread Tips" }; + +type StoryTipAction = + | { kind: "prompt"; label: string; prompt: string } + | { kind: "route"; label: string; path: string } + | { kind: "command"; label: string; commandId: string }; + +interface StoryTip { + id: string; + tone: string; + title: string; + body: string; + action: StoryTipAction; +} + +interface StoryResult { + id: string; + ok: boolean; + text: string; +} + +interface ControlsProps { + result: StoryResult | null; + onDismiss(): void; +} + +interface GalleryModule { + TipsGallery: ComponentType< + ControlsProps & { + tips: readonly StoryTip[]; + onPreview(id: string | null): void; + onActivate(tip: StoryTip): void; + } + >; +} + +interface CatalogModule { + TIP_CATALOG: readonly { id: string }[]; + renderTip(definition: { id: string }, signals: object): StoryTip; +} + +function only(modules: Record): T { + const [module] = Object.values(modules); + if (module === undefined) throw new Error("Tips plugin module not found"); + return module; +} + +const gallery = only( + import.meta.glob("../../../../plugins/tips/gallery.tsx", { + eager: true, + }), +); +const catalog = only( + import.meta.glob("../../../../plugins/tips/catalog.ts", { + eager: true, + }), +); + +const STORY_SIGNALS = { + client: { surface: "desktop", os: "macos" }, + appVersion: "0.42.0", +}; + +const CARD_TIPS = ["child-threads", "set-up-for-me", "phone"].flatMap((id) => { + const definition = catalog.TIP_CATALOG.find((entry) => entry.id === id); + return definition === undefined + ? [] + : [catalog.renderTip(definition, STORY_SIGNALS)]; +}); + +function composeTip(prompt: string, draft: string): string { + const task = draft.trim(); + if (task === "") return prompt; + return prompt.endsWith(" ") + ? `${prompt}${task}` + : `${draft.trimEnd()}\n\n${prompt}`; +} + +interface TipsPageState { + tips: readonly StoryTip[]; + result: StoryResult | null; + setPreviewId(id: string | null): void; + activate(tip: StoryTip): void; +} + +function NewThreadPage({ + children, +}: { + children: (state: TipsPageState) => ReactNode; +}) { + const [draft, setDraft] = useState(""); + const [previewId, setPreviewId] = useState(null); + const [result, setResult] = useState(null); + const [focusRequest, setFocusRequest] = useState(); + const tips = CARD_TIPS; + const preview = tips.find((tip) => tip.id === previewId)?.action; + const state: TipsPageState = { + tips, + result, + setPreviewId, + activate(tip) { + const action = tip.action; + if (action.kind !== "prompt") { + setResult({ id: tip.id, ok: true, text: `Opened ${action.label}` }); + return; + } + setDraft((current) => composeTip(action.prompt, current)); + setFocusRequest(`${tip.id}:${Date.now()}`); + setResult({ id: tip.id, ok: true, text: "Added to the composer" }); + }, + }; + return ( +
+
+
+ +
{children(state)}
+
+
+
+ ); +} + +function ControlLayout(state: TipsPageState) { + return ( + {}} + /> + ); +} + +function LayoutStory({ + layout, + hint, +}: { + layout: (state: TipsPageState) => ReactNode; + hint: string; +}) { + return ( + + + {layout} + + + ); +} + +export function Control() { + return ( + + ); +} +Control.storyName = "Control (production)"; diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 8875f93d611..1e8703740ed 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -1,3 +1,4 @@ +import { useSetupComplete } from "@/components/onboarding/useSetupComplete"; import { getPanelTabHistoryKey } from "@/components/secondary-panel/recentlyClosedPanelTabs"; import { appendQuoteAndAttachmentsToDraft } from "@bb/client-core"; import type { ComposerAttachment } from "@get-bb/plugin-sdk"; @@ -705,6 +706,7 @@ function RootComposeSurface({ }); const activeFixedSecondaryTabId = activeFixedSecondaryTab?.id ?? null; const isCompactViewport = useIsCompactViewport(); + const setupComplete = useSetupComplete(); const secondaryPanelDrawerVisibility = useThreadSecondaryPanelDrawerVisibility({ isCompactViewport, @@ -1520,6 +1522,7 @@ function RootComposeSurface({ ROOT_COMPOSE_SIDEBAR_ACTION_ALIGNED_TOP_PADDING_CLASS } isCompactHomeLayout={isCompactViewport} + setupComplete={setupComplete} compactScrollContent={ void; + placeholder?: string; + focusRequest?: string; +} + export function StoryComposer({ + id = "story-compact-home-composer", + mentionMenuPlacement = "top", initialValue = "", -}: { - initialValue?: string; -}) { - const [value, setValue] = useState(initialValue); + value: controlledValue, + onValueChange, + placeholder, + focusRequest, +}: StoryComposerProps = {}) { + const [uncontrolledValue, setValue] = useState(initialValue); const [mentionRanges, setMentionRanges] = useState([]); + const value = controlledValue ?? uncontrolledValue; return ( { setValue(nextValue); + onValueChange?.(nextValue); setMentionRanges(nextMentionRanges); }} onSubmit={noop} diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index 3ec0581e035..a6c828b8ee5 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -179,6 +179,11 @@ export const BUILTIN_PLUGINS = [ pluginId: "thread-list", defaultEnabled: true, }, + { + name: "tips", + pluginId: "bb--tips", + defaultEnabled: true, + }, { name: "prompt-library", pluginId: "bb--prompt-library", diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 4835285eafb..d360b984621 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -613,6 +613,7 @@ describe("builtin plugin reconciliation", () => { drafts: true, "provider-retry": true, "push-notifications": true, + tips: true, }); }); diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index da2f2d25ae3..a118d0f3eda 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -340,7 +340,7 @@ const _assertAllContentScriptRegistrationFieldsListed: MissingContentScriptRegis void _assertAllContentScriptRegistrationFieldsListed; const FRONTEND_SLOT_PROP_FIELDS = { - homepageSection: ["projectId"], + homepageSection: ["projectId", "experimental_setupComplete"], settingsSection: [], experimental_appOverlay: [], navPanel: ["subPath"], diff --git a/apps/server/test/services/plugins/tips-catalog-targets.test.ts b/apps/server/test/services/plugins/tips-catalog-targets.test.ts new file mode 100644 index 00000000000..65b9424083b --- /dev/null +++ b/apps/server/test/services/plugins/tips-catalog-targets.test.ts @@ -0,0 +1,55 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { TIP_TELEMETRY_IDS } from "@bb/server-contract"; +import { BUILTIN_PLUGINS } from "../../../src/services/plugins/builtin-registry.js"; + +const catalogModuleSchema = z.object({ + TIP_CATALOG: z.array( + z.object({ + id: z.string(), + action: z.object({ + kind: z.string(), + pluginId: z.string().optional(), + path: z.string().optional(), + }), + }), + ), +}); + +const catalogPath = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../../plugins/tips/catalog.ts", +); + +describe("Tips catalog targets", () => { + it("points every open-plugin tip and plugin settings page at a built-in plugin", async () => { + const { TIP_CATALOG } = catalogModuleSchema.parse( + await import(pathToFileURL(catalogPath).href), + ); + const builtinIds = new Set( + BUILTIN_PLUGINS.map((plugin) => plugin.pluginId), + ); + for (const tip of TIP_CATALOG) { + if (tip.action.kind === "open-plugin") { + expect(builtinIds.has(tip.action.pluginId ?? ""), tip.id).toBe(true); + } + const settingsPlugin = /^\/settings\/plugins\/([^/?#]+)/u.exec( + tip.action.path ?? "", + ); + if (settingsPlugin !== null) { + expect(builtinIds.has(settingsPlugin[1] ?? ""), tip.id).toBe(true); + } + } + }); + + it("allows telemetry for exactly the tips in the catalog", async () => { + const { TIP_CATALOG } = catalogModuleSchema.parse( + await import(pathToFileURL(catalogPath).href), + ); + expect([...TIP_TELEMETRY_IDS].sort()).toEqual( + TIP_CATALOG.map((tip) => tip.id).sort(), + ); + }); +}); diff --git a/apps/server/test/system/telemetry-events.test.ts b/apps/server/test/system/telemetry-events.test.ts index b25aadb3b0b..9d81b54bdf2 100644 --- a/apps/server/test/system/telemetry-events.test.ts +++ b/apps/server/test/system/telemetry-events.test.ts @@ -48,4 +48,58 @@ describe("client telemetry events", () => { expect(capture).not.toHaveBeenCalled(); }); }); + + it("accepts anonymous tip events and rejects tip ids, positions, or properties outside the allow-list", async () => { + await withTestHarness(async (harness) => { + const capture = vi.fn(); + harness.deps.telemetry = { ...harness.deps.telemetry, capture }; + const shown = { + name: "tip_shown", + properties: { tip_id: "child-threads", position: 1, action: "prompt" }, + }; + const used = { + name: "tip_used", + properties: { + tip_id: "account-pool", + position: 3, + action: "open-plugin", + }, + }; + + const accepted = [await post(harness, shown), await post(harness, used)]; + const rejected = [ + await post(harness, { + name: "tip_shown", + properties: { tip_id: "my-tip", position: 1, action: "prompt" }, + }), + await post(harness, { + name: "tip_used", + properties: { + tip_id: "child-threads", + position: 4, + action: "prompt", + }, + }), + await post(harness, { + name: "tip_used", + properties: { tip_id: "child-threads", position: 1, action: "share" }, + }), + await post(harness, { + name: "tip_shown", + properties: { + tip_id: "child-threads", + position: 1, + action: "prompt", + projectId: "proj_123", + }, + }), + ]; + + expect(accepted.map((response) => response.status)).toEqual([200, 200]); + expect(rejected.map((response) => response.status)).toEqual([ + 400, 400, 400, 400, + ]); + expect(capture.mock.calls).toEqual([[shown], [used]]); + }); + }); }); diff --git a/apps/web/src/routes/privacy.tsx b/apps/web/src/routes/privacy.tsx index 66587999b78..539ac89f552 100644 --- a/apps/web/src/routes/privacy.tsx +++ b/apps/web/src/routes/privacy.tsx @@ -72,13 +72,14 @@ function PrivacyRoute() {

Unless you turn it off, the bb server sends us anonymous usage events through PostHog: app starts, thread and message counts, - plugin installs, and setup progress, such as which first-run steps - you completed or skipped. - Each event carries a random install ID, the app version, and your - operating system and architecture. Events never include your - prompts, code, file names, project names, or account details. Turn - this off in Settings → General → Share anonymous usage - data, or set BB_TELEMETRY=false. + plugin installs, setup progress, such as which first-run steps you + completed or skipped, and + which built-in tips were shown or used. Each event carries a random + install ID, the app version, and your operating system and + architecture. Events never include your prompts, code, file names, + project names, or account details. Turn this off in Settings → + General → Share anonymous usage data, or set{" "} + BB_TELEMETRY=false.

The iOS and Android apps store the following on your device:

diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 3cb387d2f83..b847364756d 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -4066,6 +4066,65 @@ Stabilize once message links have shipped and the seq has stayed stable across edit-and-rerun, forks and context clears, and decide whether `sourceSeqEnd` should remain alongside it. +## In-app routes and app commands (`BbNavigate.experimental_openAppRoute`, `BbNavigate.experimental_runAppCommand`) + +`useBbNavigate().experimental_openAppRoute(path)` navigates the current window +to an in-app route such as `/settings/mobile`, `/settings/plugins/`, +or `/settings/updates#whats-new`, keeping the query and hash. It returns false +without navigating unless the path starts with a single `/`, resolves to the +app's own origin, and contains no backslash or control character; `/api` and +`/api/*` are refused. `useBbNavigate().experimental_runAppCommand(commandId)` +dispatches one allowlisted navigation command (`palette.open`, +`thread.search`, or `settings.open`) through the same handler chain as its +keyboard shortcut, with no event target. It returns false for every other id, +including state-changing app commands such as `thread.archive`, +`question.select.*`, or `plugins.enterSafeMode`, for plugin command ids, and +for commands that no mounted handler accepted. The SDK +frontend harness records both as `navigateCalls` and takes `openAppRoute` / +`runAppCommand` acceptance options. First consumer: the bundled Tips plugin. + +Before stabilization, decide whether routes should be typed destinations +(settings section, plugin configuration page) instead of raw paths so bb can +rename routes without breaking plugins, whether the navigation allowlist should +grow into a public versioned vocabulary, whether commands should accept a target +or surface, and how both behave on mobile and in split panes. + +## Composer placeholder preview (`PluginComposerApi.experimental_setPlaceholderPreview`) + +`useComposer().experimental_setPlaceholderPreview(text)` shows `text` as that +composer's placeholder in place of the host's own; `null` restores it. It shows +only while the draft is empty and never edits the draft. The editor's +accessible label keeps the host placeholder. Each `useComposer()` call site owns +one preview, released when it unmounts or its composer scope changes; with +several owners, the most recently set preview shows. Handles from +`useComposers()` warn and do nothing. The SDK frontend harness records +`composer.placeholderPreview` and `composer.placeholderPreviewCalls`. First +consumer: the bundled Tips plugin, which previews a tip's prompt while the +pointer or focus is on it. + +Before stabilization, decide whether the preview should support rich content +(mention pills, a highlighted task slot) instead of plain text, how it is +announced to screen readers, whether owners in different plugins should compete +by recency or by an explicit priority, and how it behaves in compact layouts +that show their own placeholder. + +## Setup state for home-screen sections (`PluginHomepageSectionProps.experimental_setupComplete`) + +Every `homepageSection` component receives `experimental_setupComplete`. It is +true once bb's setup guide has been finished or skipped and at least one thread +exists. It is false during first-run setup, before the first thread, and while +that data loads, so a section never appears during setup and then jumps. Core +computes it once in `useSetupComplete` +(`apps/app/src/components/onboarding/useSetupComplete.ts`), and +`RootComposeView` passes it through `RootComposeSecondaryContent` to +`PluginHomepageSections`. It is optional because hosts older than SDK 0.6.41 +leave it undefined. First consumer: the bundled Tips plugin, which renders +nothing and makes no calls until it is true. + +Before stabilization, decide whether setup state belongs in a general +onboarding hook available to every slot rather than one slot's props, and how +it should read on hosts without the setup guide (mobile, remote web). + ## `PluginSidebarThreadActions.experimental_archiveEnvironmentThreads` Archives an environment's active thread trees through the host flow, including optimistic cache updates, pane cleanup, shared toast styling, and one ten-second Undo action. Undo restores only returned archived IDs, sequentially with lifecycle owners first. Archive failures show a host error toast and reject; Undo failures show a host error toast. diff --git a/docs/configuration.md b/docs/configuration.md index 7ac735d3596..c61aa8c227e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -355,6 +355,23 @@ bb concurrency-limit global [unlimited|] [--json] bb concurrency-limit host [auto|] [--json] ``` +The builtin Tips plugin shows three contextual tips as a small feed under the +composer on the desktop and web New thread page, never on phones or compact +layouts. Each new visit adds one tip at the top and drops the oldest, cycling through every eligible tip before repeating; a clicked tip stays out of the next visit. A tip retires once its feature is in use or it is dismissed. Its "Show tips" switch (`enabled`) has no fixed +default: the first time Tips runs it turns tips on for new installs (no +threads, or none older than two weeks) and off for existing ones, saves that +to the switch, and never revisits it. An explicit choice always wins. "Hide +tips" on the page turns the switch off; set it with +`bb plugin config bb--tips set enabled true|false`. Inspect or change tips from +an agent or terminal with: + +```sh +bb tips [--all] [--json] +bb tips hide [--undo] [--json] +bb tips dismiss [--json] +bb tips reset [--json] +``` + The "Show diagnostic events" toggle in Settings → General → Privacy & diagnostics shows provider environment resolution and raw provider events that bb does not yet understand. It defaults to off in all builds. Warnings, errors, and model fallback remain @@ -2084,8 +2101,12 @@ through `POST /api/v1/system/telemetry/events` (`sdk.system.experimental_recordT `onboarding_started` (whether an agent was installed or ready at first launch), `onboarding_step_reached`, `onboarding_step_completed`, and `onboarding_step_skipped` (per setup-guide step, marked first run or replay), -`onboarding_finished` (completed or skipped). That route -accepts only those events and their fixed, non-identifying properties. +`onboarding_finished` (completed or skipped), plus +`tip_shown` (once per tip per visit to the New thread page) and `tip_used` (on +click) from the bundled Tips plugin, each with the built-in tip id, its position +in the feed (1-3), and its action type (`prompt`, `open-page`, `run-command`, +`open-plugin`, or `learn-more`). That route accepts only those events and their +fixed, non-identifying properties. Anonymous usage telemetry can be disabled in Settings → General → Privacy & diagnostics → Share anonymous usage data, or with `bb settings general telemetryEnabled false`. The saved server-wide preference diff --git a/packages/bb-app/scripts/smoke-tarball.mjs b/packages/bb-app/scripts/smoke-tarball.mjs index 4bdfdd70eb1..fa78035781d 100644 --- a/packages/bb-app/scripts/smoke-tarball.mjs +++ b/packages/bb-app/scripts/smoke-tarball.mjs @@ -41,6 +41,7 @@ const EXPECTED_RUNNING_BUILTIN_PLUGINS = [ "provider-retry", "scheduled-send", "secrets", + "bb--tips", ]; // The smoke drives every bridge as a canonical Provider Bridge Protocol // client, which is the only dialect the bridges still speak. Mirrors diff --git a/packages/bundled-plugins/package.json b/packages/bundled-plugins/package.json index 481188670fd..a138bd4299f 100644 --- a/packages/bundled-plugins/package.json +++ b/packages/bundled-plugins/package.json @@ -38,6 +38,7 @@ "bb-plugin-provider-pi": "workspace:*", "bb-plugin-provider-retry": "workspace:*", "bb-plugin-bb--provider-usage": "workspace:*", + "bb-plugin-bb--tips": "workspace:*", "bb-plugin-push-notifications": "workspace:*", "bb-plugin-bb--prompt-library": "workspace:*", "bb-plugin-scheduled-send": "workspace:*", diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 63e081f7ea2..0a11ef76b31 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.6.40"; +export const PLUGIN_SDK_VERSION = "0.6.41"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index bd574720226..ef75d810cf0 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -49,6 +49,14 @@ the current host accepted the intent. Targets never infer an ambient workspace. The frontend harness records both methods and accepts `openFilePreview` and `openFileExternally` behavior options. +`experimental_openAppRoute(path)` opens an in-app bb route such as +`/settings/mobile` or `/settings/plugins/` (query and hash kept) and +returns false for anything that is not a same-origin app path. +`experimental_runAppCommand(commandId)` runs a built-in app command such as +`palette.open` or `thread.search` as its shortcut would, returning false for +unknown ids or when nothing handled it. The frontend harness records both and +accepts `openAppRoute` and `runAppCommand` behavior options. + To show a terminal beside your UI, create it with `useSdk().terminals.create` in the thread, environment, or host directory you want, then call `useBbNavigate().experimental_openTerminal({ terminalId })`. It resolves diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index a0663118099..d96e6ddfa17 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.6.40", + "version": "0.6.41", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 1e9a324a94c..ad0d17c610a 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -54,6 +54,14 @@ import type { export interface PluginHomepageSectionProps { /** Project in view on the compose surface; null when none is selected. */ projectId: string | null; + /** + * True once bb's setup guide has been finished or skipped and at least one + * thread exists. False during first-run setup, before the first thread, + * and while that state loads. Undefined on hosts older than plugin SDK + * 0.6.41. Experimental: see + * docs/api_to_audit.md. + */ + experimental_setupComplete?: boolean; } /** @@ -160,11 +168,10 @@ export interface ExperimentalQuestionFormHost { * the host adds room for its microphone controls when voice input is * available. */ -export interface ExperimentalVoiceInputTextareaProps - extends Omit< - ComponentPropsWithRef<"textarea">, - "value" | "defaultValue" | "onChange" | "children" - > { +export interface ExperimentalVoiceInputTextareaProps extends Omit< + ComponentPropsWithRef<"textarea">, + "value" | "defaultValue" | "onChange" | "children" +> { value: string; /** * Receives typed edits and finished transcripts, which the host appends to @@ -501,7 +508,11 @@ export interface PluginMessageDirectiveProps { export interface PluginHomepageSectionRegistration { /** Unique within the plugin; letters, digits, `-`, `_`. */ id: string; - title: string; + /** + * Optional host-rendered section heading. Omit it when the component + * renders its own heading or may render nothing, so no empty heading shows. + */ + title?: string; component: ComponentType; } @@ -1190,7 +1201,9 @@ export interface ExperimentalClipboardContent { */ export type PluginBoundThreadsArea = Omit< BbSdkAreas["threads"], - "getPluginMetadata" | "updatePluginMetadata" | "experimental_listPluginMetadata" + | "getPluginMetadata" + | "updatePluginMetadata" + | "experimental_listPluginMetadata" > & { getPluginMetadata( args: Omit & { pluginId?: string }, @@ -2799,6 +2812,15 @@ export interface PluginComposerApi { * scope changes. */ setInputLock(locked: boolean): void; + /** + * Show `text` as this composer's placeholder in place of the host's own, or + * pass null to restore it. Like any placeholder it shows only while the + * draft is empty, and it never changes the draft. Previews are scoped to + * the calling surface and clear automatically when it unmounts or its + * composer scope changes; when several are set, the most recent one shows. + * Has no effect on handles from `useComposers()`. + */ + experimental_setPlaceholderPreview(text: string | null): void; /** * @internal Legacy quoting method; retained at runtime for older plugins. * Append text to the draft as a `> ` blockquote block and focus the @@ -3469,6 +3491,25 @@ export interface BbNavigate { experimental_openFileExternally( options: ExperimentalFileOpenOptions, ): boolean; + /** + * Navigate this window to an in-app bb route such as `/settings/mobile`, + * `/settings/plugins/`, or `/settings/updates#whats-new`. The + * path must start with a single `/` and stay on the app's origin; the + * query and hash are kept. Returns false, without navigating, for anything + * else (absolute URLs, protocol-relative `//host` paths, backslashes, or + * `/api/` routes). Experimental: see docs/api_to_audit.md. + */ + experimental_openAppRoute(path: string): boolean; + /** + * Run one of bb's navigation app commands by id, exactly as its keyboard + * shortcut or palette row would. Only `palette.open` (quick palette), + * `thread.search` (thread search), and `settings.open` are accepted; every + * other id, including commands that change state and plugin command ids, + * returns false without running. Also returns false when nothing on the + * current screen handled the command. Experimental: see + * docs/api_to_audit.md. + */ + experimental_runAppCommand(commandId: string): boolean; /** * Show a terminal session in this surface's BB terminal panel: select its * tab, adding one when needed, and reveal the panel. Create the session diff --git a/packages/plugin-sdk/src/internal/composer-handle.ts b/packages/plugin-sdk/src/internal/composer-handle.ts index 4c0bd5b674f..1b8d7995448 100644 --- a/packages/plugin-sdk/src/internal/composer-handle.ts +++ b/packages/plugin-sdk/src/internal/composer-handle.ts @@ -60,6 +60,7 @@ export interface ComposerHandleController { mentionText(mention: ComposerMention): string; setTextEffect(effect: PluginComposerTextEffect | null): void; setInputLock(locked: boolean): void; + setPlaceholderPreview(text: string | null): void; onSubmitted(listener: () => void): () => void; } @@ -431,6 +432,8 @@ export function createComposerHandleBinding( insert, setTextEffect: (effect) => controller.setTextEffect(effect), setInputLock: (locked) => controller.setInputLock(locked), + experimental_setPlaceholderPreview: (text) => + controller.setPlaceholderPreview(text), addQuote: (text) => { warnDeprecatedComposerMember("addQuote", "replace"); if (!legacyAvailable("addQuote")) return; diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 9614e0f9194..2db8bae2370 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -580,9 +580,13 @@ export function collectPluginAppRegistrations( const kind = "slots.homepageSection"; const id = requireSlotId(kind, registration?.id); requireUniqueId(kind, seenIds.homepageSection, id); + const title = + registration.title === undefined + ? undefined + : requireNonEmptyString(kind, "title", registration.title); collected.homepageSections.push({ id, - title: requireNonEmptyString(kind, "title", registration.title), + ...(title !== undefined ? { title } : {}), component: requireComponent(kind, registration.component), }); }, diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index ee91ec107cf..c28ac8ca785 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -331,6 +331,29 @@ function FileNavigationProbe() { ); } +function AppRouteAndCommandProbe() { + const navigate = useBbNavigate(); + const [results, setResults] = useState(""); + return ( +
+ + {results} +
+ ); +} + function MalformedFileLinkProbe() { return ( | null = null; let capturedComposerSetSelection: PluginComposerApi["setSelection"] | null = null; @@ -401,6 +424,8 @@ function ComposerProbe() { capturedComposerVisualSetters = { setTextEffect: composer.setTextEffect, setInputLock: composer.setInputLock, + experimental_setPlaceholderPreview: + composer.experimental_setPlaceholderPreview, }; capturedComposerSetSelection = composer.setSelection; return ( @@ -685,9 +710,9 @@ describe("loadPluginApp", () => { expect(mounted.inspection.experimental_clipboardWrites).toEqual([ { text: "plain", html: "rich" }, ]); - await expect( - experimental_copyToClipboard({ text: "later" }), - ).resolves.toBe(false); + await expect(experimental_copyToClipboard({ text: "later" })).resolves.toBe( + false, + ); const slot = renderSlot(captured.navPanels[0]!, { subPath: "" }); await expect( @@ -1620,6 +1645,25 @@ describe("renderSlot", () => { ]); }); + it("records app-route and app-command intents with host acceptance", () => { + const slot = renderSlot( + { component: AppRouteAndCommandProbe }, + {}, + { + openAppRoute: (path) => path === "/settings/mobile", + runAppCommand: () => false, + }, + ); + fireEvent.click( + slot.getByRole("button", { name: "Open route and command" }), + ); + expect(slot.getByRole("status").textContent).toBe("true,false"); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_openAppRoute", path: "/settings/mobile" }, + { method: "experimental_runAppCommand", commandId: "palette.open" }, + ]); + }); + it("records fixed-tab opens and retains target state until the owner clears it", () => { const slot = renderSlot( { component: FixedTabProbe }, @@ -1962,6 +2006,33 @@ describe("renderSlot", () => { } }); + it("records placeholder previews and clears them on unmount", () => { + const slot = renderSlot( + app.composerCustomizations[0]!.actions![0]!, + {}, + { context: { projectId: "proj_1", threadId: "thr_1" } }, + ); + const setters = capturedComposerVisualSetters; + if (setters === null) throw new Error("composer setters were not captured"); + + setters.experimental_setPlaceholderPreview("Spin up three child threads"); + expect(slot.composer.placeholderPreview).toBe( + "Spin up three child threads", + ); + setters.experimental_setPlaceholderPreview(null); + expect(slot.composer.placeholderPreview).toBeNull(); + setters.experimental_setPlaceholderPreview("Build me a bb plugin"); + + slot.unmount(); + expect(slot.composer.placeholderPreview).toBeNull(); + setters.experimental_setPlaceholderPreview("late preview"); + expect(slot.composer.placeholderPreviewCalls).toEqual([ + "Spin up three child threads", + null, + "Build me a bb plugin", + ]); + }); + it("invalidates visual-state setters when Testing Library cleans up the root", () => { const slot = renderSlot( app.composerCustomizations[0]!.actions![0]!, diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 23986d1d5bb..7328ab36833 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -200,6 +200,8 @@ export type NavigateCall = method: "experimental_openFileExternally"; options: ExperimentalFileOpenOptions; } + | { method: "experimental_openAppRoute"; path: string } + | { method: "experimental_runAppCommand"; commandId: string } | { method: "experimental_openTerminal"; options: Parameters[0]; @@ -234,6 +236,9 @@ export interface ComposerLog { /** Whether this plugin currently holds the composer input lock. */ inputLocked: boolean; inputLockCalls: boolean[]; + /** Latest placeholder preview the plugin set, or null when it cleared it. */ + placeholderPreview: string | null; + placeholderPreviewCalls: Array; quotes: string[]; mentions: PluginComposerMention[]; focusCount: number; @@ -1823,6 +1828,10 @@ export interface RenderSlotOptions< openFilePreview?: (options: ExperimentalFileOpenOptions) => boolean; /** Host acceptance for preferred-external file intents. */ openFileExternally?: (options: ExperimentalFileOpenOptions) => boolean; + /** Host acceptance for `experimental_openAppRoute`; omitted → false. */ + openAppRoute?: (path: string) => boolean; + /** Host acceptance for `experimental_runAppCommand`; omitted → false. */ + runAppCommand?: (commandId: string) => boolean; /** Host acceptance for `useBbNavigate().experimental_openTerminal`. */ openTerminal?: ( options: Parameters[0], @@ -2186,6 +2195,14 @@ export function renderSlot< }); return options.openFileExternally?.(fileOptions) ?? false; }, + experimental_openAppRoute(path) { + navigateCalls.push({ method: "experimental_openAppRoute", path }); + return options.openAppRoute?.(path) ?? false; + }, + experimental_runAppCommand(commandId) { + navigateCalls.push({ method: "experimental_runAppCommand", commandId }); + return options.runAppCommand?.(commandId) ?? false; + }, async experimental_openTerminal(terminalOptions) { navigateCalls.push({ method: "experimental_openTerminal", @@ -2294,6 +2311,8 @@ export function renderSlot< textEffectCalls: [], inputLocked: false, inputLockCalls: [], + placeholderPreview: null, + placeholderPreviewCalls: [], quotes: [], mentions: [], focusCount: 0, @@ -2394,6 +2413,11 @@ export function renderSlot< composerLog.inputLocked = locked; composerLog.inputLockCalls.push(locked); }, + setPlaceholderPreview(text) { + if (!composerOwnership.active) return; + composerLog.placeholderPreview = text; + composerLog.placeholderPreviewCalls.push(text); + }, onSubmitted(listener) { submissionListeners.add(listener); return () => { @@ -2508,6 +2532,7 @@ export function renderSlot< composerOwnership.active = false; composerLog.textEffect = null; composerLog.inputLocked = false; + composerLog.placeholderPreview = null; }; const renderSlotTree = (ui: ReactNode): ReactElement => ( diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 5722ebe5a92..f083588f01c 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -206,6 +206,42 @@ const onboardingStepTelemetryPropertiesSchema = z entry: onboardingEntryTelemetrySchema, }) .strict(); +export const TIP_TELEMETRY_IDS = [ + "whats-new", + "account-pool", + "child-threads", + "set-up-for-me", + "phone", + "browser-automation", + "build-plugin", + "open-threads-that-need-me", + "morning-digest", + "decision-buttons", + "automations", + "queue-or-steer", + "thread-search", + "command-palette", + "provider-usage", + "another-agent", + "remote-access", + "bb-cli", + "add-agent", + "browse-plugins", + "notifications", +] as const; +const tipTelemetryPropertiesSchema = z + .object({ + tip_id: z.enum(TIP_TELEMETRY_IDS), + position: z.number().int().min(1).max(3), + action: z.enum([ + "prompt", + "open-page", + "run-command", + "open-plugin", + "learn-more", + ]), + }) + .strict(); export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [ z @@ -246,6 +282,18 @@ export const recordTelemetryEventRequestSchema = z.discriminatedUnion("name", [ .strict(), }) .strict(), + z + .object({ + name: z.literal("tip_shown"), + properties: tipTelemetryPropertiesSchema, + }) + .strict(), + z + .object({ + name: z.literal("tip_used"), + properties: tipTelemetryPropertiesSchema, + }) + .strict(), ]); export type RecordTelemetryEventRequest = z.infer< typeof recordTelemetryEventRequestSchema diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index f0956264ab7..99b3803d118 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -157,6 +157,22 @@ bb concurrency-limit global [unlimited|] [--json] bb concurrency-limit host [auto|] [--json] ``` +The builtin Tips plugin shows three contextual tips as a small feed under the +composer on the desktop and web New thread page (never on phones or compact +layouts). Each new visit adds one tip at the top and drops the oldest, cycling through every eligible tip before repeating; a clicked tip stays out of the next visit. A tip retires once its feature is in use or it is dismissed. Tips are on by default for new installs (no +threads, or none older than two weeks, when Tips first runs) and off for +existing ones; that decision is made once and saved to the plugin's "Show +tips" switch, and an explicit choice always wins. "Hide tips" on the page +turns them off. Turn them on or off with the switch or +`bb plugin config bb--tips set enabled true|false`. The CLI equivalents are: + +``` +bb tips [--all] [--json] +bb tips hide [--undo] [--json] +bb tips dismiss [--json] +bb tips reset [--json] +``` + The builtin Provider retry plugin is enabled on fresh installations. It retries Codex and Claude Code turns after structured provider overloads and subscription window limits. A pending retry is a queued row on the thread, so a server @@ -747,9 +763,11 @@ useBbNavigate (including openUrl(url), which applies the current client's in-app/external-browser preference, plus experimental_openFilePreview({ target, location }) and experimental_openFileExternally({ target, location }) for explicit live -workspace/host/thread-storage files, and experimental_openTerminal({ -terminalId }), which shows a terminal created with useSdk().terminals.create -in the current surface's terminal panel), and useComposer (one stable handle for +workspace/host/thread-storage files, experimental_openAppRoute(path) for an +in-app route such as /settings/mobile, experimental_runAppCommand(id) for a +built-in app command such as palette.open or thread.search, and +experimental_openTerminal({ terminalId }), which shows a terminal created with +useSdk().terminals.create in the current surface's terminal panel), and useComposer (one stable handle for the bound composer: read its text, mentions, reactive picker selection, scope, layout, run and submit state, and why submitting is blocked; replace/update/clear text; insert text and mentions at the cursor or end; apply a class-based text effect, lock input, diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md index 9175bba50af..aca47747375 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md @@ -60,8 +60,11 @@ cleanup-safe example: `examples/plugins/content-script`. Slot props contracts (versioned, additive-only): -- `homepageSection` → `{ projectId: string | null }` (project in view on - the compose surface). Registration: `{ id, title, component }`. +- `homepageSection` → `{ projectId: string | null, + experimental_setupComplete?: boolean }` (project in view on the compose + surface; whether bb's setup guide is finished or skipped and a first thread + exists, undefined on hosts before SDK 0.6.41). Registration: `{ id, title?, component }`; omit + `title` for a section that renders its own heading or may render nothing. - `settingsSection` → `{}` (deliberately no props in V1). Rendered on the plugin detail page below the host-rendered declarative settings form for running, needs-configuration, and degraded plugins. Registration: diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md index ad99680fb3e..57d990d03a1 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md @@ -45,6 +45,7 @@ Hooks: { subPath?, replace? }?), toCompose({ initialPrompt?, focusPrompt? }?), openThreadPanel({ actionId, title?, params? }), openUrl(url), experimental_openFilePreview(options), experimental_openFileExternally(options), +experimental_openAppRoute(path), experimental_runAppCommand(commandId), experimental_openTerminal({ terminalId }) }`. `toCompose` opens the root compose screen; pass `initialPrompt` to seed the composer draft and `focusPrompt: true` to focus it. The panel @@ -55,6 +56,11 @@ experimental_openTerminal({ terminalId }) }`. `openUrl` owns HTTP(S) only and returns false for schemes BB leaves to normal anchor behavior. The two file methods accept an `ExperimentalFileOpenOptions` live-file target. + `experimental_openAppRoute` opens a same-origin in-app route such as + `/settings/mobile` or `/settings/plugins/#section` and returns + false for anything else. `experimental_runAppCommand` runs a built-in app + command such as `palette.open` or `thread.search` and returns false for + unknown ids or when no handler on screen ran it. `experimental_openTerminal` shows a terminal the plugin created with `useSdk().terminals.create` in the current surface's terminal panel; the create scope (thread, environment, or host path) picks its directory. It @@ -102,6 +108,9 @@ experimental_openTerminal({ terminalId }) }`. - `focus()` focuses the caret. `setTextEffect({ className })` paints the draft (`null` clears); `setInputLock(locked)` makes the editor read-only and auto-releases when the slot unmounts or changes scope. + `experimental_setPlaceholderPreview(text)` shows `text` as the empty + composer's placeholder (`null` restores bb's) without touching the draft, + for example to preview a prompt on hover; it clears on unmount. - Save `composer.draft`, then restore it with `composer.replace(saved)`. For an LLM rewrite, map preserved mention placeholders to explicit ranges in the resulting text and replace text and mentions together. @@ -136,8 +145,8 @@ reasoningLevel?, serviceTier?, permissionMode? })` sets the pickers as if queued-message editors), oldest first. Use it from a panel or page that writes into a composer the user picks: label each by `scope`, then call `insert`, `focus` or `submit` on the chosen one. Handles are the same - `PluginComposerApi` with the same lifetime rule; `setTextEffect` and - `setInputLock` have no effect here. Re-renders when the list or a listed + `PluginComposerApi` with the same lifetime rule; `setTextEffect`, + `setInputLock` and `experimental_setPlaceholderPreview` have no effect here. Re-renders when the list or a listed draft or selection changes. - `experimental_useCodeTheme()` → `{ mode, name, theme }` — the code theme bb is currently rendering with. `mode` is `"light" | "dark"`, `name` is the diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md index c77dd1c930a..68282f0099b 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/testing.md @@ -173,6 +173,8 @@ const slot = renderSlot( context: { projectId: "p1", threadId: null }, // useBbContext() realtimeConnectionState: "reconnecting", // useRealtimeConnectionState() openUrl: (url) => url.startsWith("https://"), + openAppRoute: (path) => path.startsWith("/settings/"), + runAppCommand: (commandId) => commandId === "palette.open", }, ); await slot.findByText("…"); // Testing Library queries diff --git a/plugins/bb-official.json b/plugins/bb-official.json index e13aa06f8b3..7c4fe8bf0cc 100644 --- a/plugins/bb-official.json +++ b/plugins/bb-official.json @@ -106,6 +106,10 @@ "category": "thread-management", "screenshots": [] }, + "tips": { + "category": "memory-and-context", + "screenshots": [] + }, "concurrency-limit": { "category": "tasks-and-workflows", "screenshots": [] diff --git a/plugins/plugin-api-docs/src/first-party-plugins.json b/plugins/plugin-api-docs/src/first-party-plugins.json index 7f753db45cf..f1a8c6316ea 100644 --- a/plugins/plugin-api-docs/src/first-party-plugins.json +++ b/plugins/plugin-api-docs/src/first-party-plugins.json @@ -189,6 +189,11 @@ "name": "Theme Preview", "icon": "Palette" }, + { + "id": "bb--tips", + "name": "Tips", + "icon": "Explore" + }, { "id": "thread-list", "name": "Thread list", diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index e1a587a6af3..2ac9a70d2d3 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -422,8 +422,9 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "PluginCommandRegistration", "PluginCommandContext", "PluginCommandShortcut", + "BbNavigate.experimental_runAppCommand", ], - firstParty: ["File Editor"], + firstParty: ["File Editor", "Tips"], }, ], }, @@ -577,6 +578,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "ExperimentalComposerPopupRegistration", "PluginComposerApi.experimental_openPopup", "PluginComposerApi.experimental_closePopup", + "PluginComposerApi.experimental_setPlaceholderPreview", "PluginAppComposer.experimental_registerCommand", "ExperimentalComposerCommandRegistration", "PluginComposerApi.setSelection", @@ -600,11 +602,14 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ bullets: [ "Show shortcuts, pinned work, or recent activity before any thread exists", "Span the full width below the composer on desktop", + "Wait until setup is done and a first thread exists before showing anything", ], apiSymbols: [ "PluginHomepageSectionRegistration", "PluginHomepageSectionProps", + "PluginHomepageSectionProps.experimental_setupComplete", ], + firstParty: ["Tips"], }, { id: "new-thread-panel", @@ -1400,6 +1405,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "BbNavigate.openUrl", "BbNavigate.experimental_openFilePreview", "BbNavigate.experimental_openFileExternally", + "BbNavigate.experimental_openAppRoute", ], firstParty: [ "Account Pooler [Experimental]", diff --git a/plugins/tips/AUTHORING.md b/plugins/tips/AUTHORING.md new file mode 100644 index 00000000000..6d3b81ef879 --- /dev/null +++ b/plugins/tips/AUTHORING.md @@ -0,0 +1,156 @@ +# Authoring tips + +A tip is one entry in `TIP_CATALOG` in `catalog.ts`, drawn by one entry in +`ILLUSTRATIONS` in `illustrations.tsx`. `catalog.test.ts` and the host tests +(`apps/server/test/services/plugins/tips-catalog-targets.test.ts`, +`apps/app/src/components/settings/tips-catalog-routes.test.ts`) enforce the +rules below. + +## How tips reach people + +The New thread page shows a feed of three tips. Each new visit (a fresh mount, +at least 10 minutes after the last one) adds one tip at the top and drops the +oldest. The engine works through every eligible tip in ranked order (by +`tier`, then `boost`, then `priority`) before it repeats any. A clicked tip is +left out of the next +visit and comes back only after the rest of the library has been shown. A tip +retires for good when `retireWhen` turns true or the person dismisses it. +Held and expired tips never show. No tips show until bb's setup guide is +finished or skipped and a first thread exists. + +## Fields + +| Field | Rule | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Unique, kebab-case, listed in `TIP_IDS` (`contract.ts`) and `TIP_TELEMETRY_IDS` (`packages/server-contract/src/api/system.ts`). Never rename one; storage keys and telemetry use it. | +| `title` | Up to 45 characters. Sentence case, no trailing period. | +| `body` | One sentence, up to 110 characters. | +| `illustration` | Key in `ILLUSTRATIONS`. Usually the tip id. | +| `tone` | `blue`, `green`, `amber`, `orange`, or `rose`. Colors the illustration's one accent. | +| `action` | One of the action types below. | +| `source` | `{ kind: "changelog" \| "blog" \| "guide" \| "feature", ref, version? }`: where the tip comes from. | +| `addedAt` | bb version the tip ships in. | +| `reviewedAt` | bb version the copy and eligibility were last checked against. Never later than `CATALOG_REVIEWED_THROUGH`. | +| `expiresAt` | Optional bb version from which the tip stops showing. Must be later than `CATALOG_REVIEWED_THROUGH`, so an expired tip fails CI. | +| `held` | Optional. `true` keeps a finished tip out of every feed. | +| `tier` | `1`, `2`, `3`, or `"unranked"`. See [Tiers](#tiers). | +| `priority` | Ordering inside a tier. | +| `eligible(signals)` | When the tip fits this person right now. | +| `retireWhen(signals)` | When the person already uses the feature. Retirement is permanent. | +| `boost(signals)` | Optional. Pushes a tip ahead when context makes it urgent. | + +Bump `CATALOG_REVIEWED_THROUGH` when you review the whole catalog against a +release. + +## Tiers + +Tiers come from which early behaviors go with people sticking with bb. The feed +shows tier 1 first, then unranked tips, then tier 2, then tier 3; `boost` and +`priority` only reorder tips inside one tier, so even an urgent boost never +lifts a tip above a higher tier. A tier 3 tip appears only after every eligible +higher-tier tip has been shown. + +- **Tier 1:** the strongest early habits: child threads, the mobile app, a + second agent on the same task, and asking the agent to build a plugin. +- **Unranked:** no clear signal yet. Place these by judgment with `priority`; + automations lead because they give people a reason to come back. +- **Tier 2:** weaker but real: remote access, the bb CLI, and running more + than one agent. +- **Tier 3:** features not worth promoting early, such as queued follow-ups, + installing catalog plugins, or ACP agents. + +New tips start `"unranked"` unless the owner places them. Keep tier reasons in +words; never put metrics, percentages, or sample sizes in this repository. + +## Voice + +- Plain and direct: "Ask bb to…", "Your agent can…", "Press ⌘K to…". +- Describe the outcome, not the mechanism: say what the person gets, not + how the feature is built. Use bb UI terms (such as "child threads") only + when pointing at that UI, or in a prompt that tells the agent which feature + to use. +- No feature-toggle caveats, no "new!", no exclamation marks. +- Use `{version}`, `{paletteKeys}`, and `{searchKeys}` instead of hard-coding them. + +## Actions + +Every click marks the tip used and confirms the result inside the clicked card. +`actions.ts` implements each type once. + +| Type | Use it when | Click | +| ------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `prompt` | bb can do it if asked. End the prompt with `Task: ` when the person's draft belongs after it. | Fills the composer, moves the draft into the `Task: ` slot, focuses the caret at the end. | +| `open-page` | A core page explains or sets it up. `path` must be `/plugins` (the plugin store), `/settings`, or a real Settings section. | Opens the in-app route. | +| `run-command` | A command does it: `palette.open`, `thread.search`, or `settings.open`. | Runs the command as its shortcut would. | +| `open-plugin` | A built-in plugin does it. `pluginId` must be in `builtin-registry.ts`; never a personal plugin. | Opens the plugin's detail tab. | +| `learn-more` | Only an `https://` release note, blog post, or guide explains it. | Opens the link with the person's browser preference. | + +Prefer `prompt`, then `open-plugin` or `open-page`. Use `learn-more` last. + +### Walkthrough prompts + +When a feature takes several setup steps (connecting the mobile app, turning +on bb connect, building a first plugin, adding a second agent), use a `prompt` +built with `walkthroughPrompt(goal)` in `catalog.ts` instead of opening a page. +It is the only place the wording lives: + +> Walk me through `` in this bb, one step at a time, and check each step +> with me. If the playground tool is available, show the steps as a +> playground; otherwise reply with plain numbered steps. + +Make the goal specific and about something bb ships. Tips never depend on the +Playgrounds plugin: the prompt asks for a playground only when +the tool is there, and plain numbered steps otherwise. Prompts that act on the +person's own task, such as child threads, end with `Task: ` instead. + +## Feed layout + +The feed is built from bb's shared activation components: +`SuggestionSection`, `SuggestionGroup`, and `SuggestionCard` from +`@/components/ui/suggestion-card`, and `InlineConfirmation` from +`@/components/ui/inline-confirmation`. Don't restyle them or add local row +styles; the tip's illustration is the card's leading node. Keep the title and +description within the card's two-line limits by cutting copy. + +## Illustrations + +Build every drawing from the parts in `diagram-kit.tsx`, and check it in the +**Illustrations** and **Diagram kit** stories in Ladle (`plugins/Tips`). + +- **Grid.** 48 units, rendered at 64px. Keep a 3-unit margin. +- **Line.** Use `LINE`: `currentColor` at a 1.1 stroke with round caps and + joins. Fills use `WASH`; secondary lines use `soft`. +- **Color.** Ink tones from `currentColor` and `CANVAS`. Use exactly one small + accent, from the `accent` argument. Never write literal colors. +- **Rest state.** The drawing is still and complete at rest. It must read + without the animation. +- **Hover.** Give the part that acts out the tip `animate`, and `stagger` for + a sequence. Use one idea per drawing, about one second, with the end state + held while the card is hovered. Reduced motion turns animation off in the kit. + +| Animation | Does | +| ------------------------------------------ | ------------------------------------------------------------------- | +| `fill-in` | Scales from `from` (0–1) to full width; ProgressBar fills. | +| `flip-on` / `flip-off` | Fades a part in or out; Toggle flips on. | +| `glide` | Moves from the `from` transform to the drawn position; Slider knob. | +| `slide-in`, `fade-in`, `rise`, `travel` | Enters from an offset. | +| `pop`, `press`, `snap`, `twinkle`, `nudge` | Plays once and settles where it started. | +| `sweep` | Moves across and settles. | +| `grow` | Grows up from the baseline. | +| `spin` | One full turn around `origin`. | + +The parts are Panel, Window, Rule, Dot, ListLine, Node, Branch, ProgressBar, +Toggle, Slider, Button, CheckMark, Check, Cursor, Phone, Envelope, Keycap, +SearchGlyph, Magnifier, ChartAxes, Bar, CardStack, Arrow, Sparkle, Bubble, +Clock, CalendarGrid, Wrench, Pill, and Highlight. Add a part to the kit, with a +cell in the Diagram kit story, when two drawings need it. + +## Checklist + +- [ ] Copy follows the voice rules and the length limits. +- [ ] The action type fits; its target is a core route, a built-in plugin, or an `https://` link. +- [ ] The illustration uses kit parts, one accent, and one hover animation. +- [ ] `source`, `addedAt`, `reviewedAt`, and any `expiresAt` are set. +- [ ] `tier` is set; new tips are `"unranked"` unless the owner placed them. +- [ ] `eligible` and `retireWhen` have tests in `engine.test.ts` when they are new logic. +- [ ] The drawing reads at rest and on hover in light and dark, in the Illustrations story. diff --git a/plugins/tips/PLUGIN_OVERVIEW.md b/plugins/tips/PLUGIN_OVERVIEW.md new file mode 100644 index 00000000000..e407e71179e --- /dev/null +++ b/plugins/tips/PLUGIN_OVERVIEW.md @@ -0,0 +1,28 @@ +Learn what bb can do from a small feed under the composer. Tips shows three short tips on the New thread page, picked for how you use bb right now and for the project you have selected. + +## What you get + +- A Tips section of three cards under the composer on the desktop and web New thread page. Each card has a small illustration, a title, and a short description. After a click, the card confirms what happened. +- Hover or focus a prompt tip to preview its prompt in the empty composer; click it to fill the composer and put the cursor at the end. Anything you had already typed moves into the prompt's "Task:" slot. +- Setup tips open the right page or command, such as Account Pooler's settings. Tips for features that take a few steps, like connecting the mobile app or turning on bb connect, fill the composer with a prompt asking the agent to walk you through it. +- "Hide tips" turns tips off in one click, with Undo. Turn them back on with the Show tips switch in the plugin settings. +- No tips on phones or in compact layouts, and none until bb's setup guide is finished or skipped and you have started a thread. + +## How it works + +Tips are on by default for new installs and off for existing ones. The first time Tips runs it counts an install as new when it has no threads or none older than two weeks, saves that to the Show tips switch, and never changes it again; your own choice in the switch always wins. + +Each new visit to the New thread page brings one new tip in at the top and lets the oldest drop off, working through every tip that fits you before any repeats. A tip you click stays out of the next visit. Tips come in tiers based on which early behaviors go with people sticking with bb: splitting work across helpers, the mobile app, comparing agents, and building your own tool come first. Inside a tier, what matters now goes first, such as threads waiting on you or Account Pooler right after a usage limit. A tip appears only when it fits your setup: keyboard tips stay off touch devices, Browser Automation tips stay off Windows, and a tip for a plugin waits until that plugin is installed. A tip retires once bb sees you using the feature or when you dismiss it with `bb tips dismiss`. After an update, a What's new tip links to Settings → Updates once. + +Tips keeps its state and its shown, dismissed, and acted counts in its own plugin storage on this bb server. With bb's anonymous usage data sharing on, it also reports two anonymous events: `tip_shown` once per tip per visit and `tip_used` when you click a tip. Each carries only the built-in tip id, its position in the feed (1–3), and its action type, never your prompts, drafts, or project. Turn this off with Settings → General → Share anonymous usage data, `bb settings general telemetryEnabled false`, or `BB_TELEMETRY=false`. + +## For agents and scripts + +Use the `bb tips` command: + +- `bb tips` lists the tips that are eligible now and marks the three in the feed, newest first, and says how to turn tips on when they are off. Add `--all` to include dismissed, retired, held, and not-applicable tips. +- `bb tips hide` hides tips for the rest of today; `bb tips hide --undo` shows them again. +- `bb tips dismiss ` retires one tip. +- `bb tips reset` brings back every dismissed and retired tip. + +Add `--json` for machine-readable output. Turn tips on or off with `bb plugin config bb--tips set enabled true|false`. diff --git a/plugins/tips/actions.ts b/plugins/tips/actions.ts new file mode 100644 index 00000000000..cf5a906a5c8 --- /dev/null +++ b/plugins/tips/actions.ts @@ -0,0 +1,86 @@ +import type { + ComposerDraftReplacement, + ComposerDraftSnapshot, +} from "@get-bb/plugin-sdk/app"; +import { composeTipDraft } from "./compose.js"; +import type { TipAction, TipView } from "./contract.js"; + +export interface TipActionHost { + replaceDraft( + update: ( + current: ComposerDraftSnapshot, + ) => ComposerDraftReplacement | ComposerDraftSnapshot, + ): void; + focusComposer(): void; + openAppRoute(path: string): boolean; + runAppCommand(commandId: string): boolean; + openUrl(url: string): boolean; +} + +export interface TipActionResult { + ok: boolean; + announcement: string; +} + +export function pluginDetailPath(pluginId: string): string { + return `/plugins/${encodeURIComponent(pluginId)}`; +} + +function attempt(ok: boolean, done: string, label: string): TipActionResult { + return { + ok, + announcement: ok ? done : `Couldn't open ${label} here.`, + }; +} + +export function describeTipAction(action: TipAction): string { + switch (action.kind) { + case "prompt": + return "Adds prompt to composer"; + case "open-page": + return `Opens ${action.label}`; + case "run-command": + return action.label; + case "open-plugin": + return `Opens ${action.label}`; + case "learn-more": + return `Opens ${action.label} in the browser`; + } +} + +export function runTipAction( + tip: TipView, + host: TipActionHost, +): TipActionResult { + const action = tip.action; + switch (action.kind) { + case "prompt": + host.replaceDraft((current) => composeTipDraft(action.prompt, current)); + host.focusComposer(); + return { ok: true, announcement: "Added to the composer" }; + case "open-page": + return attempt( + host.openAppRoute(action.path), + `Opened ${action.label}`, + action.label, + ); + case "run-command": + return attempt( + host.runAppCommand(action.commandId), + `Opened ${action.label}`, + action.label, + ); + case "open-plugin": + return attempt( + host.openAppRoute(pluginDetailPath(action.pluginId)), + `Opened ${action.label}`, + action.label, + ); + case "learn-more": + return attempt( + host.openUrl(action.url), + `Opened ${action.label}`, + action.label, + ); + } +} diff --git a/plugins/tips/app.test.tsx b/plugins/tips/app.test.tsx new file mode 100644 index 00000000000..371fa529ba5 --- /dev/null +++ b/plugins/tips/app.test.tsx @@ -0,0 +1,435 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { TipView } from "./contract.js"; + +const app = await loadPluginApp(() => import("./app")); + +const PROMPT_TIP: TipView = { + id: "child-threads", + illustration: "child-threads", + tone: "blue", + title: "Split a big task across helpers", + body: "Ask bb to try three approaches in parallel.", + action: { + kind: "prompt", + label: "Try it", + prompt: "Spin up three child threads. Task: ", + }, +}; + +const ROUTE_TIP: TipView = { + id: "phone", + illustration: "phone", + tone: "rose", + title: "Check on your agents from your phone", + body: "The bb mobile app lets you follow threads away from your desk.", + action: { + kind: "open-page", + label: "Get the app", + path: "/settings/mobile", + }, +}; + +const COMMAND_TIP: TipView = { + id: "command-palette", + illustration: "command-palette", + tone: "rose", + title: "Do anything from the keyboard", + body: "Press ⌘⇧P to search bb's commands and settings.", + action: { + kind: "run-command", + label: "Open palette", + commandId: "palette.open", + }, +}; + +const IPHONE = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"; + +function setSectionWidth(width: number) { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 400, + width, + height: 400, + toJSON: () => ({}), + }); +} + +function setCompactLayout(matches: boolean) { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + +beforeEach(() => { + setSectionWidth(720); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function section() { + const registration = app.homepageSections[0]; + if (registration === undefined) throw new Error("missing homepage section"); + return registration; +} + +function renderTips( + tips: readonly TipView[] = [PROMPT_TIP, ROUTE_TIP, COMMAND_TIP], + options: { + openAppRoute?: (path: string) => boolean; + runAppCommand?: (commandId: string) => boolean; + openUrl?: (url: string) => boolean; + settings?: Record; + setupComplete?: boolean; + } = {}, +) { + const { setupComplete = true, ...behavior } = options; + return renderSlot( + section(), + { projectId: "proj_1", experimental_setupComplete: setupComplete }, + { + rpc: { + current: () => ({ tips }), + setEnabled: () => ({ ok: true }), + act: () => ({ ok: true }), + }, + sdk: { + system: { + experimental_recordTelemetryEvent: async () => ({ + ok: true as const, + }), + }, + }, + ...behavior, + }, + ); +} + +function telemetry(slot: ReturnType): unknown[] { + return slot.inspection.sdkCalls + .filter( + (call) => call.method === "system.experimental_recordTelemetryEvent", + ) + .map((call) => call.args[0]); +} + +function methods(slot: ReturnType): string[] { + return slot.inspection.rpcCalls.map((call) => call.method); +} + +function tile(slot: ReturnType, id: string): HTMLElement { + const element = slot.container.querySelector(`[data-tip-id="${id}"] button`); + if (!(element instanceof HTMLElement)) throw new Error(`missing tile ${id}`); + return element; +} + +describe("Tips homepage section", () => { + it("registers one untitled homepage section", () => { + expect(app.homepageSections).toHaveLength(1); + expect(section()).not.toHaveProperty("title"); + }); + + it("shows three tips as a feed for the selected project", async () => { + const slot = renderTips(); + expect( + await slot.findByText("Split a big task across helpers"), + ).toBeTruthy(); + expect(slot.getByRole("region", { name: "Tips" })).toBeTruthy(); + expect(slot.container.querySelectorAll("[data-tip-id]")).toHaveLength(3); + expect( + slot.getByRole("button", { + name: /Split a big task across helpers.*Adds prompt to composer$/u, + }), + ).toBe(tile(slot, "child-threads")); + expect(slot.getByRole("button", { name: /Opens Get the app$/u })).toBe( + tile(slot, "phone"), + ); + expect(slot.getByRole("button", { name: /Open palette$/u })).toBe( + tile(slot, "command-palette"), + ); + expect(slot.queryByText("Adds prompt")).toBeNull(); + expect(slot.queryByText("Get the app")).toBeNull(); + expect(slot.inspection.rpcCalls[0]).toEqual({ + method: "current", + input: { + client: { + surface: "web", + os: expect.any(String), + notificationNudge: false, + }, + projectId: "proj_1", + visit: true, + }, + }); + }); + + it("renders nothing and asks for nothing on a phone", async () => { + vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IPHONE); + const slot = renderTips(); + await Promise.resolve(); + expect(methods(slot)).toEqual([]); + expect(slot.container.textContent).toBe(""); + }); + + it("renders nothing in the compact layout", async () => { + setCompactLayout(true); + const slot = renderTips(); + await Promise.resolve(); + expect(methods(slot)).toEqual([]); + expect(slot.queryByRole("region", { name: "Tips" })).toBeNull(); + }); + + it("renders nothing when the page is too narrow", async () => { + setCompactLayout(false); + setSectionWidth(390); + const slot = renderTips(); + await Promise.resolve(); + expect(methods(slot)).toEqual([]); + expect(slot.queryByRole("region", { name: "Tips" })).toBeNull(); + }); + + it("renders nothing and asks for nothing until bb setup is complete", async () => { + const slot = renderTips(undefined, { setupComplete: false }); + await Promise.resolve(); + expect(methods(slot)).toEqual([]); + expect(telemetry(slot)).toEqual([]); + expect(slot.container.textContent).toBe(""); + const Section = section().component; + slot.lifecycle.rerender( +
, + ); + expect( + await slot.findByText("Split a big task across helpers"), + ).toBeTruthy(); + expect(methods(slot)).toEqual(["current"]); + }); + + it("records each tip shown once per visit with its position and action", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + await slot.behavior.emitRealtime("tips-changed", {}); + await waitFor(() => expect(methods(slot)).toEqual(["current", "current"])); + expect(telemetry(slot)).toEqual([ + { + name: "tip_shown", + properties: { tip_id: "child-threads", position: 1, action: "prompt" }, + }, + { + name: "tip_shown", + properties: { tip_id: "phone", position: 2, action: "open-page" }, + }, + { + name: "tip_shown", + properties: { + tip_id: "command-palette", + position: 3, + action: "run-command", + }, + }, + ]); + }); + + it("records a used tip with its position and action", async () => { + const slot = renderTips(undefined, { openAppRoute: () => true }); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "phone")); + expect(telemetry(slot)).toContainEqual({ + name: "tip_used", + properties: { tip_id: "phone", position: 2, action: "open-page" }, + }); + }); + + it("records nothing for a tip id outside the catalog", async () => { + const slot = renderTips([{ ...PROMPT_TIP, id: "someone-elses-tip" }]); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "someone-elses-tip")); + expect(telemetry(slot)).toEqual([]); + }); + + it("asks for nothing while tips are turned off", async () => { + const slot = renderTips(undefined, { settings: { enabled: false } }); + await Promise.resolve(); + expect(methods(slot)).toEqual([]); + expect(slot.queryByRole("region", { name: "Tips" })).toBeNull(); + }); + + it("previews a prompt tip as the composer placeholder on hover and focus", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + fireEvent.mouseEnter(tile(slot, "child-threads")); + await waitFor(() => + expect(slot.composer.placeholderPreview).toBe( + "Spin up three child threads. Task:", + ), + ); + fireEvent.mouseLeave(tile(slot, "child-threads")); + await waitFor(() => expect(slot.composer.placeholderPreview).toBeNull()); + fireEvent.focus(tile(slot, "phone")); + await Promise.resolve(); + expect(slot.composer.placeholderPreview).toBeNull(); + fireEvent.focus(tile(slot, "child-threads")); + await waitFor(() => + expect(slot.composer.placeholderPreview).not.toBeNull(), + ); + await slot.setComposerText("my own draft"); + await waitFor(() => expect(slot.composer.placeholderPreview).toBeNull()); + }); + + it("fills the composer with a prompt tip, puts the draft in its task slot, and focuses it", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + await slot.setComposerText(" fix the flaky login test "); + fireEvent.click(tile(slot, "child-threads")); + expect(slot.composer.text).toBe( + "Spin up three child threads. Task: fix the flaky login test", + ); + expect(slot.composer.focusCount).toBe(1); + expect(slot.inspection.navigateCalls).toEqual([]); + expect(await slot.findByText("Added to the composer")).toBeTruthy(); + expect(tile(slot, "child-threads").textContent).toContain( + "Added to the composer", + ); + expect(slot.queryByText("In composer")).toBeNull(); + await waitFor(() => + expect(slot.inspection.rpcCalls.at(-1)).toEqual({ + method: "act", + input: { id: "child-threads" }, + }), + ); + }); + + it("fills an empty composer with the prompt alone", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "child-threads")); + expect(slot.composer.text).toBe("Spin up three child threads. Task: "); + }); + + it("opens a bb page for a route tip", async () => { + const slot = renderTips(undefined, { openAppRoute: () => true }); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "phone")); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_openAppRoute", path: "/settings/mobile" }, + ]); + await waitFor(() => expect(methods(slot)).toContain("act")); + }); + + it("announces when bb refuses the route and still marks the tip used", async () => { + const slot = renderTips(undefined, { openAppRoute: () => false }); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "phone")); + expect( + await slot.findByText("Couldn't open Get the app here."), + ).toBeTruthy(); + await waitFor(() => expect(methods(slot)).toContain("act")); + }); + + it("opens a plugin's detail tab and a learn-more link", async () => { + const pluginTip: TipView = { + id: "account-pool", + illustration: "account-pool", + tone: "orange", + title: "Keep working through usage limits", + body: "When an account hits its limit, Account Pooler moves the thread to another one you own.", + action: { + kind: "open-plugin", + label: "Set up Account Pooler", + pluginId: "account-pool", + }, + }; + const linkTip: TipView = { + id: "whats-new", + illustration: "whats-new", + tone: "amber", + title: "What's new", + body: "See what changed in this update.", + action: { + kind: "learn-more", + label: "the release notes", + url: "https://getbb.app/changelog", + }, + }; + const slot = renderTips([pluginTip, linkTip], { + openAppRoute: () => true, + openUrl: () => true, + }); + await slot.findByText("Keep working through usage limits"); + fireEvent.click(tile(slot, "account-pool")); + fireEvent.click(tile(slot, "whats-new")); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_openAppRoute", path: "/plugins/account-pool" }, + { method: "openUrl", url: "https://getbb.app/changelog" }, + ]); + expect(await slot.findByText("Opened the release notes")).toBeTruthy(); + }); + + it("asks for a new visit only on mount, not on refetches", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + await slot.behavior.emitRealtime("tips-changed", {}); + await waitFor(() => + expect( + slot.inspection.rpcCalls.filter((call) => call.method === "current"), + ).toEqual([ + { method: "current", input: expect.objectContaining({ visit: true }) }, + { method: "current", input: expect.objectContaining({ visit: false }) }, + ]), + ); + }); + + it("runs the app command for a command tip", async () => { + const slot = renderTips(undefined, { runAppCommand: () => true }); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(tile(slot, "command-palette")); + expect(slot.inspection.navigateCalls).toEqual([ + { method: "experimental_runAppCommand", commandId: "palette.open" }, + ]); + await waitFor(() => expect(methods(slot)).toContain("act")); + }); + + it("turns tips off from Hide tips and undoes it", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + fireEvent.click(slot.getByRole("button", { name: /Hide tips/u })); + expect(await slot.findByText("Tips hidden")).toBeTruthy(); + expect(slot.queryByRole("region", { name: "Tips" })).toBeNull(); + expect(slot.inspection.rpcCalls).toContainEqual({ + method: "setEnabled", + input: { enabled: false }, + }); + fireEvent.click(slot.getByRole("button", { name: "Undo" })); + expect(slot.inspection.rpcCalls).toContainEqual({ + method: "setEnabled", + input: { enabled: true }, + }); + expect( + await slot.findByText("Split a big task across helpers"), + ).toBeTruthy(); + }); + + it("refetches when tips change elsewhere", async () => { + const slot = renderTips(); + await slot.findByText("Split a big task across helpers"); + await slot.behavior.emitRealtime("tips-changed", {}); + await waitFor(() => expect(methods(slot)).toEqual(["current", "current"])); + }); +}); diff --git a/plugins/tips/app.tsx b/plugins/tips/app.tsx new file mode 100644 index 00000000000..717ce8e23f5 --- /dev/null +++ b/plugins/tips/app.tsx @@ -0,0 +1,234 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + definePluginApp, + useBbNavigate, + useComposer, + useRealtime, + useRpc, + useSdk, + useSettings, + type PluginHomepageSectionProps, +} from "@get-bb/plugin-sdk/app"; +import { + detectTipClient, + hasNotificationNudge, + readNotificationEnvironment, + readTipClientEnvironment, +} from "./client.js"; +import { runTipAction } from "./actions.js"; +import type { TipView, tipsRpcContract } from "./contract.js"; +import { TipsGallery, TipsHiddenNotice, type TipResult } from "./gallery.js"; +import { recordTipEvent } from "./telemetry.js"; + +const TIPS_CHANGED_CHANNEL = "tips-changed"; +const COMPACT_LAYOUT_QUERY = "(max-width: 767px)"; +const MIN_GALLERY_WIDTH = 520; + +function subscribeCompactLayout(listener: () => void): () => void { + if (typeof window.matchMedia !== "function") return () => {}; + const query = window.matchMedia(COMPACT_LAYOUT_QUERY); + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); +} + +function isCompactLayout(): boolean { + if (typeof window.matchMedia !== "function") return false; + return window.matchMedia(COMPACT_LAYOUT_QUERY).matches; +} + +function useCompactLayout(): boolean { + return useSyncExternalStore( + subscribeCompactLayout, + isCompactLayout, + isCompactLayout, + ); +} + +function useElementWidth(): [(element: HTMLDivElement | null) => void, number] { + const [element, setElement] = useState(null); + const [width, setWidth] = useState(0); + useLayoutEffect(() => { + if (element === null) return; + setWidth(element.getBoundingClientRect().width); + if (typeof ResizeObserver !== "function") return; + const observer = new ResizeObserver(() => { + setWidth(element.getBoundingClientRect().width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + return [setElement, width]; +} + +function promptOf(tip: TipView | undefined): string | null { + return tip?.action.kind === "prompt" ? tip.action.prompt : null; +} + +function TipsGallerySection({ + projectId, + client, + reportedShown, +}: { + projectId: string | null; + client: ReturnType; + reportedShown: Set; +}) { + const rpc = useRpc(); + const sdk = useSdk(); + const navigate = useBbNavigate(); + const composer = useComposer(); + const settings = useSettings(); + const enabled = settings.values?.enabled !== false; + const [tips, setTips] = useState([]); + const [revision, setRevision] = useState(0); + const [previewId, setPreviewId] = useState(null); + const [result, setResult] = useState< + (TipResult & { filled: boolean }) | null + >(null); + const [dismissed, setDismissed] = useState(false); + const visited = useRef(false); + const isEmpty = composer.isEmpty; + + useRealtime(TIPS_CHANGED_CHANNEL, () => { + setRevision((current) => current + 1); + }); + + useEffect(() => { + if (!enabled) { + setTips([]); + return; + } + let active = true; + const visit = !visited.current; + visited.current = true; + rpc.call("current", { client, projectId, visit }).then( + (result) => { + if (active) setTips(result.tips); + }, + () => { + if (active) setTips([]); + }, + ); + return () => { + active = false; + }; + }, [client, enabled, projectId, revision, rpc]); + + const showing = enabled && !dismissed && tips.length > 0; + useEffect(() => { + if (!showing) return; + tips.forEach((tip, index) => { + if (reportedShown.has(tip.id)) return; + reportedShown.add(tip.id); + recordTipEvent(sdk, "tip_shown", tip, index); + }); + }, [reportedShown, sdk, showing, tips]); + + const preview = promptOf(tips.find((tip) => tip.id === previewId)); + useEffect(() => { + composer.experimental_setPlaceholderPreview( + isEmpty && preview !== null ? preview.trimEnd() : null, + ); + }, [composer, isEmpty, preview]); + + const shownResult = + result !== null && result.filled && isEmpty ? null : result; + + const activate = useCallback( + (tip: TipView) => { + const outcome = runTipAction(tip, { + replaceDraft: (update) => composer.replace(update), + focusComposer: () => composer.focus(), + openAppRoute: (path) => navigate.experimental_openAppRoute(path), + runAppCommand: (commandId) => + navigate.experimental_runAppCommand(commandId), + openUrl: (url) => navigate.openUrl(url), + }); + const filled = tip.action.kind === "prompt"; + if (filled) setPreviewId(null); + setResult({ + id: tip.id, + ok: outcome.ok, + text: outcome.announcement, + filled, + }); + recordTipEvent( + sdk, + "tip_used", + tip, + tips.findIndex((shown) => shown.id === tip.id), + ); + void rpc.call("act", { id: tip.id }).catch(() => {}); + }, + [composer, navigate, rpc, sdk, tips], + ); + + const turnOff = useCallback(() => { + setDismissed(true); + setPreviewId(null); + void rpc.call("setEnabled", { enabled: false }).catch(() => {}); + }, [rpc]); + + const undo = useCallback(() => { + setDismissed(false); + void rpc.call("setEnabled", { enabled: true }).catch(() => {}); + }, [rpc]); + + if (dismissed) return ; + if (!showing) return null; + return ( + + ); +} + +function TipsHomepageSection({ + projectId, + experimental_setupComplete, +}: PluginHomepageSectionProps) { + const client = useMemo( + () => ({ + ...detectTipClient(readTipClientEnvironment()), + notificationNudge: hasNotificationNudge(readNotificationEnvironment()), + }), + [], + ); + const onPhone = + client.surface === "mobile-app" || client.surface === "mobile-web"; + const compact = useCompactLayout(); + const [measure, width] = useElementWidth(); + const [reportedShown] = useState(() => new Set()); + const fits = width >= MIN_GALLERY_WIDTH; + if (onPhone || experimental_setupComplete !== true) return null; + return ( +
+ {!compact && fits ? ( + + ) : null} +
+ ); +} + +export default definePluginApp((app) => { + app.slots.homepageSection({ + id: "tips", + component: TipsHomepageSection, + }); +}); diff --git a/plugins/tips/catalog.test.ts b/plugins/tips/catalog.test.ts new file mode 100644 index 00000000000..b23599dde96 --- /dev/null +++ b/plugins/tips/catalog.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { + CATALOG_REVIEWED_THROUGH, + TIP_BODY_MAX, + TIP_CATALOG, + TIP_TITLE_MAX, + renderTip, + type TipSignals, +} from "./catalog.js"; +import { TIP_IDS, tipActionSchema, tipViewSchema } from "./contract.js"; +import { compareVersions } from "./engine.js"; +import { ILLUSTRATION_IDS } from "./illustrations.js"; + +const VERSION = /^\d+\.\d+\.\d+$/u; + +function signals(overrides: Partial = {}): TipSignals { + return { + client: { surface: "desktop", os: "macos" }, + projectId: null, + serverPlatform: "darwin", + appVersion: "1.1.0", + firstSeenVersion: "1.0.0", + daysSinceFirstSeen: 7, + threadCount: 60, + availableProviderCount: 3, + installedPlugins: {}, + hasFinishedThread: true, + finishedThreadCount: 10, + hasChildThread: false, + hasAutomationThread: false, + projectHasChildThread: false, + projectHasAutomationThread: false, + waitingThreadCount: 3, + rateLimited: true, + recentlyRateLimited: true, + queuedFollowUp: false, + usedMobileApp: false, + ...overrides, + }; +} + +const FIXTURES: readonly TipSignals[] = [ + signals(), + signals({ availableProviderCount: 1 }), + signals({ + client: { surface: "desktop", os: "macos", notificationNudge: true }, + installedPlugins: { "push-notifications": true }, + }), + signals({ + installedPlugins: { + "account-pool": false, + automations: true, + "bb--provider-usage": true, + }, + }), +]; + +describe("tip catalog", () => { + it("gives every tip a unique id from TIP_IDS, and every id a tip", () => { + const ids = TIP_CATALOG.map((definition) => definition.id); + expect(new Set(ids).size).toBe(ids.length); + expect([...ids].sort()).toEqual([...TIP_IDS].sort()); + }); + + it("keeps every title and body short, plain, and one sentence", () => { + for (const definition of TIP_CATALOG) { + const view = renderTip(definition, signals()); + expect(tipViewSchema.safeParse(view).success, definition.id).toBe(true); + expect(view.title.length, definition.id).toBeLessThanOrEqual( + TIP_TITLE_MAX, + ); + expect(view.body.length, definition.id).toBeLessThanOrEqual(TIP_BODY_MAX); + expect(view.body.match(/[.!?](\s|$)/gu)?.length, definition.id).toBe(1); + expect(`${view.title} ${view.body}`, definition.id).not.toMatch( + /\{\w+\}/u, + ); + } + }); + + it("describes outcomes instead of naming child threads in titles and bodies", () => { + for (const definition of TIP_CATALOG) { + expect( + `${definition.title} ${definition.body}`, + definition.id, + ).not.toMatch(/child thread/iu); + } + }); + + it("draws every tip with an illustration that exists", () => { + for (const definition of TIP_CATALOG) { + expect(ILLUSTRATION_IDS, definition.id).toContain( + definition.illustration, + ); + } + }); + + it("gives every tip a valid action of a known type", () => { + for (const definition of TIP_CATALOG) { + expect( + tipActionSchema.safeParse(definition.action).success, + definition.id, + ).toBe(true); + } + }); + + it("records a source and versions, and holds no expired tips", () => { + for (const definition of TIP_CATALOG) { + expect(definition.source.ref.length, definition.id).toBeGreaterThan(0); + expect(definition.addedAt, definition.id).toMatch(VERSION); + expect(definition.reviewedAt, definition.id).toMatch(VERSION); + expect( + compareVersions(definition.reviewedAt, CATALOG_REVIEWED_THROUGH), + definition.id, + ).toBeLessThanOrEqual(0); + if (definition.expiresAt !== undefined) { + expect(definition.expiresAt, definition.id).toMatch(VERSION); + expect( + compareVersions(definition.expiresAt, CATALOG_REVIEWED_THROUGH), + `${definition.id} expired`, + ).toBeGreaterThan(0); + } + } + }); + + it("walks people through multi-step setup with a conditional playground", () => { + for (const id of ["phone", "remote-access", "build-plugin", "add-agent"]) { + const definition = TIP_CATALOG.find((candidate) => candidate.id === id); + const action = definition?.action; + expect(action?.kind, id).toBe("prompt"); + if (action?.kind !== "prompt") continue; + expect(action.prompt, id).toMatch( + /^Walk me through .+ in this bb, one step at a time/u, + ); + expect(action.prompt, id).toContain( + "If the playground tool is available, show the steps as a playground; otherwise reply with plain numbered steps.", + ); + } + for (const id of ["child-threads", "another-agent"]) { + const action = TIP_CATALOG.find( + (candidate) => candidate.id === id, + )?.action; + expect( + action?.kind === "prompt" && action.prompt.endsWith("Task: "), + id, + ).toBe(true); + } + }); + + it("keeps the agreed tiers", () => { + const tierOf = (id: string) => + TIP_CATALOG.find((definition) => definition.id === id)?.tier; + for (const id of [ + "child-threads", + "phone", + "another-agent", + "build-plugin", + ]) { + expect(tierOf(id), id).toBe(1); + } + for (const id of ["remote-access", "bb-cli", "add-agent"]) { + expect(tierOf(id), id).toBe(2); + } + expect(tierOf("queue-or-steer")).toBe(3); + expect(tierOf("browse-plugins")).toBe(3); + expect(tierOf("notifications")).toBe("unranked"); + const unranked = TIP_CATALOG.filter( + (definition) => definition.tier === "unranked", + ).sort((left, right) => right.priority - left.priority); + expect(unranked[0]?.id).toBe("automations"); + }); + + it("has no dead tips: every tip that is not held can show for someone", () => { + for (const definition of TIP_CATALOG) { + if (definition.held) continue; + expect( + FIXTURES.some((fixture) => definition.eligible(fixture)), + definition.id, + ).toBe(true); + } + }); +}); diff --git a/plugins/tips/catalog.ts b/plugins/tips/catalog.ts new file mode 100644 index 00000000000..a1a291d0948 --- /dev/null +++ b/plugins/tips/catalog.ts @@ -0,0 +1,608 @@ +import type { + TipAction, + TipClient, + TipId, + TipTone, + TipView, +} from "./contract.js"; + +export const ACCOUNT_POOL_PLUGIN_ID = "account-pool"; +export const AUTOMATIONS_PLUGIN_ID = "automations"; +export const BROWSER_AUTOMATION_PLUGIN_ID = "browser-automation"; +export const PROVIDER_USAGE_PLUGIN_ID = "bb--provider-usage"; +export const PUSH_NOTIFICATIONS_PLUGIN_ID = "push-notifications"; + +export interface TipSignals { + client: TipClient | null; + projectId: string | null; + serverPlatform: string; + appVersion: string | null; + firstSeenVersion: string | null; + daysSinceFirstSeen: number; + threadCount: number; + availableProviderCount: number; + installedPlugins: Readonly>; + hasFinishedThread: boolean; + finishedThreadCount: number; + hasChildThread: boolean; + hasAutomationThread: boolean; + projectHasChildThread: boolean; + projectHasAutomationThread: boolean; + waitingThreadCount: number; + rateLimited: boolean; + recentlyRateLimited: boolean; + queuedFollowUp: boolean; + usedMobileApp: boolean; +} + +export const CATALOG_REVIEWED_THROUGH = "0.46.0"; + +export interface TipSource { + kind: "changelog" | "blog" | "guide" | "feature"; + ref: string; + version?: string; +} + +export type TipTier = 1 | 2 | 3 | "unranked"; + +export interface TipDefinition { + id: string; + title: string; + body: string; + illustration: string; + tone: TipTone; + action: TipAction; + source: TipSource; + addedAt: string; + reviewedAt: string; + expiresAt?: string; + held: boolean; + tier: TipTier; + priority: number; + perVersion: boolean; + eligible(signals: TipSignals): boolean; + retireWhen(signals: TipSignals): boolean; + boost(signals: TipSignals): number; +} + +export const TIP_TITLE_MAX = 45; +export const TIP_BODY_MAX = 110; +const WAITING_THREADS_FOR_TIP = 2; +const WAITING_BOOST = 2000; +const RATE_LIMIT_BOOST = 1500; +const NEW_VERSION_BOOST = 1000; +const POWER_USER_THREAD_COUNT = 50; +const NEW_USER_DAYS = 14; + +function isInstalled(signals: TipSignals, pluginId: string): boolean { + return pluginId in signals.installedPlugins; +} + +function isEnabled(signals: TipSignals, pluginId: string): boolean { + return signals.installedPlugins[pluginId] === true; +} + +function onClient( + signals: TipSignals, + test: (client: TipClient) => boolean, +): boolean { + return signals.client === null || test(signals.client); +} + +function hasKeyboard(client: TipClient): boolean { + return client.surface === "desktop" || client.surface === "web"; +} + +function isWindowsClient(client: TipClient): boolean { + return client.os === "windows"; +} + +function never(): boolean { + return false; +} + +function noBoost(): number { + return 0; +} + +function usesChildThreadsHere(signals: TipSignals): boolean { + return signals.projectId === null + ? signals.hasChildThread + : signals.projectHasChildThread; +} + +function usesAutomationsHere(signals: TipSignals): boolean { + return signals.projectId === null + ? signals.hasAutomationThread + : signals.projectHasAutomationThread; +} + +export function walkthroughPrompt(goal: string): string { + return `Walk me through ${goal} in this bb, one step at a time, and check each step with me. If the playground tool is available, show the steps as a playground; otherwise reply with plain numbered steps.`; +} + +type TipDefaults = "held" | "perVersion" | "retireWhen" | "boost"; + +function tip( + definition: Omit & + Partial> & { id: TipId }, +): TipDefinition { + return { + held: false, + perVersion: false, + retireWhen: never, + boost: noBoost, + ...definition, + }; +} + +export const TIP_CATALOG: readonly TipDefinition[] = [ + tip({ + id: "whats-new", + illustration: "whats-new", + tone: "amber", + source: { kind: "changelog", ref: "/settings/updates#whats-new" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "What's new in v{version}", + body: "See what changed in this update.", + action: { + kind: "open-page", + label: "See what's new", + path: "/settings/updates#whats-new", + }, + tier: "unranked", + priority: 130, + perVersion: true, + boost: () => NEW_VERSION_BOOST, + eligible: (signals) => + signals.appVersion !== null && + signals.firstSeenVersion !== null && + signals.appVersion !== signals.firstSeenVersion, + }), + tip({ + id: "account-pool", + illustration: "account-pool", + tone: "orange", + source: { kind: "feature", ref: "account-pool plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Keep working through usage limits", + body: "When an account hits its limit, Account Pooler moves the thread to another one you own.", + action: { + kind: "open-plugin", + label: "Set up Account Pooler", + pluginId: ACCOUNT_POOL_PLUGIN_ID, + }, + tier: "unranked", + priority: 120, + eligible: (signals) => + isInstalled(signals, ACCOUNT_POOL_PLUGIN_ID) && + (signals.rateLimited || signals.threadCount >= POWER_USER_THREAD_COUNT), + retireWhen: (signals) => isEnabled(signals, ACCOUNT_POOL_PLUGIN_ID), + boost: (signals) => (signals.recentlyRateLimited ? RATE_LIMIT_BOOST : 0), + }), + tip({ + id: "child-threads", + illustration: "child-threads", + tone: "blue", + source: { kind: "feature", ref: "child-threads" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Split a big task across helpers", + body: "Ask bb to try three approaches in parallel, or have a second agent review the work.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Spin up three child threads that each try a different approach to this task, then compare their results and recommend one. Task: ", + }, + tier: 1, + priority: 110, + eligible: (signals) => + signals.hasFinishedThread && !usesChildThreadsHere(signals), + }), + tip({ + id: "set-up-for-me", + illustration: "set-up-for-me", + tone: "green", + source: { kind: "feature", ref: "bb self-configuration" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Ask bb to set things up", + body: "bb can change its own settings, add machines, and configure providers when you ask.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Review my bb setup (settings, machines, and providers), suggest improvements, and make the changes I approve.", + }, + tier: "unranked", + priority: 100, + eligible: (signals) => signals.hasFinishedThread, + }), + tip({ + id: "phone", + illustration: "phone", + tone: "rose", + source: { kind: "feature", ref: "mobile app" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Check on your agents from your phone", + body: "The bb mobile app lets you follow threads and answer questions away from your desk.", + action: { + kind: "prompt", + label: "Walk me through it", + prompt: walkthroughPrompt("connecting the bb mobile app on my phone"), + }, + tier: 1, + priority: 90, + eligible: (signals) => + signals.hasFinishedThread && + onClient( + signals, + (client) => client.surface === "desktop" || client.surface === "web", + ), + retireWhen: (signals) => signals.usedMobileApp, + }), + tip({ + id: "browser-automation", + illustration: "browser-automation", + tone: "green", + source: { kind: "feature", ref: "browser-automation plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Let the agent test your app", + body: "Your agent can turn on Browser Automation, click through your app, and report what breaks.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Turn on the Browser Automation plugin if it is off, then open my app in a browser, click through the main flow, and tell me what is broken.", + }, + tier: "unranked", + priority: 80, + eligible: (signals) => + signals.hasFinishedThread && + signals.serverPlatform !== "win32" && + onClient(signals, (client) => !isWindowsClient(client)), + retireWhen: (signals) => isEnabled(signals, BROWSER_AUTOMATION_PLUGIN_ID), + }), + tip({ + id: "build-plugin", + illustration: "build-plugin", + tone: "amber", + source: { kind: "guide", ref: "bb guide plugins" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Ask the agent to build you a tool", + body: "Your agent can write bb plugins for you, from a dashboard page to a new panel or command.", + action: { + kind: "prompt", + label: "Walk me through it", + prompt: walkthroughPrompt("building and installing my first bb plugin"), + }, + tier: 1, + priority: 75, + eligible: (signals) => + signals.hasFinishedThread && signals.daysSinceFirstSeen <= NEW_USER_DAYS, + }), + tip({ + id: "open-threads-that-need-me", + illustration: "open-threads-that-need-me", + tone: "orange", + source: { kind: "feature", ref: "split panes" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "See every thread that needs you", + body: "Ask bb to open each thread that is waiting on you side by side in split panes.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Find my threads that are waiting on me (open questions, errors, or unread results) and open them side by side in split panes with bb thread open --split.", + }, + tier: "unranked", + priority: 70, + eligible: (signals) => + signals.waitingThreadCount >= WAITING_THREADS_FOR_TIP && + onClient(signals, hasKeyboard), + boost: (signals) => WAITING_BOOST + signals.waitingThreadCount, + }), + tip({ + id: "morning-digest", + illustration: "morning-digest", + tone: "rose", + source: { kind: "feature", ref: "automations with Browser Automation" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Get a morning email digest", + body: "An automation can read your inbox in bb's browser each morning and send you a short digest.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Set up an automation that runs every weekday at 8am, uses Browser Automation to read my unread email in bb's browser (where I am signed in), and writes me a short digest of what needs my attention. Turn on Browser Automation first if it is off.", + }, + tier: "unranked", + priority: 65, + eligible: (signals) => + signals.finishedThreadCount >= 3 && + isEnabled(signals, AUTOMATIONS_PLUGIN_ID) && + signals.serverPlatform !== "win32" && + onClient(signals, (client) => !isWindowsClient(client)), + }), + tip({ + id: "decision-buttons", + illustration: "decision-buttons", + tone: "green", + source: { kind: "guide", ref: "bb guide plugins" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Turn decisions into buttons", + body: "Ask the agent for a plugin that shows the choices it needs from you as one-click buttons.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Build me a bb plugin that gives you a tool to ask me a decision with one-click answer buttons in the thread, then use it whenever you need a choice from me.", + }, + tier: 1, + priority: 60, + eligible: (signals) => signals.finishedThreadCount >= 3, + }), + tip({ + id: "automations", + illustration: "automations", + tone: "blue", + source: { kind: "feature", ref: "automations plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Run a prompt on a schedule", + body: "Ask the agent to turn any prompt into an automation that runs every morning or every hour.", + action: { + kind: "prompt", + label: "Try it", + prompt: "Create an automation that runs every weekday at 9am and ", + }, + tier: "unranked", + priority: 150, + eligible: (signals) => + signals.finishedThreadCount >= 5 && + isEnabled(signals, AUTOMATIONS_PLUGIN_ID) && + !usesAutomationsHere(signals), + }), + tip({ + id: "queue-or-steer", + illustration: "queue-or-steer", + tone: "amber", + source: { kind: "feature", ref: "queue and steer" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Add to a running turn", + body: "While the agent works, steer to change course now or queue a follow-up for when it finishes.", + action: { + kind: "open-page", + label: "Choose what Enter does", + path: "/settings", + }, + tier: 3, + priority: 50, + eligible: (signals) => signals.finishedThreadCount >= 3, + retireWhen: (signals) => signals.queuedFollowUp, + }), + tip({ + id: "thread-search", + illustration: "thread-search", + tone: "blue", + source: { kind: "feature", ref: "thread search" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Jump to any thread", + body: "Press {searchKeys} to search your threads.", + action: { + kind: "run-command", + label: "Search threads", + commandId: "thread.search", + }, + tier: "unranked", + priority: 42, + eligible: (signals) => + signals.threadCount >= 10 && onClient(signals, hasKeyboard), + }), + tip({ + id: "command-palette", + illustration: "command-palette", + tone: "rose", + source: { kind: "feature", ref: "command palette" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Do anything from the keyboard", + body: "Press {paletteKeys} to search bb's commands and settings.", + action: { + kind: "run-command", + label: "Open palette", + commandId: "palette.open", + }, + tier: "unranked", + priority: 40, + eligible: (signals) => + signals.daysSinceFirstSeen >= 3 && onClient(signals, hasKeyboard), + }), + tip({ + id: "another-agent", + illustration: "another-agent", + tone: "amber", + source: { kind: "feature", ref: "multiple providers" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Compare two agents on one task", + body: "Ask bb to have another agent do the same task, then compare both results and pick the better one.", + action: { + kind: "prompt", + label: "Try it", + prompt: + "Start a child thread that does this task with a different agent provider than the one you are using, then compare both results and tell me which is better. Task: ", + }, + tier: 1, + priority: 105, + eligible: (signals) => + signals.hasFinishedThread && signals.availableProviderCount >= 2, + }), + tip({ + id: "remote-access", + illustration: "remote-access", + tone: "orange", + source: { kind: "feature", ref: "connect plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Open bb from another computer", + body: "bb connect gives your bb a private web address, so you can reach it from any browser.", + action: { + kind: "prompt", + label: "Walk me through it", + prompt: walkthroughPrompt("turning on bb connect"), + }, + tier: 2, + priority: 95, + eligible: (signals) => + signals.hasFinishedThread && + onClient( + signals, + (client) => client.surface === "desktop" || client.surface === "web", + ), + }), + tip({ + id: "bb-cli", + illustration: "bb-cli", + tone: "blue", + source: { kind: "guide", ref: "bb guide cli" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Drive bb from scripts", + body: "The bb command lets scripts and agents start threads, check on them, and change settings.", + action: { + kind: "prompt", + label: "Try it", + prompt: "Write me a shell script that uses the bb CLI to ", + }, + tier: 2, + priority: 85, + eligible: (signals) => + signals.hasFinishedThread && onClient(signals, hasKeyboard), + }), + tip({ + id: "add-agent", + illustration: "add-agent", + tone: "green", + source: { kind: "feature", ref: "multiple providers" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Add a second coding agent", + body: "bb can run more than one agent, so you can compare them or keep working when one hits a limit.", + action: { + kind: "prompt", + label: "Walk me through it", + prompt: walkthroughPrompt("setting up a second coding agent"), + }, + tier: 2, + priority: 80, + eligible: (signals) => + signals.hasFinishedThread && signals.availableProviderCount < 2, + }), + tip({ + id: "notifications", + illustration: "notifications", + tone: "amber", + source: { kind: "feature", ref: "push-notifications plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Know when an agent needs you", + body: "Turn on notifications to see when a thread finishes or asks you a question.", + action: { + kind: "open-plugin", + label: "Turn on notifications", + pluginId: PUSH_NOTIFICATIONS_PLUGIN_ID, + }, + tier: "unranked", + priority: 140, + eligible: (signals) => + signals.client?.notificationNudge === true && + isEnabled(signals, PUSH_NOTIFICATIONS_PLUGIN_ID), + }), + tip({ + id: "browse-plugins", + illustration: "browse-plugins", + tone: "green", + source: { kind: "feature", ref: "plugin store" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "Find plugins that fit your work", + body: "Browse bb's plugins, like Browser Automation, which lets the agent test your app in a real browser.", + action: { + kind: "open-page", + label: "Browse plugins", + path: "/plugins", + }, + tier: 3, + priority: 30, + eligible: (signals) => + signals.hasFinishedThread && + onClient( + signals, + (client) => client.surface === "desktop" || client.surface === "web", + ), + }), + tip({ + id: "provider-usage", + illustration: "provider-usage", + tone: "orange", + source: { kind: "feature", ref: "provider-usage plugin" }, + addedAt: "0.46.0", + reviewedAt: "0.46.0", + title: "See your usage across providers", + body: "Provider usage shows how much of each account's limits you have used and when they reset.", + action: { + kind: "open-plugin", + label: "Open Provider usage", + pluginId: PROVIDER_USAGE_PLUGIN_ID, + }, + tier: 2, + priority: 35, + eligible: (signals) => + signals.threadCount >= 10 && isEnabled(signals, PROVIDER_USAGE_PLUGIN_ID), + }), +]; + +function keyLabel( + client: TipClient | null, + mac: string, + other: string, +): string { + if (client === null) return `${mac} (${other})`; + return client.os === "macos" ? mac : other; +} + +function fillTemplate(text: string, signals: TipSignals): string { + return text + .replaceAll("{version}", signals.appVersion ?? "") + .replaceAll( + "{paletteKeys}", + keyLabel(signals.client, "⌘⇧P", "Ctrl+Shift+P"), + ) + .replaceAll("{searchKeys}", keyLabel(signals.client, "⌘K", "Ctrl+K")); +} + +export function renderTip( + definition: TipDefinition, + signals: TipSignals, +): TipView { + return { + id: definition.id, + illustration: definition.illustration, + tone: definition.tone, + title: fillTemplate(definition.title, signals), + body: fillTemplate(definition.body, signals), + action: definition.action, + }; +} diff --git a/plugins/tips/client.test.ts b/plugins/tips/client.test.ts new file mode 100644 index 00000000000..3b9362ae371 --- /dev/null +++ b/plugins/tips/client.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + detectTipClient, + hasNotificationNudge, + type TipClientEnvironment, +} from "./client.js"; + +const MAC_SAFARI = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"; +const WINDOWS_CHROME = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"; +const ANDROID_CHROME = + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Mobile Safari/537.36"; +const LINUX_FIREFOX = + "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"; + +function environment( + overrides: Partial, +): TipClientEnvironment { + return { + bbDesktop: undefined, + bb: undefined, + userAgent: MAC_SAFARI, + maxTouchPoints: 0, + ...overrides, + }; +} + +describe("detectTipClient", () => { + it("reads the desktop app's platform", () => { + expect( + detectTipClient( + environment({ + bbDesktop: { platform: "windows" }, + userAgent: WINDOWS_CHROME, + }), + ), + ).toEqual({ surface: "desktop", os: "windows" }); + }); + + it("recognizes the native mobile app", () => { + expect( + detectTipClient( + environment({ + bb: { native: { platform: "android" } }, + userAgent: ANDROID_CHROME, + }), + ), + ).toEqual({ surface: "mobile-app", os: "android" }); + }); + + it.each([ + [MAC_SAFARI, 0, { surface: "web", os: "macos" }], + [MAC_SAFARI, 5, { surface: "mobile-web", os: "ios" }], + [WINDOWS_CHROME, 0, { surface: "web", os: "windows" }], + [LINUX_FIREFOX, 0, { surface: "web", os: "linux" }], + [ANDROID_CHROME, 5, { surface: "mobile-web", os: "android" }], + ["Unknown agent", 0, { surface: "web", os: "unknown" }], + ])("classifies the browser %s", (userAgent, maxTouchPoints, expected) => { + expect(detectTipClient(environment({ userAgent, maxTouchPoints }))).toEqual( + expected, + ); + }); + + it("ignores a window.bb object without a native bridge", () => { + expect(detectTipClient(environment({ bb: { other: true } }))).toEqual({ + surface: "web", + os: "macos", + }); + }); +}); + +describe("hasNotificationNudge", () => { + it.each([ + ["default", "answered", true], + ["default", "shown", false], + ["default", null, false], + ["granted", "answered", false], + ["denied", "answered", false], + [null, "answered", false], + ])( + "permission %s with the sidebar prompt %s gives %s", + (permission, promptState, expected) => { + expect(hasNotificationNudge({ permission, promptState })).toBe(expected); + }, + ); +}); diff --git a/plugins/tips/client.ts b/plugins/tips/client.ts new file mode 100644 index 00000000000..e9a9f829d87 --- /dev/null +++ b/plugins/tips/client.ts @@ -0,0 +1,106 @@ +import type { TipClient } from "./contract.js"; + +export interface TipClientEnvironment { + bbDesktop: unknown; + bb: unknown; + userAgent: string; + maxTouchPoints: number; +} + +function isRecord(value: unknown): value is object { + return typeof value === "object" && value !== null; +} + +function osFromUserAgent( + userAgent: string, + maxTouchPoints: number, +): TipClient["os"] { + if (/android/iu.test(userAgent)) return "android"; + if (/iphone|ipad|ipod/iu.test(userAgent)) return "ios"; + if (/macintosh|mac os x/iu.test(userAgent)) { + return maxTouchPoints > 1 ? "ios" : "macos"; + } + if (/windows/iu.test(userAgent)) return "windows"; + if (/linux|x11|cros/iu.test(userAgent)) return "linux"; + return "unknown"; +} + +function desktopOs(platform: unknown): TipClient["os"] { + if (platform === "macos" || platform === "windows" || platform === "linux") { + return platform; + } + return "unknown"; +} + +function nativeOs( + platform: unknown, + fallback: TipClient["os"], +): TipClient["os"] { + return platform === "ios" || platform === "android" ? platform : fallback; +} + +export function detectTipClient(environment: TipClientEnvironment): TipClient { + const userAgentOs = osFromUserAgent( + environment.userAgent, + environment.maxTouchPoints, + ); + if (isRecord(environment.bbDesktop)) { + return { + surface: "desktop", + os: desktopOs(Reflect.get(environment.bbDesktop, "platform")), + }; + } + const native = isRecord(environment.bb) + ? Reflect.get(environment.bb, "native") + : undefined; + if (isRecord(native)) { + return { + surface: "mobile-app", + os: nativeOs(Reflect.get(native, "platform"), userAgentOs), + }; + } + return { + surface: + userAgentOs === "ios" || userAgentOs === "android" ? "mobile-web" : "web", + os: userAgentOs, + }; +} + +export const SIDEBAR_NOTIFICATION_PROMPT_KEY = "bb.sidebar.notificationPrompt"; + +export interface NotificationEnvironment { + permission: string | null; + promptState: string | null; +} + +export function hasNotificationNudge( + environment: NotificationEnvironment, +): boolean { + return ( + environment.permission === "default" && + environment.promptState === "answered" + ); +} + +export function readNotificationEnvironment(): NotificationEnvironment { + let promptState: string | null; + try { + promptState = window.localStorage.getItem(SIDEBAR_NOTIFICATION_PROMPT_KEY); + } catch { + promptState = null; + } + return { + permission: + typeof Notification === "undefined" ? null : Notification.permission, + promptState, + }; +} + +export function readTipClientEnvironment(): TipClientEnvironment { + return { + bbDesktop: Reflect.get(window, "bbDesktop"), + bb: Reflect.get(window, "bb"), + userAgent: navigator.userAgent, + maxTouchPoints: navigator.maxTouchPoints, + }; +} diff --git a/plugins/tips/compose.test.ts b/plugins/tips/compose.test.ts new file mode 100644 index 00000000000..708e26d0cac --- /dev/null +++ b/plugins/tips/compose.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerDraftSnapshot } from "@get-bb/plugin-sdk/app"; +import { composeTipDraft } from "./compose.js"; + +function draft( + text: string, + mentions: ComposerDraftSnapshot["mentions"] = [], +): ComposerDraftSnapshot { + return { text, mentions, attachments: [] }; +} + +const SLOT_PROMPT = "Compare three approaches. Task: "; +const PLAIN_PROMPT = "Review my bb setup."; + +describe("composeTipDraft", () => { + it("fills an empty draft with the prompt", () => { + expect(composeTipDraft(SLOT_PROMPT, draft(" "))).toEqual({ + text: SLOT_PROMPT, + mentions: [], + }); + }); + + it("moves the draft into the prompt's task slot and keeps its mentions", () => { + const current = draft(" fix @login now ", [ + { + kind: "project", + projectId: "proj_1", + label: "@login", + from: 6, + to: 12, + }, + ]); + const next = composeTipDraft(SLOT_PROMPT, current); + expect(next.text).toBe(`${SLOT_PROMPT}fix @login now`); + const [mention] = next.mentions; + expect(next.text.slice(mention?.from, mention?.to)).toBe("@login"); + }); + + it("appends a prompt without a task slot after the draft", () => { + expect(composeTipDraft(PLAIN_PROMPT, draft("my notes\n"))).toEqual({ + text: `my notes\n\n${PLAIN_PROMPT}`, + mentions: [], + }); + }); + + it("leaves a draft that already holds the prompt unchanged", () => { + const current = draft(`${SLOT_PROMPT}fix it`); + expect(composeTipDraft(SLOT_PROMPT, current)).toBe(current); + }); +}); diff --git a/plugins/tips/compose.ts b/plugins/tips/compose.ts new file mode 100644 index 00000000000..878a417b050 --- /dev/null +++ b/plugins/tips/compose.ts @@ -0,0 +1,33 @@ +import type { + ComposerDraftReplacement, + ComposerDraftSnapshot, +} from "@get-bb/plugin-sdk/app"; + +export function hasTaskSlot(prompt: string): boolean { + return prompt.endsWith(" "); +} + +export function composeTipDraft( + prompt: string, + current: ComposerDraftSnapshot, +): ComposerDraftReplacement | ComposerDraftSnapshot { + const task = current.text.trim(); + if (task === "") return { text: prompt, mentions: [] }; + if (current.text.includes(prompt.trim())) return current; + if (!hasTaskSlot(prompt)) { + return { + text: `${current.text.trimEnd()}\n\n${prompt}`, + mentions: current.mentions, + }; + } + const shift = + prompt.length - (current.text.length - current.text.trimStart().length); + return { + text: `${prompt}${task}`, + mentions: current.mentions.map((mention) => ({ + ...mention, + from: mention.from + shift, + to: mention.to + shift, + })), + }; +} diff --git a/plugins/tips/contract.ts b/plugins/tips/contract.ts new file mode 100644 index 00000000000..90e5aca3943 --- /dev/null +++ b/plugins/tips/contract.ts @@ -0,0 +1,170 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export const tipClientSchema = z + .object({ + surface: z.enum(["desktop", "web", "mobile-app", "mobile-web"]), + os: z.enum(["macos", "windows", "linux", "ios", "android", "unknown"]), + notificationNudge: z.boolean().optional(), + }) + .strict(); +export type TipClient = z.infer; + +export const TIP_IDS = [ + "whats-new", + "account-pool", + "child-threads", + "set-up-for-me", + "phone", + "browser-automation", + "build-plugin", + "open-threads-that-need-me", + "morning-digest", + "decision-buttons", + "automations", + "queue-or-steer", + "thread-search", + "command-palette", + "provider-usage", + "another-agent", + "remote-access", + "bb-cli", + "add-agent", + "browse-plugins", + "notifications", +] as const; +export const tipIdSchema = z.enum(TIP_IDS); +export type TipId = z.infer; + +export const TIP_COMMAND_IDS = [ + "palette.open", + "thread.search", + "settings.open", +] as const; + +export const tipActionSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("prompt"), + label: z.string().min(1), + prompt: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("open-page"), + label: z.string().min(1), + path: z.string().startsWith("/"), + }) + .strict(), + z + .object({ + kind: z.literal("run-command"), + label: z.string().min(1), + commandId: z.enum(TIP_COMMAND_IDS), + }) + .strict(), + z + .object({ + kind: z.literal("open-plugin"), + label: z.string().min(1), + pluginId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("learn-more"), + label: z.string().min(1), + url: z.string().url().startsWith("https://"), + }) + .strict(), +]); +export type TipAction = z.infer; + +export const tipToneSchema = z.enum([ + "blue", + "green", + "amber", + "orange", + "rose", +]); +export type TipTone = z.infer; + +export const tipViewSchema = z + .object({ + id: z.string().min(1), + illustration: z.string().min(1), + tone: tipToneSchema, + title: z.string().min(1), + body: z.string().min(1), + action: tipActionSchema, + }) + .strict(); +export type TipView = z.infer; + +export const tipStatusSchema = z.enum([ + "in-feed", + "eligible", + "not-applicable", + "dismissed", + "retired", + "expired", + "held", +]); +export type TipStatus = z.infer; + +export const tipRetiredReasonSchema = z.enum(["used"]); +export type TipRetiredReason = z.infer; + +export const tipListEntrySchema = tipViewSchema + .extend({ + status: tipStatusSchema, + shownCount: z.number().int().nonnegative(), + dismissed: z.boolean(), + acted: z.boolean(), + retiredReason: tipRetiredReasonSchema.nullable(), + }) + .strict(); +export type TipListEntry = z.infer; + +const tipIdInputSchema = z.object({ id: z.string().min(1).max(64) }).strict(); +const okSchema = z.object({ ok: z.literal(true) }).strict(); +const tipFeedInputSchema = z + .object({ + client: tipClientSchema, + projectId: z.string().min(1).nullable(), + visit: z.boolean(), + }) + .strict(); +const tipFeedOutputSchema = z.object({ tips: z.array(tipViewSchema) }).strict(); + +export const FEED_SIZE = 3; + +export const tipsRpcContract = defineRpcContract({ + current: { input: tipFeedInputSchema, output: tipFeedOutputSchema }, + hide: { + input: z.object({ hidden: z.boolean() }).strict(), + output: okSchema, + }, + setEnabled: { + input: z.object({ enabled: z.boolean() }).strict(), + output: okSchema, + }, + dismiss: { input: tipIdInputSchema, output: okSchema }, + act: { input: tipIdInputSchema, output: okSchema }, + list: { + input: z + .object({ client: tipClientSchema.nullable(), all: z.boolean() }) + .strict(), + output: z + .object({ + enabled: z.boolean(), + hiddenToday: z.boolean(), + tips: z.array(tipListEntrySchema), + }) + .strict(), + }, + reset: { input: z.null(), output: okSchema }, +}); + +export const TIPS_CHANGED_CHANNEL = "tips-changed"; diff --git a/plugins/tips/diagram-kit.stories.tsx b/plugins/tips/diagram-kit.stories.tsx new file mode 100644 index 00000000000..a1989120fa7 --- /dev/null +++ b/plugins/tips/diagram-kit.stories.tsx @@ -0,0 +1,283 @@ +import type { ReactNode } from "react"; +import { TIP_CATALOG, renderTip, type TipSignals } from "./catalog.js"; +import { + Arrow, + Bar, + Branch, + Bubble, + Button, + CalendarGrid, + CardStack, + ChartAxes, + Check, + CheckMark, + Clock, + Cursor, + Diagram, + DiagramKitStyles, + Dot, + Envelope, + HOVER_ANIMATIONS, + Highlight, + Keycap, + ListLine, + Magnifier, + Node, + Panel, + Phone, + Pill, + ProgressBar, + Rule, + SearchGlyph, + Slider, + Sparkle, + Toggle, + Window, + Wrench, + type HoverAnimation, +} from "./diagram-kit.js"; +import { TONE_COLOR, TipArt } from "./illustrations.js"; + +export default { title: "plugins/Tips" }; + +const ACCENT = TONE_COLOR.blue; + +const STORY_SIGNALS: TipSignals = { + client: { surface: "desktop", os: "macos" }, + projectId: null, + serverPlatform: "darwin", + appVersion: "0.46.0", + firstSeenVersion: "0.45.0", + daysSinceFirstSeen: 7, + threadCount: 60, + availableProviderCount: 3, + installedPlugins: {}, + hasFinishedThread: true, + finishedThreadCount: 10, + hasChildThread: false, + hasAutomationThread: false, + projectHasChildThread: false, + projectHasAutomationThread: false, + waitingThreadCount: 3, + rateLimited: false, + recentlyRateLimited: false, + queuedFollowUp: false, + usedMobileApp: false, +}; + +function Cell({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {children} + {label} +
+ ); +} + +function Sheet({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const PRIMITIVES: readonly (readonly [string, ReactNode])[] = [ + ["Panel", ], + ["Window", ], + ["Rule", ], + ["Dot", ], + ["ListLine", ], + [ + "Node + Branch", + + + + + , + ], + [ + "ProgressBar", + , + ], + [ + "Toggle", + + + + , + ], + [ + "Slider", + , + ], + [ + "Button + CheckMark", + + + + + Turns tips off. Turn them back on with Show tips in the Tips plugin + settings. + + + + ); +} + +function TipOutcome({ result }: { result: TipResult }) { + return result.ok ? ( + {result.text} + ) : ( + {result.text} + ); +} + +export function TipsFeed({ + tips, + result, + onPreview, + onActivate, +}: Pick) { + return ( + <> + + + {tips.map((tip) => ( +
onPreview(tip.id)} + onMouseLeave={() => onPreview(null)} + onFocus={() => onPreview(tip.id)} + onBlur={() => onPreview(null)} + > + + } + title={tip.title} + description={tip.body} + meta={ + result?.id === tip.id ? ( + + ) : undefined + } + ariaLabel={`${tip.title}. ${tip.body} ${describeTipAction(tip.action)}`} + onSelect={() => onActivate(tip)} + /> +
+ ))} +
+ + ); +} + +export function TipsGallery({ + tips, + result, + onPreview, + onActivate, + onDismiss, +}: TipsGalleryProps) { + return ( +
+ + + + +
+ ); +} + +export function TipsHiddenNotice({ onUndo }: { onUndo: () => void }) { + return ( +

+ Tips hidden + +

+ ); +} diff --git a/plugins/tips/illustrations.tsx b/plugins/tips/illustrations.tsx new file mode 100644 index 00000000000..222826fa84b --- /dev/null +++ b/plugins/tips/illustrations.tsx @@ -0,0 +1,433 @@ +import type { ReactNode } from "react"; +import type { TipTone } from "./contract.js"; +import { + Arrow, + Bar, + Branch, + Bubble, + Button, + CalendarGrid, + CardStack, + ChartAxes, + Check, + CheckMark, + Clock, + Cursor, + Diagram, + DiagramKitStyles, + Dot, + Envelope, + Highlight, + Keycap, + LINE, + ListLine, + Magnifier, + Node, + Panel, + Part, + Phone, + Pill, + ProgressBar, + Rule, + SearchGlyph, + Slider, + Sparkle, + Toggle, + Window, + Wrench, + type Stagger, +} from "./diagram-kit.js"; + +export const TONE_COLOR: Record = { + blue: "var(--timeline-accent)", + green: "var(--success)", + amber: "var(--attention)", + orange: "var(--warning)", + rose: "color-mix(in oklab, var(--destructive) 55%, var(--timeline-accent))", +}; + +type Illustration = (accent: string) => ReactNode; + +const PANES: readonly (readonly [number, number])[] = [ + [8, 16], + [25, 16], + [8, 29], + [25, 29], +]; + +const USAGE_BARS: readonly (readonly [number, number])[] = [ + [10, 22], + [18, 15], + [26, 28], + [34, 19], +]; + +function step(index: number): Stagger { + return index <= 0 + ? 0 + : index === 1 + ? 1 + : index === 2 + ? 2 + : index === 3 + ? 3 + : 4; +} + +export const ILLUSTRATIONS: Record = { + "whats-new": (accent) => ( + <> + + + + + + + + + + ), + "account-pool": (accent) => ( + <> + + + + + + + + + + + + + ), + "child-threads": (accent) => ( + <> + + + + + + + + + ), + "set-up-for-me": (accent) => ( + <> + + + + + + + + ), + phone: (accent) => ( + <> + + + + + + + + ), + "browser-automation": (accent) => ( + <> + + + + + + + + + ), + "build-plugin": (accent) => ( + <> + + + + + + + ), + "open-threads-that-need-me": (accent) => ( + <> + + {PANES.map(([x, y], index) => ( + + + {index === 1 ? null : ( + + )} + + + + ))} + + ), + "morning-digest": (accent) => ( + <> + + + + + + + + + ), + "decision-buttons": (accent) => ( + <> + + + +