diff --git a/apps/app/src/components/plugin/plugin-composer-host.tsx b/apps/app/src/components/plugin/plugin-composer-host.tsx index b0b8cc235d..5f76b81f24 100644 --- a/apps/app/src/components/plugin/plugin-composer-host.tsx +++ b/apps/app/src/components/plugin/plugin-composer-host.tsx @@ -17,6 +17,12 @@ import type { PromptDraftState } from "@bb/client-core"; export interface PluginComposerHost { scope: PluginComposerScope; textEffectKey: string; + newThreadMentionContext?: { + projectId: string; + environmentId: string | null; + hostId: string | null; + threadStorageThreadId: string | null; + }; getCurrent(): PromptDraftState; subscribeDraft(listener: () => void): () => void; setDraft(next: PromptDraftState): void; 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 231752d926..66afd9e02f 100644 --- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx +++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx @@ -62,6 +62,7 @@ import { import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests"; import { getComposerTextEffects } from "@/lib/composer-text-effects"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; +import { sdk } from "@/lib/sdk"; import { PluginPanelTabContent, usePluginNewThreadPanelActions, @@ -311,7 +312,7 @@ describe("useComposer", () => { - ); } @@ -1237,20 +1226,70 @@ describe("useComposer", () => { ); }); - it("rejects provider ids containing ':' without touching the draft", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - registerComposerProbe("b"); + it("resolves and inserts BB-owned mention resources", async () => { + vi.spyOn(sdk.threads, "resolveMentions").mockResolvedValue([ + { + threadId: "thr_target", + projectId: "proj_target", + label: "Canonical thread title", + }, + ]); + let composer: PluginComposerApi | null = null; + registerComposerProbe("built-in", (nextComposer) => { + composer = nextComposer; + }); render( , ); - fireEvent.click(screen.getByText("b-bad-mention")); - expect(screen.getByTestId("draft-text").textContent).toBe(""); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("invalid provider id"), + + await act(async () => { + if (composer === null) throw new Error("Composer did not render"); + await composer.insertMention({ + kind: "thread", + threadId: "thr_target", + }); + }); + + expect(screen.getByTestId("draft-text").textContent).toBe( + "Canonical thread title ", ); + expect( + JSON.parse(screen.getByTestId("draft-mentions").textContent ?? "[]"), + ).toEqual([ + { + start: 0, + end: 22, + resource: { + kind: "thread", + threadId: "thr_target", + projectId: "proj_target", + label: "Canonical thread title", + }, + }, + ]); + }); + + it("rejects provider ids containing ':' without touching the draft", async () => { + let composer: PluginComposerApi | null = null; + registerComposerProbe("b", (api) => { + composer = api; + }); + render( + + + + , + ); + await act(async () => { + if (composer === null) throw new Error("Composer did not render"); + await expect( + composer.insertMention({ provider: "bad:colon", id: "x", label: "x" }), + ).rejects.toThrow("invalid provider id"); + }); + expect(screen.getByTestId("draft-text").textContent).toBe(""); }); }); diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 0cc97b3c26..0478944a18 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -991,6 +991,12 @@ export function NewThreadComposer({ () => ({ scope: { kind: "new-thread", projectId }, textEffectKey: promptDraft.storageKey, + newThreadMentionContext: { + projectId, + environmentId: reuseEnvironmentId, + hostId: projectHostId, + threadStorageThreadId: panelThreadId, + }, getCurrent: promptDraft.getCurrent, subscribeDraft: promptDraft.subscribe, setDraft: promptDraft.setDraft, @@ -998,10 +1004,13 @@ export function NewThreadComposer({ }), [ projectId, + projectHostId, promptDraft.getCurrent, promptDraft.setDraft, promptDraft.storageKey, promptDraft.subscribe, + panelThreadId, + reuseEnvironmentId, ], ); diff --git a/apps/app/src/lib/plugin-composer-mentions.test.ts b/apps/app/src/lib/plugin-composer-mentions.test.ts new file mode 100644 index 0000000000..fbbbdf727e --- /dev/null +++ b/apps/app/src/lib/plugin-composer-mentions.test.ts @@ -0,0 +1,294 @@ +import { FILE_LIST_QUERY_MAX_LENGTH, PERSONAL_PROJECT_ID } from "@bb/domain"; +import { describe, expect, it, vi } from "vitest"; +import { resolveBuiltInComposerMention } from "./plugin-composer-mentions"; + +type ComposerMentionClient = NonNullable< + Parameters[2] +>; + +function createClient(): ComposerMentionClient { + return { + environments: { + paths: vi.fn(async () => ({ paths: [], truncated: false })), + }, + projects: { + get: vi.fn(async ({ projectId }: { projectId: string }) => ({ + id: projectId, + name: "Canonical project", + })), + paths: vi.fn(async () => ({ paths: [], truncated: false })), + }, + threadSections: { + get: vi.fn(async ({ sectionId }: { sectionId: string }) => ({ + id: sectionId, + name: "Canonical section", + })), + }, + threads: { + get: vi.fn(async () => ({ + environmentId: "env_1", + projectId: "proj_1", + })), + resolveMentions: vi.fn(async () => [ + { + threadId: "thr_1", + projectId: "proj_1", + label: "Canonical thread", + }, + ]), + storagePaths: vi.fn(async () => ({ paths: [], truncated: false })), + }, + }; +} + +describe("resolveBuiltInComposerMention", () => { + it("resolves canonical labels for thread, project, and section mentions", async () => { + const client = createClient(); + const scope = { kind: "thread", threadId: "thr_context" } as const; + + await expect( + resolveBuiltInComposerMention( + { kind: "thread", threadId: "thr_1" }, + scope, + client, + ), + ).resolves.toEqual({ + kind: "thread", + threadId: "thr_1", + projectId: "proj_1", + label: "Canonical thread", + }); + await expect( + resolveBuiltInComposerMention( + { kind: "project", projectId: "proj_1" }, + scope, + client, + ), + ).resolves.toEqual({ + kind: "project", + projectId: "proj_1", + label: "Canonical project", + }); + await expect( + resolveBuiltInComposerMention( + { kind: "section", sectionId: "sec_1" }, + scope, + client, + ), + ).resolves.toEqual({ + kind: "section", + sectionId: "sec_1", + label: "Canonical section", + }); + }); + + it("uses the current thread environment for workspace paths", async () => { + const client = createClient(); + const environmentPaths = vi.fn( + async ( + _args: Parameters[0], + ) => ({ + paths: [ + { kind: "file" as const, path: "src/index.ts", name: "index.ts" }, + ], + truncated: false, + }), + ); + client.environments.paths = environmentPaths; + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "src/index.ts" }, + { kind: "thread", threadId: "thr_context" }, + client, + ), + ).resolves.toEqual({ + kind: "path", + source: "workspace", + entryKind: "file", + path: "src/index.ts", + label: "index.ts", + }); + expect(client.threads.get).toHaveBeenCalledWith({ + threadId: "thr_context", + }); + expect(environmentPaths).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: "env_1", + query: "src/index.ts", + }), + ); + }); + + it("uses the selected new-thread environment instead of the project default", async () => { + const client = createClient(); + const environmentPaths = vi.fn( + async ( + _args: Parameters[0], + ) => ({ + paths: [ + { kind: "file" as const, path: "worktree.txt", name: "worktree.txt" }, + ], + truncated: false, + }), + ); + client.environments.paths = environmentPaths; + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "worktree.txt" }, + { kind: "new-thread", projectId: "proj_1" }, + client, + { + projectId: "proj_1", + environmentId: "env_selected", + hostId: null, + threadStorageThreadId: null, + }, + ), + ).resolves.toMatchObject({ + kind: "path", + path: "worktree.txt", + label: "worktree.txt", + }); + expect(environmentPaths).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "env_selected" }), + ); + expect(client.projects.paths).not.toHaveBeenCalled(); + }); + + it("uses the active side-chat thread for thread-storage paths", async () => { + const client = createClient(); + const storagePaths = vi.fn( + async ( + _args: Parameters[0], + ) => ({ + paths: [ + { kind: "directory" as const, path: "reports", name: "reports" }, + ], + truncated: false, + }), + ); + client.threads.storagePaths = storagePaths; + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "thread-storage", path: "reports" }, + { + kind: "side-chat", + projectId: "proj_1", + parentThreadId: "thr_parent", + tabId: "tab_1", + childThreadId: "thr_child", + }, + client, + ), + ).resolves.toEqual({ + kind: "path", + source: "thread-storage", + entryKind: "directory", + path: "reports", + label: "reports", + }); + expect(storagePaths).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "thr_child", query: "reports" }), + ); + }); + + it("rejects absolute and unresolved paths", async () => { + const client = createClient(); + const scope = { kind: "new-thread", projectId: "proj_1" } as const; + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "/tmp/file.txt" }, + scope, + client, + ), + ).rejects.toThrow("must be relative"); + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "../file.txt" }, + scope, + client, + ), + ).rejects.toThrow("must stay within"); + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "missing.txt" }, + scope, + client, + ), + ).rejects.toThrow("could not be resolved"); + }); + + it("rejects workspace paths without a resolvable project", async () => { + const client = createClient(); + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "file.txt" }, + { kind: "new-thread", projectId: PERSONAL_PROJECT_ID }, + client, + { + projectId: PERSONAL_PROJECT_ID, + environmentId: null, + hostId: null, + threadStorageThreadId: null, + }, + ), + ).rejects.toThrow("Workspace mentions require a resolved project"); + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "file.txt" }, + { kind: "new-thread", projectId: PERSONAL_PROJECT_ID }, + client, + ), + ).rejects.toThrow("Workspace mentions require a resolved project"); + expect(client.projects.paths).not.toHaveBeenCalled(); + }); + + it("reports truncation instead of claiming the path does not exist", async () => { + const client = createClient(); + client.projects.paths = vi.fn(async () => ({ + paths: [], + truncated: true, + })); + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: "src/index.ts" }, + { kind: "new-thread", projectId: "proj_1" }, + client, + ), + ).rejects.toThrow( + "could not be verified because the path listing was truncated", + ); + }); + + it("rejects paths longer than the listing query limit", async () => { + const client = createClient(); + const longPath = "a".repeat(FILE_LIST_QUERY_MAX_LENGTH + 1); + + await expect( + resolveBuiltInComposerMention( + { kind: "path", source: "workspace", path: longPath }, + { kind: "new-thread", projectId: "proj_1" }, + client, + ), + ).rejects.toThrow("Mention path must be at most"); + expect(client.projects.paths).not.toHaveBeenCalled(); + }); + + it("rejects unknown mention kinds", async () => { + const client = createClient(); + + await expect( + resolveBuiltInComposerMention( + { kind: "bogus" } as never, + { kind: "new-thread", projectId: "proj_1" }, + client, + ), + ).rejects.toThrow('Unsupported composer mention kind "bogus"'); + }); +}); diff --git a/apps/app/src/lib/plugin-composer-mentions.ts b/apps/app/src/lib/plugin-composer-mentions.ts new file mode 100644 index 0000000000..8a3584ac16 --- /dev/null +++ b/apps/app/src/lib/plugin-composer-mentions.ts @@ -0,0 +1,249 @@ +import { FILE_LIST_QUERY_MAX_LENGTH } from "@bb/domain"; +import type { PromptMentionResource } from "@bb/domain"; +import type { + ExperimentalPluginComposerBuiltInMention, + PluginComposerScope, +} from "@get-bb/plugin-sdk"; +import { isProjectlessProjectId } from "@/lib/route-paths"; +import { sdk } from "@/lib/sdk"; + +interface PathQuery { + query: string; + limit: string; + includeFiles: "true"; + includeDirectories: "true"; +} + +interface ResolvedPathEntry { + kind: "file" | "directory"; + path: string; + name: string; +} + +const PATH_MENTION_LISTING_LIMIT = 100; + +interface ResolvedPathList { + paths: ResolvedPathEntry[]; + truncated: boolean; +} + +type ProjectPathQuery = PathQuery & { projectId: string } & ( + | { hostId: string } + | { hostId?: never } + ); + +interface ComposerMentionSdk { + environments: { + paths( + args: PathQuery & { environmentId: string }, + ): Promise; + }; + projects: { + get(args: { projectId: string }): Promise<{ id: string; name: string }>; + paths(args: ProjectPathQuery): Promise; + }; + threadSections: { + get(args: { sectionId: string }): Promise<{ id: string; name: string }>; + }; + threads: { + get(args: { + threadId: string; + }): Promise<{ environmentId: string | null; projectId: string }>; + resolveMentions(args: { threadIds: string[] }): Promise< + Array<{ + threadId: string; + projectId: string; + label: string; + }> + >; + storagePaths( + args: PathQuery & { threadId: string }, + ): Promise; + }; +} + +export interface NewThreadMentionContext { + projectId: string; + environmentId: string | null; + hostId: string | null; + threadStorageThreadId: string | null; +} + +function requiredValue(value: string, description: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${description} must not be empty`); + } + return trimmed; +} + +function normalizeMentionPath(value: string): string { + const requestedPath = requiredValue(value, "Mention path"); + if ( + requestedPath.startsWith("/") || + requestedPath.startsWith("\\") || + /^[A-Za-z]:[\\/]/u.test(requestedPath) + ) { + throw new Error("Mention path must be relative to its source"); + } + const normalizedPath = requestedPath + .replaceAll("\\", "/") + .replace(/^\.\/+|\/+$/gu, "") + .replace(/\/{2,}/gu, "/"); + if (normalizedPath.length === 0 || normalizedPath.split("/").includes("..")) { + throw new Error("Mention path must stay within its source"); + } + return normalizedPath; +} + +function threadIdForComposerScope(scope: PluginComposerScope): string | null { + switch (scope.kind) { + case "thread": + case "queued-message": + return scope.threadId; + case "side-chat": + return scope.childThreadId ?? scope.parentThreadId; + case "new-thread": + return null; + } +} + +async function resolvePathMention( + mention: Extract, + scope: PluginComposerScope, + client: ComposerMentionSdk, + newThreadContext: NewThreadMentionContext | undefined, +): Promise> { + const requestedPath = normalizeMentionPath(mention.path); + if (requestedPath.length > FILE_LIST_QUERY_MAX_LENGTH) { + throw new Error( + `Mention path must be at most ${FILE_LIST_QUERY_MAX_LENGTH} characters`, + ); + } + + const threadId = + newThreadContext === undefined + ? threadIdForComposerScope(scope) + : newThreadContext.threadStorageThreadId; + const query = { + query: requestedPath, + limit: String(PATH_MENTION_LISTING_LIMIT), + includeFiles: "true" as const, + includeDirectories: "true" as const, + }; + const requireResolvedProjectId = (projectId: string): string => { + if (isProjectlessProjectId(projectId)) { + throw new Error("Workspace mentions require a resolved project"); + } + return projectId; + }; + + let listing: ResolvedPathList; + if (mention.source === "thread-storage") { + if (threadId === null) { + throw new Error( + "Thread-storage mentions require an existing thread composer", + ); + } + listing = await client.threads.storagePaths({ threadId, ...query }); + } else if (newThreadContext !== undefined) { + listing = + newThreadContext.environmentId !== null + ? await client.environments.paths({ + environmentId: newThreadContext.environmentId, + ...query, + }) + : await client.projects.paths( + newThreadContext.hostId === null + ? { + projectId: requireResolvedProjectId( + newThreadContext.projectId, + ), + ...query, + } + : { + projectId: requireResolvedProjectId( + newThreadContext.projectId, + ), + hostId: newThreadContext.hostId, + ...query, + }, + ); + } else if (threadId !== null) { + const thread = await client.threads.get({ threadId }); + listing = + thread.environmentId === null + ? await client.projects.paths({ + projectId: requireResolvedProjectId(thread.projectId), + ...query, + }) + : await client.environments.paths({ + environmentId: thread.environmentId, + ...query, + }); + } else { + if (scope.kind !== "new-thread" || scope.projectId === null) { + throw new Error("Workspace mentions require a resolved project"); + } + listing = await client.projects.paths({ + projectId: requireResolvedProjectId(scope.projectId), + ...query, + }); + } + + const entry = listing.paths.find( + (candidate) => candidate.path === requestedPath, + ); + if (entry === undefined) { + throw new Error( + listing.truncated + ? `${mention.source} path "${requestedPath}" could not be verified because the path listing was truncated` + : `${mention.source} path "${requestedPath}" could not be resolved. It does not exist or is not listable (hidden and ignored paths cannot be mentioned)`, + ); + } + return { + kind: "path", + source: mention.source, + entryKind: entry.kind, + path: entry.path, + label: entry.name, + }; +} + +export async function resolveBuiltInComposerMention( + mention: ExperimentalPluginComposerBuiltInMention, + scope: PluginComposerScope, + client: ComposerMentionSdk = sdk, + newThreadContext?: NewThreadMentionContext, +): Promise { + switch (mention.kind) { + case "thread": { + const threadId = requiredValue(mention.threadId, "Thread id"); + const resolution = ( + await client.threads.resolveMentions({ threadIds: [threadId] }) + )[0]; + if (resolution === undefined) { + throw new Error(`Thread "${threadId}" could not be resolved`); + } + return { kind: "thread", ...resolution }; + } + case "project": { + const projectId = requiredValue(mention.projectId, "Project id"); + const project = await client.projects.get({ projectId }); + return { kind: "project", projectId: project.id, label: project.name }; + } + case "section": { + const sectionId = requiredValue(mention.sectionId, "Section id"); + const section = await client.threadSections.get({ sectionId }); + return { kind: "section", sectionId: section.id, label: section.name }; + } + case "path": + return resolvePathMention(mention, scope, client, newThreadContext); + default: + throw new Error( + `Unsupported composer mention kind "${String( + (mention as { kind?: unknown }).kind, + )}"`, + ); + } +} diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts index aa2d1279ba..5cfb31f6b3 100644 --- a/apps/app/src/lib/plugin-sdk-hooks.ts +++ b/apps/app/src/lib/plugin-sdk-hooks.ts @@ -67,6 +67,7 @@ import { getPluginFixedTabOwnerId, useAppFixedTabTarget, } from "@/lib/app-fixed-tab-navigation"; +import { resolveBuiltInComposerMention } from "@/lib/plugin-composer-mentions"; type FetchLike = ( input: string, @@ -773,42 +774,85 @@ export function useComposer(): PluginComposerApi { [focusActiveComposer, getCurrent, setDraft], ); - const insertMention = useCallback( - (mention: PluginComposerMention) => { - const provider = mention.provider.trim(); - const label = mention.label.trim() || mention.id; - if (provider.length === 0 || provider.includes(":")) { - console.warn( - `[plugin:${pluginId}] useComposer().insertMention: invalid provider id "${mention.provider}"`, + const resolvedScope = useMemo( + () => + composerScope ?? + (threadId !== undefined + ? { kind: "thread", threadId } + : { kind: "new-thread", projectId: projectId ?? null }), + [composerScope, projectId, threadId], + ); + const newThreadMentionContext = composerHost?.newThreadMentionContext; + + const insertResolvedMention = useCallback( + async (mention: PluginComposerMention) => { + let resource: PromptTextMention["resource"]; + if ("provider" in mention) { + const provider = mention.provider.trim(); + const label = mention.label.trim() || mention.id; + if (provider.length === 0 || provider.includes(":")) { + throw new Error( + `useComposer().insertMention: invalid provider id "${mention.provider}"`, + ); + } + resource = { + kind: "plugin", + pluginId, + icon: null, + itemId: `${provider}:${mention.id}`, + label, + }; + } else { + resource = await resolveBuiltInComposerMention( + mention, + resolvedScope, + sdk, + newThreadMentionContext, ); - return; } + if (!scopeOwnership.isActive()) return; const current = getCurrent(); const separator = current.text.length === 0 || /\s$/u.test(current.text) ? "" : " "; const start = current.text.length + separator.length; - const end = start + label.length; + const end = start + resource.label.length; setDraft({ ...current, - text: `${current.text}${separator}${label} `, + text: `${current.text}${separator}${resource.label} `, mentions: [ ...current.mentions, { start, end, - resource: { - kind: "plugin", - pluginId, - icon: null, - itemId: `${provider}:${mention.id}`, - label, - }, + resource, }, ], }); focusActiveComposer(); }, - [focusActiveComposer, getCurrent, pluginId, setDraft], + [ + focusActiveComposer, + getCurrent, + newThreadMentionContext, + pluginId, + resolvedScope, + scopeOwnership, + setDraft, + ], + ); + + const insertMention = useCallback( + (mention: PluginComposerMention) => { + const insertion = insertResolvedMention(mention); + insertion.catch((error: unknown) => { + console.warn( + `[plugin:${pluginId}] useComposer().insertMention failed:`, + error, + ); + }); + return insertion; + }, + [insertResolvedMention, pluginId], ); const focus = focusActiveComposer; @@ -816,11 +860,7 @@ export function useComposer(): PluginComposerApi { return useMemo( () => ({ - scope: - composerScope ?? - (threadId !== undefined - ? { kind: "thread", threadId } - : { kind: "new-thread", projectId: projectId ?? null }), + scope: resolvedScope, text: composerText, setText, updateText, @@ -835,15 +875,13 @@ export function useComposer(): PluginComposerApi { [ addQuote, clear, - composerScope, composerText, focus, insertMention, - projectId, + resolvedScope, setText, setTextEffect, setInputLock, - threadId, updateText, ], ); diff --git a/apps/server/src/routes/thread-sections.ts b/apps/server/src/routes/thread-sections.ts index 22932161fa..dbf91aa0fa 100644 --- a/apps/server/src/routes/thread-sections.ts +++ b/apps/server/src/routes/thread-sections.ts @@ -1,6 +1,7 @@ import { createThreadSection, deleteThreadSection, + getThreadSectionById, normalizeThreadSectionName, renameThreadSection, } from "@bb/db"; @@ -30,11 +31,19 @@ function throwDuplicateSectionName(): never { } export function registerThreadSectionRoutes(app: Hono, deps: AppDeps): void { - const { del, patch, post } = typedRoutes(app, { + const { del, get, patch, post } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.threadSections; + get(routes.get, (context) => { + const section = getThreadSectionById(deps.db, context.req.param("id")); + if (section === null) { + throw new ApiError(404, "section_not_found", "Section not found"); + } + return context.json(section); + }); + post(routes.create, (context, payload) => { const result = createThreadSection(deps.db, deps.hub, { name: requireSectionName(payload.name), diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md index 5ee1b991b1..9e311180ee 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md @@ -20,7 +20,7 @@ signatures (see "Looking up the exact API"). | Area | Methods | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `threads` | `list` `get` `search` `spawn` `fork` `send` `editMessage` `resolveMentions` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storageLocation` `storagePaths` `cancelPlan` `clearGoal` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) | -| `threadSections` | `list` `create` `update` `delete` | +| `threadSections` | `list` `get` `create` `update` `delete` | | `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory` `sidebarBootstrap`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | | `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` | | `hosts` | `list` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `retryUpdate` `providerCliStatus` `installProviderCli` | @@ -34,6 +34,10 @@ signatures (see "Looking up the exact API"). | `system` | `version` `config` `reloadConfig` `attention` `usageLimits` `executionOptions` `providerStates` `transcribeVoice` `updateGeneralSettings` `updateKeyboardSettings` `updateExperiments` `cliSkillsStatus` `installCliSkills` | | `guide` | `render` (the `bb guide` text; local, no request) | +Get one section with `await bb.sdk.threadSections.get({ sectionId })`. The +input also accepts `signal`, and the promise returns `ThreadSectionResponse`. +A missing section rejects with an API error. + Prefer your own `bb.settings` and `bb.storage` over `sdk.system` and `sdk.plugins` for your plugin's own configuration. The `system` and `plugins` areas write app-wide state that the user owns. diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md index 06bba6b225..015b241d80 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-hooks-and-ui.md @@ -39,11 +39,17 @@ experimental_openFilePreview(options), experimental_openFileExternally(options) from the plugin stylesheet (`null` clears it); `setInputLock(locked)` makes the editor read-only and busy and auto-releases when the customization unmounts or changes scope; - `insertMention({ provider, id, label })` inserts an @-mention pill bound - to one of YOUR `bb.ui.registerMentionProvider` providers, resolved to - fresh context at send time; `focus()` focuses the caret. The `scope` is - `thread`, `queued-message`, `side-chat`, or `new-thread`, with the identifiers - for that surface. + `insertMention(input)` inserts a structured @-mention pill and returns a + promise. Use `{ provider, id, label }` for one of your + `bb.ui.registerMentionProvider` providers. The provider resolves fresh + context when the user sends the message. Built-in inputs use one of these + forms: `{ kind: "thread", threadId }`, `{ kind: "project", projectId }`, + `{ kind: "section", sectionId }`, or `{ kind: "path", source, path }`. + A path source is `"workspace"` or `"thread-storage"`. The path is relative + to its source. BB resolves the built-in label and path-entry kind. Await the + promise. It rejects when BB cannot resolve the entity. `focus()` focuses the + caret. The `scope` is `thread`, `queued-message`, `side-chat`, or + `new-thread`, with the identifiers for that surface. - `useComposerView()` → reactive `{ scope, layout, draft, run }` for the composer instance that mounted an action or banner. `layout` is `"expanded" | "compact" | "zen"`; `draft` is diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts index 3ecba0385d..88e014fcdc 100644 --- a/apps/server/test/public/public-thread-data.test.ts +++ b/apps/server/test/public/public-thread-data.test.ts @@ -108,6 +108,14 @@ describe("public thread data routes", () => { const section = threadSectionSchema.parse(await readJson(createResponse)); expect(section.name).toBe("Release QA"); + const getResponse = await harness.app.request( + `/api/v1/thread-sections/${section.id}`, + ); + expect(getResponse.status).toBe(200); + expect(threadSectionSchema.parse(await readJson(getResponse))).toEqual( + section, + ); + const duplicateResponse = await harness.app.request( "/api/v1/thread-sections", { @@ -193,6 +201,14 @@ describe("public thread data routes", () => { await expect(readJson(missingResponse)).resolves.toMatchObject({ code: "section_not_found", }); + + const missingGetResponse = await harness.app.request( + `/api/v1/thread-sections/${section.id}`, + ); + expect(missingGetResponse.status).toBe(404); + await expect(readJson(missingGetResponse)).resolves.toMatchObject({ + code: "section_not_found", + }); }); }); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 8026e8147e..b4fff40184 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -1887,3 +1887,34 @@ other pane's copy (or release its owned state). The thread-list slot omits it deliberately: it mounts once, and a crash there should disable it everywhere. Confirm that split before stabilizing, and decide whether other multi-mount slots need the same treatment. + +## `ExperimentalPluginComposerBuiltInMention` / `ExperimentalPluginComposerProviderMention` (`@get-bb/plugin-sdk/app`) + +**What it does.** Widens `useComposer().insertMention()` beyond plugin-provider +mentions. `PluginComposerMention` is now a union of the provider form +(`ExperimentalPluginComposerProviderMention`) and a BB-owned form +(`ExperimentalPluginComposerBuiltInMention`, `kind: "thread" | "project" | +"section" | "path"`). BB resolves the built-in forms to canonical +`PromptMentionResource` values before insertion, so the inserted pill matches +one the user picked by hand. `insertMention` became async (`void` -> +`Promise`) and rejects when resolution fails. The testing harness gained +the `renderSlot` option `composer.resolveBuiltInMention`, which stands in for +BB's resolver. + +**Audit before stabilizing.** + +1. **Path resolution primitive.** Path mentions resolve through the fuzzy path + listing. Hidden and ignored paths never resolve, and a truncated listing is + reported as an error rather than avoided. An exact server-side + existence/stat lookup would retire both limits. Decide the final primitive + before freezing the contract. +2. **`insertMention` signature.** The union changed a shipped member's return + type from `void` to `Promise`. Confirm the union shape and the async + signature are final, and whether the SDK compatibility fence needs to gate + built-in mentions for older plugins. +3. **New-thread reach.** `NewThreadMentionContext` only reaches composers + mounted in the root compose view. Nav-panel and homepage composers fall + back to project-default resolution. Close or document the gap. +4. **Harness fidelity.** `composer.resolveBuiltInMention` lets a test resolver + return labels production would reject. Decide whether the harness should + enforce the production invariants. diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index 098cbc0ed2..832ca841a1 100644 --- a/packages/plugin-api-map/sdk-public-api.json +++ b/packages/plugin-api-map/sdk-public-api.json @@ -3,7 +3,7 @@ "entries": { ".": { "types": "bundled-types/bb-plugin-sdk.d.ts", - "sha256": "70077a510684588d29816ecb221ca99476640c7972c31730025064904265535e" + "sha256": "328dca8eef3ea61637c4a428fa761d1b4e551d3799a0be661ea94eee92ee1687" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts", @@ -11,7 +11,7 @@ }, "./app": { "types": "bundled-types/bb-plugin-sdk-app.d.ts", - "sha256": "d5a5ca8bb14f04dbe5d53dc47470412337ce1fcf8a8019c692479620b05ef7c2" + "sha256": "f5dee2c637c804cc6595bfdf91201e21a9de7cb26f98a8238b36d76ecb34142d" }, "./host": { "types": "bundled-types/bb-plugin-sdk-host.d.ts", @@ -35,7 +35,7 @@ }, "./testing/app": { "types": "bundled-types/bb-plugin-sdk-testing-app.d.ts", - "sha256": "e618f89997828f11b10520e9dcd00b4b26e0fad7f49fb6030b55189c3247b3ba" + "sha256": "34c0ca7759ec877a956af4299b6c689e40539331ab03f72e3b41543fab4f8e67" }, "./testing/host": { "types": "bundled-types/bb-plugin-sdk-testing-host.d.ts", diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index 03a66db32c..49ab3e9cef 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -363,11 +363,15 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Adds a button to the row of controls inside the prompt box, beside the voice and send buttons. With this, a plugin can:", bullets: [ "Read and rewrite the draft prompt, for example rephrasing it or inserting a template", - "Insert an @-mention into the draft so its provider can resolve fresh context when the message is sent", + "Insert an @-mention into the draft: either a plugin-provider mention resolved when the message is sent, or a built-in thread, project, section, or path mention that bb resolves before inserting", "Lock the input while it works, and tint the whole draft while it does", "Render in the same row as bb's own prompt-box buttons. If you have more than 3 plugins enabled, bb keeps the 3 most-used plugins inline and moves the rest into an overflow menu", ], - apiSymbols: ["PluginComposerApi"], + apiSymbols: [ + "PluginComposerApi", + "ExperimentalPluginComposerBuiltInMention", + "ExperimentalPluginComposerProviderMention", + ], }, ], }, diff --git a/packages/plugin-sdk/src/__tests__/composer-mention-types.test.ts b/packages/plugin-sdk/src/__tests__/composer-mention-types.test.ts new file mode 100644 index 0000000000..f215121946 --- /dev/null +++ b/packages/plugin-sdk/src/__tests__/composer-mention-types.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import type { PluginComposerMention } from "../app-contract.js"; + +function acceptMention(mention: PluginComposerMention): PluginComposerMention { + return mention; +} + +describe("PluginComposerMention", () => { + it("accepts provider and BB-owned forms", () => { + expect( + acceptMention({ provider: "issues", id: "ENG-42", label: "ENG-42" }), + ).toMatchObject({ provider: "issues" }); + expect(acceptMention({ kind: "thread", threadId: "thr_42" })).toMatchObject( + { kind: "thread" }, + ); + expect( + acceptMention({ + kind: "path", + source: "thread-storage", + path: "reports/result.md", + }), + ).toMatchObject({ kind: "path" }); + }); + + it("does not accept caller-owned labels for BB-owned forms", () => { + // @ts-expect-error BB resolves the label for a built-in mention. + acceptMention({ kind: "project", projectId: "proj_42", label: "Alias" }); + }); +}); diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index db27f3aa8f..27b902dbf5 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1553,7 +1553,7 @@ export interface PluginComposerThreadRowStatus { } /** An @-mention pill bound to one of the calling plugin's mention providers. */ -export interface PluginComposerMention { +export interface ExperimentalPluginComposerProviderMention { /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */ provider: string; /** Item id your provider's `resolve` will receive at send time. */ @@ -1562,6 +1562,25 @@ export interface PluginComposerMention { label: string; } +/** A BB-owned entity that `insertMention()` resolves before insertion. */ +export type ExperimentalPluginComposerBuiltInMention = { label?: never } & ( + | { kind: "thread"; threadId: string } + | { kind: "project"; projectId: string } + | { kind: "section"; sectionId: string } + | { + kind: "path"; + /** Root that `path` is relative to. */ + source: "workspace" | "thread-storage"; + /** Relative path inside `source`. BB resolves its name and entry kind. */ + path: string; + } +); + +/** A plugin-owned or BB-owned @-mention pill. */ +export type PluginComposerMention = + | ExperimentalPluginComposerProviderMention + | ExperimentalPluginComposerBuiltInMention; + /** * Programmatic access to the chat composer draft — the same shared draft the * built-in "Add to chat" affordances (file preview, diff, terminal selections) @@ -1607,11 +1626,11 @@ export interface PluginComposerApi { */ addQuote(text: string): void; /** - * Insert an @-mention pill that resolves through this plugin's mention - * provider at send time — the durable way to reference an entity whose - * content should be fetched fresh when the message is sent. + * Insert an @-mention pill. Provider mentions resolve through this plugin at + * send time. BB resolves built-in entity labels and path kinds before it + * inserts them. The promise rejects when BB cannot resolve a built-in entity. */ - insertMention(mention: PluginComposerMention): void; + insertMention(mention: PluginComposerMention): Promise; /** Focus the composer caret at the end of the draft. */ focus(): void; } 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 0f8033a926..c4c984a757 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom import { useEffect, useState } from "react"; -import { cleanup, fireEvent, render, within } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { @@ -378,7 +384,7 @@ function ComposerProbe() { + @@ -1623,6 +1640,27 @@ describe("renderSlot", () => { expect(slot.composer.focusCount).toBe(3); }); + it("uses the configured host resolver for BB-owned mentions", async () => { + const resolveBuiltInMention = vi.fn(async () => "Resolved thread"); + const slot = renderSlot( + app.composerCustomizations[0]!.actions![0]!, + {}, + { composer: { resolveBuiltInMention } }, + ); + + fireEvent.click(slot.getByText("built-in mention")); + + await waitFor(() => expect(slot.composer.text).toBe("Resolved thread ")); + expect(resolveBuiltInMention).toHaveBeenCalledWith({ + kind: "thread", + threadId: "thr_reference", + }); + expect(slot.composer.mentions).toEqual([ + { kind: "thread", threadId: "thr_reference" }, + ]); + expect(slot.composer.focusCount).toBe(1); + }); + it("invalidates visual-state setters through both unmount controls", () => { for (const control of ["top-level", "lifecycle"] as const) { const slot = renderSlot( diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 0b0e3a075c..5f142cba84 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -23,6 +23,7 @@ import { type PluginContentScriptDisposer, type PluginContentScriptRegistration, type PluginComposerApi, + type ExperimentalPluginComposerBuiltInMention, type PluginComposerMention, type PluginComposerScope, type PluginComposerTextEffect, @@ -1105,6 +1106,10 @@ export interface RenderSlotOptions< text?: string; scope?: PluginComposerScope; attachmentCount?: number; + /** Resolve the pill label for each BB-owned `insertMention()` call. */ + resolveBuiltInMention?: ( + mention: ExperimentalPluginComposerBuiltInMention, + ) => string | Promise; }; /** * Threads and projects `experimental_useSidebarThreads()` reports. Omitted → @@ -1537,11 +1542,27 @@ export function renderSlot< } composerLog.focusCount += 1; }, - insertMention(mention) { - const label = mention.label.trim() || mention.id; + async insertMention(mention) { + const label = + "provider" in mention + ? mention.label.trim() || mention.id + : await (() => { + const resolveBuiltInMention = + options.composer?.resolveBuiltInMention; + if (resolveBuiltInMention === undefined) { + throw new Error( + "renderSlot composer.resolveBuiltInMention must resolve BB-owned insertMention calls", + ); + } + return resolveBuiltInMention(mention); + })(); + const resolvedLabel = label.trim(); + if (resolvedLabel.length === 0) { + throw new Error("Resolved composer mention label must not be empty"); + } const separator = composerText.length === 0 || /\s$/u.test(composerText) ? "" : " "; - commitComposerText(`${composerText}${separator}${label} `); + commitComposerText(`${composerText}${separator}${resolvedLabel} `); composerLog.mentions.push(mention); composerLog.focusCount += 1; }, diff --git a/packages/sdk/src/areas/thread-sections.ts b/packages/sdk/src/areas/thread-sections.ts index 243d2f01d6..03f061aec5 100644 --- a/packages/sdk/src/areas/thread-sections.ts +++ b/packages/sdk/src/areas/thread-sections.ts @@ -15,15 +15,22 @@ import { signalRequestArgs, type CreateSdkAreaArgs } from "./common.js"; export type ThreadSectionCreateResult = ThreadSectionResponse; export type ThreadSectionUpdateResult = ThreadSectionMutationResponse; export type ThreadSectionDeleteResult = ThreadSectionMutationResponse; +export type ThreadSectionGetResult = ThreadSectionResponse; export type ThreadSectionListResult = ThreadSectionResponse[]; export interface ThreadSectionListArgs { signal?: AbortSignal; } +export interface ThreadSectionGetArgs { + sectionId: string; + signal?: AbortSignal; +} + export interface ThreadSectionsArea { create(args: CreateThreadSectionRequest): Promise; delete(args: DeleteThreadSectionRequest): Promise; + get(args: ThreadSectionGetArgs): Promise; list(args?: ThreadSectionListArgs): Promise; update(args: UpdateThreadSectionRequest): Promise; } @@ -45,6 +52,15 @@ export function createThreadSectionsArea( ); return threadSectionMutationResponseSchema.parse(body); }, + async get(input) { + const body = await transport.readJson( + transport.api.v1["thread-sections"][":id"].$get( + { param: { id: input.sectionId } }, + ...signalRequestArgs(input.signal), + ), + ); + return threadSectionSchema.parse(body); + }, async list(input) { const body = await transport.readJson( transport.api.v1["sidebar-bootstrap"].$get( diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index ad470b1b97..ead9df3142 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -347,7 +347,12 @@ type ExpectedSystemKey = type ExpectedThemeKey = "catalog" | "get" | "set"; -type ExpectedThreadSectionsKey = "create" | "delete" | "list" | "update"; +type ExpectedThreadSectionsKey = + | "create" + | "delete" + | "get" + | "list" + | "update"; type ExpectedThreadsKey = | "archive" diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index f031fcf1b9..263c59aee6 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -1257,8 +1257,16 @@ describe("@bb/sdk", () => { }); }); - it("exposes thread section mutations", async () => { + it("exposes thread section reads and mutations", async () => { const queue = createFetchQueue([ + { + body: { + id: "sec_123", + name: "Review", + createdAt: 1, + updatedAt: 1, + }, + }, { body: { id: "sec_123", @@ -1277,10 +1285,18 @@ describe("@bb/sdk", () => { }), }); + await expect( + sdk.threadSections.get({ sectionId: "sec_123" }), + ).resolves.toMatchObject({ id: "sec_123", name: "Review" }); await expect( sdk.threadSections.create({ name: "Review" }), ).resolves.toMatchObject({ id: "sec_123", name: "Review" }); expect(queue.requests[0]).toEqual({ + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/thread-sections/sec_123", + }); + expect(queue.requests[1]).toEqual({ bodyText: JSON.stringify({ name: "Review" }), method: "POST", url: "http://bb.test/api/v1/thread-sections", diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 27e8eb99a6..434a180814 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -855,6 +855,15 @@ export const publicApiRoutes = { }, threadSections: { + get: defineRoute({ + path: "/thread-sections/:id", + method: "get", + request: noRequest(), + response: [ + jsonResponse(), + jsonResponse({ status: 404 }), + ], + }), create: defineRoute({ path: "/thread-sections", method: "post",