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",