diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index dff63bef1..8246aeb58 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -245,6 +245,8 @@ interface AppHeaderProps {
* the shared header shows plugin logo + title, plus the registration's
* `headerContent` as the actions. */
pluginPanel?: PluginNavPanelSlot;
+ /** The panel route's splat remainder ("" at the panel root). */
+ pluginPanelSubPath?: string;
meta: {
title: string;
subtitle?: string;
@@ -260,6 +262,7 @@ function AppHeader({
projectId,
project,
pluginPanel,
+ pluginPanelSubPath,
meta,
}: AppHeaderProps) {
const headerBreadcrumbs = meta.breadcrumbs;
@@ -325,7 +328,10 @@ function AppHeader({
) : null;
const actions = pluginPanel ? (
-
+
) : usesProjectChromeStyle &&
projectId &&
!isProjectlessProjectId(projectId) ? (
@@ -684,6 +690,7 @@ export function AppLayout({ children }: AppLayoutProps) {
projectId={projectId}
project={project}
pluginPanel={pluginPanel}
+ pluginPanelSubPath={pluginPanelMatch?.params["*"] ?? ""}
meta={meta}
/>
) : null}
diff --git a/apps/app/src/components/plugin/PluginPanelActions.tsx b/apps/app/src/components/plugin/PluginPanelActions.tsx
index f7a355744..ddea42b94 100644
--- a/apps/app/src/components/plugin/PluginPanelActions.tsx
+++ b/apps/app/src/components/plugin/PluginPanelActions.tsx
@@ -5,6 +5,10 @@ import {
type PluginThreadPanelActionSlot,
} from "@/lib/plugin-slots";
import type { PluginPanelFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
+import {
+ fileOpenerIdFromActionId,
+ parseFileOpenerParams,
+} from "./file-opener-tabs";
import { PluginSlotMount } from "./PluginSlotMount";
/**
@@ -124,6 +128,20 @@ export function PluginPanelTabContent({
}: {
tab: PluginPanelFixedPanelTab;
threadId: string | null | undefined;
+}) {
+ const openerId = fileOpenerIdFromActionId(tab.actionId);
+ if (openerId !== null) {
+ return ;
+ }
+ return ;
+}
+
+function ActionTabContent({
+ tab,
+ threadId,
+}: {
+ tab: PluginPanelFixedPanelTab;
+ threadId: string | null | undefined;
}) {
const { threadPanelActions } = usePluginSlots();
const action =
@@ -169,3 +187,53 @@ export function PluginPanelTabContent({
);
}
+
+/**
+ * A file diverted to a plugin `fileOpener` (see file-opener-tabs.ts). Same
+ * degrade rules as action tabs: missing opener/plugin or unparsable params
+ * render a placeholder, never a crash.
+ */
+function FileOpenerTabContent({
+ openerId,
+ tab,
+}: {
+ openerId: string;
+ tab: PluginPanelFixedPanelTab;
+}) {
+ const { fileOpeners } = usePluginSlots();
+ const opener =
+ fileOpeners.find(
+ (candidate) =>
+ candidate.pluginId === tab.pluginId && candidate.id === openerId,
+ ) ?? null;
+ const file = useMemo(
+ () => parseFileOpenerParams(tab.paramsJson),
+ [tab.paramsJson],
+ );
+ if (opener === null || file === null) {
+ return (
+
+
+ This file opener is not available. The plugin may still be loading,
+ or it has been disabled or removed — reopen the file to use the
+ built-in preview.
+
+
+ );
+ }
+ return (
+
+ );
+}
diff --git a/apps/app/src/components/plugin/PluginPanelHeader.tsx b/apps/app/src/components/plugin/PluginPanelHeader.tsx
index cac4e3d36..341c8dbfc 100644
--- a/apps/app/src/components/plugin/PluginPanelHeader.tsx
+++ b/apps/app/src/components/plugin/PluginPanelHeader.tsx
@@ -63,8 +63,10 @@ export function PluginPanelHeaderCenter({
*/
export function PluginPanelHeaderActions({
panel,
+ subPath,
}: {
panel: PluginNavPanelSlot;
+ subPath: string;
}) {
const HeaderContent = panel.headerContent;
if (HeaderContent === undefined || panel.chrome === "none") return null;
@@ -83,7 +85,7 @@ export function PluginPanelHeaderActions({
data-bb-plugin={panel.pluginId}
className="flex shrink-0 items-center gap-2"
>
-
+
diff --git a/apps/app/src/components/plugin/file-opener-tabs.ts b/apps/app/src/components/plugin/file-opener-tabs.ts
new file mode 100644
index 000000000..e8027528f
--- /dev/null
+++ b/apps/app/src/components/plugin/file-opener-tabs.ts
@@ -0,0 +1,195 @@
+import type { PluginFileOpenerProps, PluginFileOpenerSource } from "@bb/plugin-sdk";
+import {
+ createPluginPanelFixedPanelTab,
+ type PluginPanelFixedPanelTab,
+} from "@/lib/fixed-panel-tabs-state";
+import {
+ resolvePreferredFileOpener,
+ type FileOpenerPreferenceMap,
+} from "@/lib/file-opener-preference";
+import type { PluginFileOpenerSlot } from "@/lib/plugin-slots";
+import type { OpenSecondaryPanelTabRequest } from "@/components/secondary-panel/useThreadFileTabs";
+
+/**
+ * Plugin file-opener tabs ride the existing `plugin-panel` tab kind: the
+ * action id carries this prefix + the opener's id, and `paramsJson` persists
+ * the opened file (`PluginFileOpenerProps`). Same identity semantics as
+ * action tabs — same opener + same file focuses the existing tab.
+ */
+export const FILE_OPENER_ACTION_ID_PREFIX = "file-opener:";
+
+export function isFileOpenerPanelTab(tab: PluginPanelFixedPanelTab): boolean {
+ return tab.actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX);
+}
+
+export function fileOpenerIdFromActionId(actionId: string): string | null {
+ return actionId.startsWith(FILE_OPENER_ACTION_ID_PREFIX)
+ ? actionId.slice(FILE_OPENER_ACTION_ID_PREFIX.length)
+ : null;
+}
+
+export function buildFileOpenerPanelTab(
+ opener: Pick,
+ file: PluginFileOpenerProps,
+): PluginPanelFixedPanelTab {
+ return createPluginPanelFixedPanelTab({
+ actionId: `${FILE_OPENER_ACTION_ID_PREFIX}${opener.id}`,
+ paramsJson: JSON.stringify({ path: file.path, source: file.source }),
+ pluginId: opener.pluginId,
+ title: file.path.split("/").at(-1) ?? file.path,
+ });
+}
+
+/** Parse a persisted opener tab's params; null on any mismatch (degrade). */
+export function parseFileOpenerParams(
+ paramsJson: string | null,
+): PluginFileOpenerProps | null {
+ if (paramsJson === null) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(paramsJson);
+ } catch {
+ return null;
+ }
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const { path, source } = parsed as { path?: unknown; source?: unknown };
+ if (typeof path !== "string" || path.length === 0) return null;
+ if (typeof source !== "object" || source === null) return null;
+ const { kind, threadId, environmentId, projectId } = source as {
+ kind?: unknown;
+ threadId?: unknown;
+ environmentId?: unknown;
+ projectId?: unknown;
+ };
+ if (kind !== "workspace" && kind !== "host" && kind !== "thread-storage") {
+ return null;
+ }
+ return {
+ path,
+ source: {
+ kind,
+ threadId: typeof threadId === "string" ? threadId : null,
+ environmentId: typeof environmentId === "string" ? environmentId : null,
+ projectId: typeof projectId === "string" ? projectId : null,
+ },
+ };
+}
+
+/**
+ * A per-open viewer choice (the link context menu): "builtin" pins the
+ * built-in preview; an opener ref forces that plugin opener. Absent =
+ * follow the per-extension default.
+ */
+export type FileTabViewerOverride =
+ | "builtin"
+ | { pluginId: string; openerId: string };
+
+export interface CreateFileOpenerTabForRequestArgs {
+ fileOpeners: readonly PluginFileOpenerSlot[];
+ preference: FileOpenerPreferenceMap;
+ projectId: string | null;
+ request: OpenSecondaryPanelTabRequest;
+ resolvedEnvironmentId: string | null | undefined;
+ threadId: string | null | undefined;
+ viewer?: FileTabViewerOverride;
+}
+
+/**
+ * The plugin-opener tab a file-open request should divert to, or null for
+ * the built-in path. Diversion applies only to live file content — working
+ * tree, host, and thread-storage previews; git-ref snapshots and deleted
+ * files always use the built-in preview.
+ */
+export function createFileOpenerTabForRequest({
+ fileOpeners,
+ preference,
+ projectId,
+ request,
+ resolvedEnvironmentId,
+ threadId,
+ viewer,
+}: CreateFileOpenerTabForRequestArgs): PluginPanelFixedPanelTab | null {
+ if (viewer === "builtin") return null;
+ const file = fileForOpenRequest({
+ projectId,
+ request,
+ resolvedEnvironmentId,
+ threadId,
+ });
+ if (file === null) return null;
+ const opener =
+ viewer !== undefined
+ ? (fileOpeners.find(
+ (candidate) =>
+ candidate.pluginId === viewer.pluginId &&
+ candidate.id === viewer.openerId,
+ ) ?? null)
+ : resolvePreferredFileOpener({
+ openers: fileOpeners,
+ preference,
+ path: file.path,
+ });
+ if (opener === null) return null;
+ return buildFileOpenerPanelTab(opener, file);
+}
+
+function fileForOpenRequest({
+ projectId,
+ request,
+ resolvedEnvironmentId,
+ threadId,
+}: Omit):
+ | PluginFileOpenerProps
+ | null {
+ switch (request.kind) {
+ case "workspace-file-preview": {
+ // Same guard as the built-in path, plus live-content-only rules.
+ if (resolvedEnvironmentId === undefined) return null;
+ if (request.tab.source.kind !== "working-tree") return null;
+ if (request.tab.statusLabel === "deleted") return null;
+ return {
+ path: request.tab.path,
+ source: buildSource("workspace", {
+ environmentId: resolvedEnvironmentId,
+ projectId: resolvedEnvironmentId === null ? projectId : null,
+ threadId: threadId ?? null,
+ }),
+ };
+ }
+ case "host-file-preview": {
+ if (!threadId || !resolvedEnvironmentId) return null;
+ return {
+ path: request.tab.path,
+ source: buildSource("host", {
+ environmentId: resolvedEnvironmentId,
+ projectId: null,
+ threadId,
+ }),
+ };
+ }
+ case "thread-storage-file-preview": {
+ if (!threadId) return null;
+ return {
+ path: request.tab.path,
+ source: buildSource("thread-storage", {
+ environmentId: resolvedEnvironmentId ?? null,
+ projectId: null,
+ threadId,
+ }),
+ };
+ }
+ default:
+ return null;
+ }
+}
+
+function buildSource(
+ kind: PluginFileOpenerSource["kind"],
+ fields: {
+ environmentId: string | null;
+ projectId: string | null;
+ threadId: string | null;
+ },
+): PluginFileOpenerSource {
+ return { kind, ...fields };
+}
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 9e68d776b..debcbb6aa 100644
--- a/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
+++ b/apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
@@ -2,7 +2,7 @@
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import type {
PluginComposerAccessoryProps,
@@ -30,6 +30,9 @@ import { resetAllCrashedPluginSlotsForTest } from "./PluginSlotMount";
import { PluginComposerAccessories } from "./PluginComposerAccessories";
import { PluginHomepageSections } from "./PluginHomepageSections";
import { PluginNavSidebarItems } from "./PluginNavSidebarItems";
+import { useComposer } from "@/lib/plugin-sdk-hooks";
+import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests";
+import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
import {
PluginPanelTabContent,
usePluginPanelActions,
@@ -44,6 +47,7 @@ function registrationSet(
navPanels: [],
threadPanelActions: [],
composerAccessories: [],
+ fileOpeners: [],
...overrides,
};
}
@@ -143,6 +147,153 @@ describe("PluginComposerAccessories", () => {
});
});
+
+function ThreadDraftViewer({ threadId }: { threadId: string }) {
+ const draft = usePromptDraftStorage({
+ kind: "thread",
+ projectId: PERSONAL_PROJECT_ID,
+ threadId,
+ });
+ return (
+
+
{draft.storageKey}
+
{draft.text}
+
{JSON.stringify(draft.mentions)}
+
+ );
+}
+
+function NewThreadDraftViewer() {
+ const draft = usePromptDraftStorage({ kind: "new-thread" });
+ return (
+
+
{draft.storageKey}
+
{draft.text}
+
{JSON.stringify(draft.mentions)}
+
+ );
+}
+
+describe("useComposer", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ function registerComposerProbe(label: string) {
+ function ComposerProbe() {
+ const composer = useComposer();
+ return (
+
+
scope: {composer.scope.kind}
+
composer.addQuote("picked text")}>
+ {label}-quote
+
+
+ composer.insertMention({
+ provider: "notes",
+ id: "work/ideas.md",
+ label: "ideas.md",
+ })
+ }
+ >
+ {label}-mention
+
+
+ composer.insertMention({ provider: "bad:colon", id: "x", label: "x" })
+ }
+ >
+ {label}-bad-mention
+
+
+ );
+ }
+ setPluginSlotRegistrations(
+ "demo",
+ registrationSet({
+ composerAccessories: [{ id: "probe", component: ComposerProbe }],
+ }),
+ );
+ }
+
+ it("writes quotes into the thread draft and fires the focus bus", () => {
+ registerComposerProbe("t");
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText("scope: thread")).toBeDefined();
+
+ let focusRequests = 0;
+ const storageKey = screen.getByTestId("draft-key").textContent ?? "";
+ const unsubscribe = subscribeComposerFocusRequests(storageKey, () => {
+ focusRequests += 1;
+ });
+ fireEvent.click(screen.getByText("t-quote"));
+ expect(screen.getByTestId("draft-text").textContent).toBe(
+ "> picked text\n",
+ );
+ expect(focusRequests).toBe(1);
+ unsubscribe();
+ });
+
+ it("appends mention pills with offsets into the new-thread draft", () => {
+ registerComposerProbe("n");
+ render(
+
+
+
+ ,
+ );
+ expect(screen.getByText("scope: new-thread")).toBeDefined();
+
+ fireEvent.click(screen.getByText("n-mention"));
+ expect(screen.getByTestId("draft-text").textContent).toBe("ideas.md ");
+ const mentions = JSON.parse(
+ screen.getByTestId("draft-mentions").textContent ?? "[]",
+ ) as Array<{ start: number; end: number; resource: Record }>;
+ expect(mentions).toEqual([
+ {
+ start: 0,
+ end: 8,
+ resource: {
+ kind: "plugin",
+ pluginId: "demo",
+ itemId: "notes:work/ideas.md",
+ label: "ideas.md",
+ },
+ },
+ ]);
+
+ // A second mention lands after the first with a preserved gap.
+ fireEvent.click(screen.getByText("n-mention"));
+ expect(screen.getByTestId("draft-text").textContent).toBe(
+ "ideas.md ideas.md ",
+ );
+ });
+
+ it("rejects provider ids containing ':' without touching the draft", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ registerComposerProbe("b");
+ render(
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText("b-bad-mention"));
+ expect(screen.getByTestId("draft-text").textContent).toBe("");
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("invalid provider id"),
+ );
+ });
+});
+
describe("PluginNavSidebarItems + PluginPanelView", () => {
function Board() {
return board panel body
;
@@ -176,6 +327,44 @@ describe("PluginNavSidebarItems + PluginPanelView", () => {
expect(screen.getByText("board panel body")).toBeDefined();
});
+ it("passes the route splat to the panel as subPath ('' at the root)", () => {
+ function SubPathProbe({ subPath }: { subPath: string }) {
+ return subPath: "{subPath}"
;
+ }
+ setPluginSlotRegistrations(
+ "demo",
+ registrationSet({
+ navPanels: [
+ {
+ id: "board",
+ title: "Demo board",
+ icon: "columns",
+ path: "board",
+ component: SubPathProbe,
+ },
+ ],
+ }),
+ );
+ const { unmount } = render(
+
+
+ } />
+
+ ,
+ );
+ expect(screen.getByText('subPath: "work/ideas.md"')).toBeDefined();
+ unmount();
+
+ render(
+
+
+ } />
+
+ ,
+ );
+ expect(screen.getByText('subPath: ""')).toBeDefined();
+ });
+
it("shows a placeholder for an unknown plugin panel route", () => {
render(
@@ -269,7 +458,7 @@ describe("plugin panel chrome (shared header + body modes)", () => {
render(
<>
-
+
>,
);
expect(screen.getByText("Demo board")).toBeDefined();
@@ -289,7 +478,7 @@ describe("plugin panel chrome (shared header + body modes)", () => {
render(
<>
-
+
>,
);
// The header center survives; the accessory is hidden, not chip-ified.
@@ -305,7 +494,7 @@ describe("plugin panel chrome (shared header + body modes)", () => {
chrome: "none",
headerContent: HeaderAccessory,
});
- const { container } = render( );
+ const { container } = render( );
expect(container.innerHTML).toBe("");
});
@@ -495,3 +684,89 @@ describe("plugin thread panel actions", () => {
expect(screen.getByText(/This plugin tab is not available/)).toBeDefined();
});
});
+
+describe("plugin file opener tabs", () => {
+ function MarkdownEditorProbe({
+ path,
+ source,
+ }: {
+ path: string;
+ source: { kind: string; environmentId: string | null };
+ }) {
+ return (
+
+ editor {path} @ {source.kind}:{String(source.environmentId)}
+
+ );
+ }
+
+ it("renders the opener component with parsed path + source", () => {
+ setPluginSlotRegistrations(
+ "notes",
+ registrationSet({
+ fileOpeners: [
+ {
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md"],
+ component: MarkdownEditorProbe,
+ },
+ ],
+ }),
+ );
+ const tab = createPluginPanelFixedPanelTab({
+ actionId: "file-opener:editor",
+ paramsJson: JSON.stringify({
+ path: "notes/todo.md",
+ source: {
+ kind: "workspace",
+ threadId: null,
+ environmentId: "env_1",
+ projectId: null,
+ },
+ }),
+ pluginId: "notes",
+ title: "todo.md",
+ });
+ render( );
+ expect(
+ screen.getByText("editor notes/todo.md @ workspace:env_1"),
+ ).toBeDefined();
+ });
+
+ it("degrades to a placeholder when the opener is gone or params are junk", () => {
+ const orphanTab = createPluginPanelFixedPanelTab({
+ actionId: "file-opener:gone",
+ paramsJson: JSON.stringify({ path: "a.md", source: { kind: "workspace" } }),
+ pluginId: "ghost",
+ title: "a.md",
+ });
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByText(/file opener is not available/)).toBeDefined();
+ unmount();
+
+ setPluginSlotRegistrations(
+ "notes",
+ registrationSet({
+ fileOpeners: [
+ {
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md"],
+ component: MarkdownEditorProbe,
+ },
+ ],
+ }),
+ );
+ const junkParamsTab = createPluginPanelFixedPanelTab({
+ actionId: "file-opener:editor",
+ paramsJson: "not json",
+ pluginId: "notes",
+ title: "junk",
+ });
+ render( );
+ expect(screen.getByText(/file opener is not available/)).toBeDefined();
+ });
+});
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts
index 336c42188..558cc0e10 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts
@@ -13,6 +13,10 @@ import {
FIXED_PANEL_TABS_STATE_STORAGE_VERSION,
} from "@/lib/fixed-panel-tabs-state";
import { useThreadFileTabs } from "./useThreadFileTabs";
+import {
+ resetPluginSlotStoreForTest,
+ setPluginSlotRegistrations,
+} from "@/lib/plugin-slots";
type TerminalSessionOverrides = Partial;
@@ -41,6 +45,7 @@ function terminalSession(
afterEach(() => {
cleanup();
window.localStorage.clear();
+ resetPluginSlotStoreForTest();
});
describe("useThreadFileTabs terminal pruning", () => {
@@ -338,3 +343,196 @@ describe("useThreadFileTabs plugin panel tabs", () => {
).toEqual(["plugin-panel"]);
});
});
+
+describe("useThreadFileTabs file opener diversion", () => {
+ function NotesEditor() {
+ return null;
+ }
+
+ function registerNotesOpener() {
+ setPluginSlotRegistrations("notes", {
+ homepageSections: [],
+ navPanels: [],
+ threadPanelActions: [],
+ composerAccessories: [],
+ fileOpeners: [
+ {
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md"],
+ component: NotesEditor,
+ },
+ ],
+ });
+ }
+
+ function setDefaultOpener() {
+ window.localStorage.setItem(
+ "bb.fileOpenerByExtension",
+ JSON.stringify({ md: "notes:editor" }),
+ );
+ }
+
+ it("diverts working-tree markdown opens to the preferred opener tab", () => {
+ registerNotesOpener();
+ setDefaultOpener();
+ const { result } = renderHook(() =>
+ useThreadFileTabs({
+ threadId: "opener-divert",
+ environmentId: "env_1",
+ storageFiles: undefined,
+ terminalSessions: undefined,
+ }),
+ );
+
+ act(() =>
+ result.current.openTab({
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "notes/todo.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ }),
+ );
+
+ expect(result.current.activePluginPanelTab).toMatchObject({
+ kind: "plugin-panel",
+ pluginId: "notes",
+ actionId: "file-opener:editor",
+ title: "todo.md",
+ });
+ const params = JSON.parse(
+ result.current.activePluginPanelTab?.paramsJson ?? "null",
+ ) as { path: string; source: { kind: string; environmentId: string | null } };
+ expect(params.path).toBe("notes/todo.md");
+ expect(params.source).toMatchObject({
+ kind: "workspace",
+ environmentId: "env_1",
+ });
+ });
+
+ it("keeps the built-in preview for ref snapshots and unmatched extensions", () => {
+ registerNotesOpener();
+ setDefaultOpener();
+ const { result } = renderHook(() =>
+ useThreadFileTabs({
+ threadId: "opener-skip",
+ environmentId: "env_1",
+ storageFiles: undefined,
+ terminalSessions: undefined,
+ }),
+ );
+
+ // A git-ref snapshot never diverts, even for a matching extension.
+ act(() =>
+ result.current.openTab({
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "notes/todo.md",
+ source: { kind: "head" },
+ statusLabel: null,
+ },
+ }),
+ );
+ expect(result.current.activePluginPanelTab).toBeNull();
+ expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md");
+
+ // Unmatched extension stays built-in too.
+ act(() =>
+ result.current.openTab({
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "src/index.ts",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ }),
+ );
+ expect(result.current.activePluginPanelTab).toBeNull();
+ expect(result.current.activeWorkspaceFilePath).toBe("src/index.ts");
+ });
+
+ it("falls back to the built-in preview when the preferred opener is gone", () => {
+ // Preference points at an opener that is not registered (plugin removed).
+ setDefaultOpener();
+ const { result } = renderHook(() =>
+ useThreadFileTabs({
+ threadId: "opener-gone",
+ environmentId: "env_1",
+ storageFiles: undefined,
+ terminalSessions: undefined,
+ }),
+ );
+
+ act(() =>
+ result.current.openTab({
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "notes/todo.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ }),
+ );
+ expect(result.current.activePluginPanelTab).toBeNull();
+ expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md");
+ });
+
+ it("honors per-open viewer overrides in both directions", () => {
+ registerNotesOpener();
+ setDefaultOpener();
+ const { result } = renderHook(() =>
+ useThreadFileTabs({
+ threadId: "opener-override",
+ environmentId: "env_1",
+ storageFiles: undefined,
+ terminalSessions: undefined,
+ }),
+ );
+
+ // "builtin" override skips the opener default entirely.
+ act(() =>
+ result.current.openTab(
+ {
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "notes/todo.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ },
+ { viewer: "builtin" },
+ ),
+ );
+ expect(result.current.activePluginPanelTab).toBeNull();
+ expect(result.current.activeWorkspaceFilePath).toBe("notes/todo.md");
+
+ // A forced opener applies even with no default preference set.
+ window.localStorage.removeItem("bb.fileOpenerByExtension");
+ act(() =>
+ result.current.openTab(
+ {
+ kind: "workspace-file-preview",
+ tab: {
+ lineRange: null,
+ path: "notes/other.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ },
+ { viewer: { pluginId: "notes", openerId: "editor" } },
+ ),
+ );
+ expect(result.current.activePluginPanelTab).toMatchObject({
+ pluginId: "notes",
+ actionId: "file-opener:editor",
+ title: "other.md",
+ });
+ });
+});
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
index c7256380d..3c9e6f55b 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -20,6 +20,12 @@ import {
type ThreadStorageFilePreviewFixedPanelTab,
type WorkspaceFilePreviewFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
+import { usePluginSlots } from "@/lib/plugin-slots";
+import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference";
+import {
+ createFileOpenerTabForRequest,
+ type FileTabViewerOverride,
+} from "@/components/plugin/file-opener-tabs";
import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions";
import type {
HostFileTabState,
@@ -405,14 +411,36 @@ export function useThreadFileTabs({
updateFixedPanelTabsState,
]);
+ const { fileOpeners } = usePluginSlots();
+ const fileOpenerPreference = useFileOpenerPreferenceValue();
+
const openTab = useCallback(
- (request: OpenSecondaryPanelTabRequest) => {
- const tab = createTabForOpenRequest({
+ (
+ request: OpenSecondaryPanelTabRequest,
+ options?: { viewer?: FileTabViewerOverride },
+ ) => {
+ // Default-opener diversion (plugin design §5.2): every file-open flow
+ // funnels through here (links, file search, `bb thread open`), so a
+ // preferred plugin opener applies uniformly. Falls through to the
+ // built-in tab when no opener matches; a link menu's per-open viewer
+ // choice overrides the default in either direction.
+ const openerTab = createFileOpenerTabForRequest({
+ fileOpeners,
+ preference: fileOpenerPreference,
projectId,
request,
resolvedEnvironmentId,
threadId: resolvedFileOwnerThreadId,
+ ...(options?.viewer !== undefined ? { viewer: options.viewer } : {}),
});
+ const tab =
+ openerTab ??
+ createTabForOpenRequest({
+ projectId,
+ request,
+ resolvedEnvironmentId,
+ threadId: resolvedFileOwnerThreadId,
+ });
if (tab === null) return;
if (
@@ -433,6 +461,8 @@ export function useThreadFileTabs({
});
},
[
+ fileOpenerPreference,
+ fileOpeners,
recordRecentItem,
projectId,
resolvedEnvironmentId,
diff --git a/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx b/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx
new file mode 100644
index 000000000..b4f3fb22f
--- /dev/null
+++ b/apps/app/src/components/settings/FileOpenersSettingsSection.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ resetPluginSlotStoreForTest,
+ setPluginSlotRegistrations,
+} from "@/lib/plugin-slots";
+import { FileOpenersSettingsSection } from "./FileOpenersSettingsSection";
+
+function NotesEditor() {
+ return null;
+}
+
+function registerNotesOpener() {
+ setPluginSlotRegistrations("notes", {
+ homepageSections: [],
+ navPanels: [],
+ threadPanelActions: [],
+ composerAccessories: [],
+ fileOpeners: [
+ {
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md", "mdx"],
+ component: NotesEditor,
+ },
+ ],
+ });
+}
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+ resetPluginSlotStoreForTest();
+});
+
+describe("FileOpenersSettingsSection", () => {
+ it("renders nothing while no plugin openers are registered", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("lists one row per extension and persists a picked default", async () => {
+ registerNotesOpener();
+ render( );
+
+ expect(screen.getByText(".md files")).toBeDefined();
+ expect(screen.getByText(".mdx files")).toBeDefined();
+
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Default opener for .md files" }),
+ { button: 0 },
+ );
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: /Notes editor/ }),
+ );
+
+ expect(
+ JSON.parse(window.localStorage.getItem("bb.fileOpenerByExtension") ?? "{}"),
+ ).toEqual({ md: "notes:editor" });
+
+ // Switching back to the built-in preview clears the entry.
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Default opener for .md files" }),
+ { button: 0 },
+ );
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: /Built-in preview/ }),
+ );
+ expect(
+ JSON.parse(window.localStorage.getItem("bb.fileOpenerByExtension") ?? "{}"),
+ ).toEqual({});
+ });
+});
diff --git a/apps/app/src/components/settings/FileOpenersSettingsSection.tsx b/apps/app/src/components/settings/FileOpenersSettingsSection.tsx
new file mode 100644
index 000000000..9fa7c1469
--- /dev/null
+++ b/apps/app/src/components/settings/FileOpenersSettingsSection.tsx
@@ -0,0 +1,153 @@
+import { useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Icon } from "@/components/ui/icon";
+import {
+ SettingsSection,
+ SettingsWithControl,
+} from "@/components/ui/settings-section";
+import { COARSE_POINTER_ICON_SIZE_CLASS } from "@/components/ui/coarse-pointer-sizing";
+import {
+ buildFileOpenerRef,
+ resolvePreferredFileOpener,
+ useFileOpenerPreference,
+} from "@/lib/file-opener-preference";
+import { usePluginSlots, type PluginFileOpenerSlot } from "@/lib/plugin-slots";
+import { cn } from "@/lib/utils";
+
+const DROPDOWN_TRIGGER_CLASS =
+ "h-7 w-full justify-between border-border/60 bg-card px-2 text-xs sm:w-44";
+const DROPDOWN_CONTENT_CLASS =
+ "min-w-[var(--radix-dropdown-menu-trigger-width)]";
+
+/**
+ * Default viewer per file extension (Settings → File openers). One row per
+ * extension any installed plugin registers a `fileOpener` for: built-in
+ * preview (the default) or a plugin opener. Hidden entirely while no plugin
+ * openers are installed — the built-in preview needs no configuration.
+ */
+export function FileOpenersSettingsSection() {
+ const { fileOpeners } = usePluginSlots();
+ const [preference, setPreference] = useFileOpenerPreference();
+
+ const extensions = useMemo(
+ () =>
+ [...new Set(fileOpeners.flatMap((opener) => opener.extensions))].sort(),
+ [fileOpeners],
+ );
+
+ if (extensions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {extensions.map((extension) => (
+
+ opener.extensions.includes(extension),
+ )}
+ selected={resolvePreferredFileOpener({
+ openers: fileOpeners,
+ preference,
+ path: `file.${extension}`,
+ })}
+ onSelect={(opener) =>
+ setPreference((previous) => {
+ const next = { ...previous };
+ if (opener === null) {
+ delete next[extension];
+ } else {
+ next[extension] = buildFileOpenerRef(opener);
+ }
+ return next;
+ })
+ }
+ />
+ ))}
+
+
+ );
+}
+
+const BUILTIN_LABEL = "Built-in preview";
+
+function ExtensionOpenerControl({
+ extension,
+ onSelect,
+ openers,
+ selected,
+}: {
+ extension: string;
+ onSelect: (opener: PluginFileOpenerSlot | null) => void;
+ openers: PluginFileOpenerSlot[];
+ selected: PluginFileOpenerSlot | null;
+}) {
+ const selectedLabel =
+ selected === null ? BUILTIN_LABEL : `${selected.title} (${selected.pluginId})`;
+ return (
+
+
+
+
+ {selectedLabel}
+
+
+
+
+ onSelect(null)}>
+ {BUILTIN_LABEL}
+
+
+ {openers.map((opener) => (
+ onSelect(opener)}
+ >
+
+ {opener.title} ({opener.pluginId})
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/components/ui/markdown-link-routing.ts b/apps/app/src/components/ui/markdown-link-routing.ts
index e5d783071..03eb8ba54 100644
--- a/apps/app/src/components/ui/markdown-link-routing.ts
+++ b/apps/app/src/components/ui/markdown-link-routing.ts
@@ -1,10 +1,36 @@
+import { createContext } from "react";
import type { MarkdownPreviewLinkHandler } from "./markdown-link.js";
import type {
MarkdownAbsoluteLocalFileLinkRouting,
+ MarkdownPreviewLocalFileLink,
MarkdownPreviewLocalFileLinkHandler,
MarkdownRelativeLocalFileLinkRouting,
} from "./markdown-local-file-link.js";
+/** One entry in a local file link's right-click "Open with" menu. */
+export interface MarkdownLocalFileOpenWithItem {
+ id: string;
+ label: string;
+ onSelect: () => void;
+}
+
+/**
+ * Viewer choices for the right-click menu on local file links (e.g. "Open
+ * with built-in preview" / plugin file openers). Null/empty = no menu;
+ * left-click behavior is unchanged either way.
+ */
+export type MarkdownLocalFileOpenWithItemsProvider = (
+ link: MarkdownPreviewLocalFileLink,
+) => MarkdownLocalFileOpenWithItem[] | null;
+
+/**
+ * Context, not a routing field: local file links render across the thread
+ * timeline and every file-preview surface, whose link routings are built in
+ * many places — one provider at the view root covers them all uniformly.
+ */
+export const MarkdownLocalFileOpenWithContext =
+ createContext(null);
+
export interface MarkdownLocalFileLinkRouting {
absoluteLinks: MarkdownAbsoluteLocalFileLinkRouting;
onOpenLink: MarkdownPreviewLocalFileLinkHandler;
diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx
index dd4afc4c5..ff2b8a557 100644
--- a/apps/app/src/components/ui/markdown-preview.test.tsx
+++ b/apps/app/src/components/ui/markdown-preview.test.tsx
@@ -3,7 +3,10 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MarkdownPreview } from "./markdown-preview";
-import type { MarkdownLinkRouting } from "./markdown-link-routing";
+import {
+ MarkdownLocalFileOpenWithContext,
+ type MarkdownLinkRouting,
+} from "./markdown-link-routing";
const workspaceLinkRouting = {
localFile: {
@@ -74,6 +77,51 @@ describe("MarkdownPreview", () => {
expect(screen.getByText("src/app.ts").tagName).toBe("CODE");
});
+ it("shows an Open with menu on local file links when the context provides items", () => {
+ const openBuiltin = vi.fn();
+ const openWithPlugin = vi.fn();
+ render(
+
+ link.path.endsWith(".md")
+ ? [
+ {
+ id: "builtin",
+ label: "Open with built-in preview",
+ onSelect: openBuiltin,
+ },
+ {
+ id: "notes:editor",
+ label: "Open with Notes editor",
+ onSelect: openWithPlugin,
+ },
+ ]
+ : null
+ }
+ >
+ true),
+ },
+ }}
+ />
+ ,
+ );
+
+ const link = screen.getByRole("link", { name: /notes/ });
+ fireEvent.contextMenu(link);
+ fireEvent.click(screen.getByText("Open with Notes editor"));
+ expect(openWithPlugin).toHaveBeenCalledTimes(1);
+ expect(openBuiltin).not.toHaveBeenCalled();
+
+ // The provider returned null for the .ts link — plain anchor, no menu.
+ fireEvent.contextMenu(screen.getByRole("link", { name: /app/ }));
+ expect(screen.queryByText(/Open with/)).toBeNull();
+ });
+
it("leaves inline-code Markdown paths as code without local file routing", () => {
render( );
diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx
index 059ced74c..f988c4c72 100644
--- a/apps/app/src/components/ui/markdown-preview.tsx
+++ b/apps/app/src/components/ui/markdown-preview.tsx
@@ -1,6 +1,7 @@
import {
memo,
useLayoutEffect,
+ useContext,
useMemo,
useRef,
useState,
@@ -10,6 +11,12 @@ import {
type SetStateAction,
} from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
import type {
Components,
ExtraProps,
@@ -41,9 +48,10 @@ import {
type MarkdownAbsoluteLocalFileLinkRouting,
type MarkdownRelativeLocalFileLinkRouting,
} from "./markdown-local-file-link.js";
-import type {
- MarkdownLinkRouting,
- MarkdownLocalFileLinkRouting,
+import {
+ MarkdownLocalFileOpenWithContext,
+ type MarkdownLinkRouting,
+ type MarkdownLocalFileLinkRouting,
} from "./markdown-link-routing.js";
import {
buildThreadMentionComponent,
@@ -473,6 +481,11 @@ function MarkdownAnchor({
})
: null;
const anchorHref = buildLocalFileAnchorHref(localFileLink, rewrittenHref);
+ const getOpenWithItems = useContext(MarkdownLocalFileOpenWithContext);
+ const openWithItems =
+ localFileLink !== null && getOpenWithItems !== null
+ ? getOpenWithItems(localFileLink)
+ : null;
const handleAnchorClick = (event: MarkdownAnchorEvent) => {
if (localFileLink && onOpenLocalFileLink) {
if (onOpenLocalFileLink(localFileLink)) {
@@ -498,7 +511,7 @@ function MarkdownAnchor({
}
};
- return (
+ const anchor = (
);
+ if (openWithItems === null || openWithItems.length === 0) {
+ return anchor;
+ }
+ // Local file links with viewer choices get a right-click "Open with" menu
+ // (per-open override of the extension's default opener).
+ return (
+
+ {anchor}
+
+ {openWithItems.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ );
}
function MarkdownCode({
diff --git a/apps/app/src/lib/composer-focus-requests.ts b/apps/app/src/lib/composer-focus-requests.ts
new file mode 100644
index 000000000..61734ff70
--- /dev/null
+++ b/apps/app/src/lib/composer-focus-requests.ts
@@ -0,0 +1,40 @@
+/**
+ * Focus-request bus between plugin composer writes and the composer mounts.
+ * The draft store (usePromptDraftStorage) is module-level and shared, but
+ * "focus the caret" is per-view state (ThreadDetailView's focus nonce, the
+ * root compose prompt box ref) — this bus carries the request across that
+ * gap, keyed by the same draft storage key both sides already share.
+ */
+type ComposerFocusListener = () => void;
+
+const listenersByStorageKey = new Map>();
+
+export function subscribeComposerFocusRequests(
+ storageKey: string | null,
+ listener: ComposerFocusListener,
+): () => void {
+ // Null = the draft scope is incomplete (matching the draft store's own
+ // null storage keys); there is nothing to focus.
+ if (storageKey === null) return () => {};
+ let listeners = listenersByStorageKey.get(storageKey);
+ if (!listeners) {
+ listeners = new Set();
+ listenersByStorageKey.set(storageKey, listeners);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ listenersByStorageKey.delete(storageKey);
+ }
+ };
+}
+
+export function requestComposerFocus(storageKey: string | null): void {
+ if (storageKey === null) return;
+ const listeners = listenersByStorageKey.get(storageKey);
+ if (!listeners) return;
+ for (const listener of [...listeners]) {
+ listener();
+ }
+}
diff --git a/apps/app/src/lib/file-opener-preference.ts b/apps/app/src/lib/file-opener-preference.ts
new file mode 100644
index 000000000..bd76fff41
--- /dev/null
+++ b/apps/app/src/lib/file-opener-preference.ts
@@ -0,0 +1,77 @@
+import { atomWithStorage } from "jotai/utils";
+import { useAtom, useAtomValue } from "jotai";
+import type { PluginFileOpenerSlot } from "./plugin-slots";
+import { createJsonLocalStorage } from "./browser-storage";
+
+/**
+ * Default file opener per extension: `"" → ":"`.
+ * Extensions absent from the map (or pointing at an opener that is no longer
+ * registered) fall back to the built-in preview — a removed plugin can never
+ * dead-end file opening. Stored client-side like the other view preferences
+ * (see workspace-open-target-preference.ts).
+ */
+export type FileOpenerPreferenceMap = Record;
+
+const FILE_OPENER_PREFERENCE_STORAGE_KEY = "bb.fileOpenerByExtension";
+
+const fileOpenerPreferenceAtom = atomWithStorage(
+ FILE_OPENER_PREFERENCE_STORAGE_KEY,
+ {},
+ createJsonLocalStorage(),
+ { getOnInit: true },
+);
+
+export function useFileOpenerPreference() {
+ return useAtom(fileOpenerPreferenceAtom);
+}
+
+export function useFileOpenerPreferenceValue(): FileOpenerPreferenceMap {
+ return useAtomValue(fileOpenerPreferenceAtom);
+}
+
+/** Lowercased extension without the dot; null when the name has none. */
+export function getFileExtension(path: string): string | null {
+ const name = path.split("/").at(-1) ?? path;
+ const dotIndex = name.lastIndexOf(".");
+ if (dotIndex <= 0 || dotIndex === name.length - 1) return null;
+ return name.slice(dotIndex + 1).toLowerCase();
+}
+
+export function buildFileOpenerRef(opener: {
+ pluginId: string;
+ id: string;
+}): string {
+ return `${opener.pluginId}:${opener.id}`;
+}
+
+export function findFileOpenersForPath(
+ openers: readonly PluginFileOpenerSlot[],
+ path: string,
+): PluginFileOpenerSlot[] {
+ const extension = getFileExtension(path);
+ if (extension === null) return [];
+ return openers.filter((opener) => opener.extensions.includes(extension));
+}
+
+/**
+ * The opener the given path should open with, or null for the built-in
+ * preview (no preference, an unknown extension, or a preferred opener that
+ * is no longer registered).
+ */
+export function resolvePreferredFileOpener(args: {
+ openers: readonly PluginFileOpenerSlot[];
+ preference: FileOpenerPreferenceMap;
+ path: string;
+}): PluginFileOpenerSlot | null {
+ const extension = getFileExtension(args.path);
+ if (extension === null) return null;
+ const preferredRef = args.preference[extension];
+ if (preferredRef === undefined) return null;
+ return (
+ args.openers.find(
+ (opener) =>
+ buildFileOpenerRef(opener) === preferredRef &&
+ opener.extensions.includes(extension),
+ ) ?? null
+ );
+}
diff --git a/apps/app/src/lib/plugin-app-definition.test.ts b/apps/app/src/lib/plugin-app-definition.test.ts
index d33bc6898..712579711 100644
--- a/apps/app/src/lib/plugin-app-definition.test.ts
+++ b/apps/app/src/lib/plugin-app-definition.test.ts
@@ -52,6 +52,12 @@ describe("collectPluginAppRegistrations", () => {
run,
});
app.slots.composerAccessory({ id: "picker", component: Component });
+ app.slots.fileOpener({
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md", "mdx"],
+ component: Component,
+ });
});
const registrations = collectPluginAppRegistrations(definition);
@@ -75,6 +81,14 @@ describe("collectPluginAppRegistrations", () => {
expect(registrations.composerAccessories).toEqual([
{ id: "picker", component: Component },
]);
+ expect(registrations.fileOpeners).toEqual([
+ {
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md", "mdx"],
+ component: Component,
+ },
+ ]);
});
it.each([
@@ -113,6 +127,32 @@ describe("collectPluginAppRegistrations", () => {
}),
/"path" must match/,
],
+ [
+ "file opener with no extensions",
+ () =>
+ definePluginApp((app) => {
+ app.slots.fileOpener({
+ id: "x",
+ title: "X",
+ extensions: [],
+ component: Component,
+ });
+ }),
+ /"extensions" must be a non-empty array/,
+ ],
+ [
+ "file opener with a dotted extension",
+ () =>
+ definePluginApp((app) => {
+ app.slots.fileOpener({
+ id: "x",
+ title: "X",
+ extensions: [".md"],
+ component: Component,
+ });
+ }),
+ /extensions must be lowercase alphanumerics/,
+ ],
[
"thread panel action with a non-function run",
() =>
diff --git a/apps/app/src/lib/plugin-app-definition.ts b/apps/app/src/lib/plugin-app-definition.ts
index a2ce5d20c..92dbffa2c 100644
--- a/apps/app/src/lib/plugin-app-definition.ts
+++ b/apps/app/src/lib/plugin-app-definition.ts
@@ -3,6 +3,7 @@ import {
type PluginAppDefinition,
type PluginAppSetup,
type PluginComposerAccessoryRegistration,
+ type PluginFileOpenerRegistration,
type PluginHomepageSectionRegistration,
type PluginNavPanelRegistration,
type PluginThreadPanelActionRegistration,
@@ -84,11 +85,13 @@ export function collectPluginAppRegistrations(
const navPanels: PluginNavPanelRegistration[] = [];
const threadPanelActions: PluginThreadPanelActionRegistration[] = [];
const composerAccessories: PluginComposerAccessoryRegistration[] = [];
+ const fileOpeners: PluginFileOpenerRegistration[] = [];
const seenIds = {
homepageSection: new Set(),
navPanel: new Set(),
threadPanelAction: new Set(),
composerAccessory: new Set(),
+ fileOpener: new Set(),
};
definition.setup({
@@ -171,6 +174,34 @@ export function collectPluginAppRegistrations(
component: requireComponent(kind, registration.component),
});
},
+ fileOpener(registration) {
+ const kind = "slots.fileOpener";
+ const id = requireSlotId(kind, registration?.id);
+ requireUniqueId(kind, seenIds.fileOpener, id);
+ const rawExtensions = registration?.extensions;
+ if (!Array.isArray(rawExtensions) || rawExtensions.length === 0) {
+ throw new Error(
+ `${kind}: "extensions" must be a non-empty array of lowercase extensions without the dot`,
+ );
+ }
+ const extensions = rawExtensions.map((extension) => {
+ if (
+ typeof extension !== "string" ||
+ !/^[a-z0-9]+$/.test(extension)
+ ) {
+ throw new Error(
+ `${kind}: extensions must be lowercase alphanumerics without the dot, got ${JSON.stringify(extension)}`,
+ );
+ }
+ return extension;
+ });
+ fileOpeners.push({
+ id,
+ title: requireNonEmptyString(kind, "title", registration.title),
+ extensions,
+ component: requireComponent(kind, registration.component),
+ });
+ },
},
});
@@ -179,6 +210,7 @@ export function collectPluginAppRegistrations(
navPanels,
threadPanelActions,
composerAccessories,
+ fileOpeners,
};
}
diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx
index 0bf2b2209..e05177b15 100644
--- a/apps/app/src/lib/plugin-sdk-app-impl.tsx
+++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx
@@ -3,6 +3,7 @@ import { definePluginApp } from "./plugin-app-definition";
import {
useBbContext,
useBbNavigate,
+ useComposer,
useRealtime,
useRpc,
useSettings,
@@ -27,6 +28,7 @@ export const pluginSdkAppImplementation = {
definePluginApp,
useBbContext,
useBbNavigate,
+ useComposer,
useRealtime,
useRpc,
useSettings,
diff --git a/apps/app/src/lib/plugin-sdk-hooks.ts b/apps/app/src/lib/plugin-sdk-hooks.ts
index ed1998cf2..8c2c187cc 100644
--- a/apps/app/src/lib/plugin-sdk-hooks.ts
+++ b/apps/app/src/lib/plugin-sdk-hooks.ts
@@ -4,11 +4,18 @@ import { useNavigate } from "react-router-dom";
import type {
BbContext,
BbNavigate,
+ PluginComposerApi,
+ PluginComposerMention,
PluginRpcClient,
PluginSettingsState,
} from "@bb/plugin-sdk";
import { usePluginId } from "@/components/plugin/plugin-context";
import { getThread } from "@/lib/api";
+import { requestComposerFocus } from "@/lib/composer-focus-requests";
+import {
+ usePromptDraftStorage,
+ type PromptDraftScope,
+} from "@/hooks/usePromptDraftStorage";
import {
getPluginPanelRoutePath,
getProjectComposeRoutePath,
@@ -178,8 +185,17 @@ export function useBbNavigate(): BbNavigate {
[navigate],
);
const toPluginPanel = useCallback(
- (path: string) => {
- void navigate(getPluginPanelRoutePath({ pluginId, path }));
+ (path: string, options?: { subPath?: string; replace?: boolean }) => {
+ void navigate(
+ getPluginPanelRoutePath({
+ pluginId,
+ path,
+ ...(options?.subPath !== undefined
+ ? { subPath: options.subPath }
+ : {}),
+ }),
+ options?.replace ? { replace: true } : undefined,
+ );
},
[navigate, pluginId],
);
@@ -188,3 +204,91 @@ export function useBbNavigate(): BbNavigate {
[toThread, toProject, toPluginPanel],
);
}
+
+/**
+ * Programmatic composer-draft access (plugin design §5.2): the same shared
+ * localStorage-backed draft store the built-in "Add to chat" affordances
+ * write to. Thread context → that thread's draft; anywhere else → the
+ * new-thread draft. Focus requests ride the composer-focus bus, which the
+ * composer hosts (ThreadDetailView / RootComposeView) subscribe to by
+ * draft storage key.
+ */
+export function useComposer(): PluginComposerApi {
+ const pluginId = usePluginId();
+ const { projectId, threadId } = useRouteState();
+ const scope: PromptDraftScope = useMemo(
+ () =>
+ threadId !== undefined && projectId !== undefined
+ ? { kind: "thread", projectId, threadId }
+ : { kind: "new-thread" },
+ [projectId, threadId],
+ );
+ const draft = usePromptDraftStorage(scope);
+ const { addQuote: addDraftQuote, getCurrent, setDraft, storageKey } = draft;
+
+ const addQuote = useCallback(
+ (text: string) => {
+ addDraftQuote(text);
+ requestComposerFocus(storageKey);
+ },
+ [addDraftQuote, storageKey],
+ );
+
+ const insertMention = useCallback(
+ (mention: PluginComposerMention) => {
+ const provider = mention.provider.trim();
+ const label = mention.label.trim() || mention.id;
+ if (provider.length === 0 || provider.includes(":")) {
+ // Provider ids exclude ":" (enforced at registration) — a bad id
+ // would corrupt the composite itemId the server splits at send.
+ console.warn(
+ `[plugin:${pluginId}] useComposer().insertMention: invalid provider id "${mention.provider}"`,
+ );
+ return;
+ }
+ const current = getCurrent();
+ // Append at the END so existing mention offsets stay valid (the same
+ // invariant addQuote relies on).
+ const separator =
+ current.text.length === 0 || /\s$/u.test(current.text) ? "" : " ";
+ const start = current.text.length + separator.length;
+ const end = start + label.length;
+ setDraft({
+ ...current,
+ text: `${current.text}${separator}${label} `,
+ mentions: [
+ ...current.mentions,
+ {
+ start,
+ end,
+ resource: {
+ kind: "plugin",
+ pluginId,
+ itemId: `${provider}:${mention.id}`,
+ label,
+ },
+ },
+ ],
+ });
+ requestComposerFocus(storageKey);
+ },
+ [getCurrent, pluginId, setDraft, storageKey],
+ );
+
+ const focus = useCallback(() => {
+ requestComposerFocus(storageKey);
+ }, [storageKey]);
+
+ return useMemo(
+ () => ({
+ scope:
+ threadId !== undefined
+ ? { kind: "thread", threadId }
+ : { kind: "new-thread", projectId: projectId ?? null },
+ addQuote,
+ insertMention,
+ focus,
+ }),
+ [addQuote, focus, insertMention, projectId, threadId],
+ );
+}
diff --git a/apps/app/src/lib/plugin-slots.test.ts b/apps/app/src/lib/plugin-slots.test.ts
index c38b0003b..1e55d3bb8 100644
--- a/apps/app/src/lib/plugin-slots.test.ts
+++ b/apps/app/src/lib/plugin-slots.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import type { PluginHomepageSectionProps } from "@bb/plugin-sdk";
+import type { PluginNavPanelProps, PluginHomepageSectionProps } from "@bb/plugin-sdk";
import {
getPluginSlotSnapshot,
removePluginSlotRegistrations,
@@ -12,6 +12,9 @@ import {
function SectionComponent(_props: Partial) {
return null;
}
+function PanelComponent(_props: PluginNavPanelProps) {
+ return null;
+}
function registrationSet(
overrides: Partial = {},
@@ -21,6 +24,7 @@ function registrationSet(
navPanels: [],
threadPanelActions: [],
composerAccessories: [],
+ fileOpeners: [],
...overrides,
};
}
@@ -98,7 +102,7 @@ describe("plugin slot store", () => {
title: "Board",
icon: "columns",
path: "board",
- component: SectionComponent,
+ component: PanelComponent,
},
],
}),
diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts
index a5b5ba708..3b3fb8433 100644
--- a/apps/app/src/lib/plugin-slots.ts
+++ b/apps/app/src/lib/plugin-slots.ts
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from "react";
import type {
PluginComposerAccessoryRegistration,
+ PluginFileOpenerRegistration,
PluginHomepageSectionRegistration,
PluginNavPanelRegistration,
PluginThreadPanelActionRegistration,
@@ -19,6 +20,7 @@ export interface PluginRegistrationSet {
navPanels: readonly PluginNavPanelRegistration[];
threadPanelActions: readonly PluginThreadPanelActionRegistration[];
composerAccessories: readonly PluginComposerAccessoryRegistration[];
+ fileOpeners: readonly PluginFileOpenerRegistration[];
}
interface PluginSlotBase {
@@ -40,6 +42,8 @@ export interface PluginThreadPanelActionSlot
extends PluginThreadPanelActionRegistration, PluginSlotBase {}
export interface PluginComposerAccessorySlot
extends PluginComposerAccessoryRegistration, PluginSlotBase {}
+export interface PluginFileOpenerSlot
+ extends PluginFileOpenerRegistration, PluginSlotBase {}
/** Flattened view across plugins, ordered by plugin id (deterministic). */
export interface PluginSlotSnapshot {
@@ -47,6 +51,7 @@ export interface PluginSlotSnapshot {
navPanels: readonly PluginNavPanelSlot[];
threadPanelActions: readonly PluginThreadPanelActionSlot[];
composerAccessories: readonly PluginComposerAccessorySlot[];
+ fileOpeners: readonly PluginFileOpenerSlot[];
}
export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
@@ -54,6 +59,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
navPanels: [],
threadPanelActions: [],
composerAccessories: [],
+ fileOpeners: [],
};
const registrationsByPluginId = new Map();
@@ -68,11 +74,13 @@ function buildSnapshot(): PluginSlotSnapshot {
navPanels: PluginNavPanelSlot[];
threadPanelActions: PluginThreadPanelActionSlot[];
composerAccessories: PluginComposerAccessorySlot[];
+ fileOpeners: PluginFileOpenerSlot[];
} = {
homepageSections: [],
navPanels: [],
threadPanelActions: [],
composerAccessories: [],
+ fileOpeners: [],
};
for (const pluginId of pluginIds) {
const set = registrationsByPluginId.get(pluginId);
@@ -90,6 +98,9 @@ function buildSnapshot(): PluginSlotSnapshot {
for (const registration of set.composerAccessories) {
next.composerAccessories.push({ ...registration, pluginId, generation });
}
+ for (const registration of set.fileOpeners) {
+ next.fileOpeners.push({ ...registration, pluginId, generation });
+ }
}
return next;
}
diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts
index afe996dcc..5c9fcd930 100644
--- a/apps/app/src/lib/route-paths.ts
+++ b/apps/app/src/lib/route-paths.ts
@@ -26,7 +26,8 @@ export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings";
export const PROJECT_ARCHIVED_ROUTE_PATH = "/projects/:projectId/archived";
export const THREAD_DETAIL_ROUTE_PATH =
"/projects/:projectId/threads/:threadId";
-export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath";
+// Trailing splat: the remainder is the panel's `subPath` (empty at the root).
+export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*";
export interface ThreadRoutePathArgs {
projectId: string;
@@ -129,13 +130,25 @@ export interface PluginPanelRoutePathArgs {
pluginId: string;
/** The nav panel's registered `path` segment (validated: [a-zA-Z0-9_-]+). */
path: string;
+ /** Location inside the panel; segments are encoded, slashes preserved. */
+ subPath?: string;
}
export function getPluginPanelRoutePath({
pluginId,
path,
+ subPath,
}: PluginPanelRoutePathArgs): string {
- return `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`;
+ const root = `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`;
+ if (subPath === undefined || subPath === "") {
+ return root;
+ }
+ const encoded = subPath
+ .split("/")
+ .filter((segment) => segment.length > 0)
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+ return encoded.length > 0 ? `${root}/${encoded}` : root;
}
export function getThreadRoutePath(args: ThreadRoutePathArgs): string {
diff --git a/apps/app/src/views/PluginPanelView.tsx b/apps/app/src/views/PluginPanelView.tsx
index 224791835..7ffff2900 100644
--- a/apps/app/src/views/PluginPanelView.tsx
+++ b/apps/app/src/views/PluginPanelView.tsx
@@ -36,10 +36,14 @@ const HIGHLIGHTER_OPTIONS = {};
* padding — with only the error boundary remaining.
*/
export function PluginPanelView() {
- const { pluginId, panelPath } = useParams<{
+ const params = useParams<{
pluginId: string;
panelPath: string;
+ "*": string;
}>();
+ const { pluginId, panelPath } = params;
+ // The route's trailing splat: panel-internal location ("" at the root).
+ const subPath = params["*"] ?? "";
const { navPanels } = usePluginSlots();
const panel =
navPanels.find(
@@ -67,7 +71,7 @@ export function PluginPanelView() {
slotKind="navPanel"
slotId={panel.id}
>
-
+
);
// The provider spawns workers eagerly; environments without Worker
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index db7c804a3..cf80827b5 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -95,6 +95,7 @@ import { useHostDaemon } from "@/hooks/useHostDaemon";
import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets";
import { useHosts } from "@/hooks/queries/host-queries";
import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
+import { subscribeComposerFocusRequests } from "@/lib/composer-focus-requests";
import { useEscapeToHide } from "@/hooks/useEscapeToHide";
import { usePromptMentions } from "@/hooks/usePromptMentions";
import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link";
@@ -927,6 +928,18 @@ export function RootComposeView(props: RootComposeViewProps) {
const primaryHostId = primaryHost?.id ?? null;
const uploadPromptAttachment = useUploadPromptAttachment();
const promptDraft = usePromptDraftStorage({ kind: "new-thread" });
+ // Plugin useComposer() writes (from nav panels / homepage sections) target
+ // the new-thread draft; surface + focus the composer when they ask.
+ useEffect(
+ () =>
+ subscribeComposerFocusRequests(promptDraft.storageKey, () => {
+ setStartedComposing(true);
+ window.requestAnimationFrame(() => {
+ promptBoxRef.current?.focusEnd();
+ });
+ }),
+ [promptDraft.storageKey],
+ );
const handleRootPanelSelectionAddToChat = useCallback(
(text: string, attachments?: readonly PromptDraftAttachment[]) => {
promptDraft.addQuote(text, attachments);
@@ -2035,6 +2048,7 @@ export function RootComposeView(props: RootComposeViewProps) {
storageFiles: rootThreadStorageFiles?.files,
terminalSessions: loadedTerminalSessions,
});
+
const activeRootHostFileThreadId =
activeHostFileThreadId ??
(activeHostFilePath !== null ? rootPanelThreadId : null);
@@ -2593,9 +2607,9 @@ export function RootComposeView(props: RootComposeViewProps) {
onClose: () => closeTab(tab.id),
};
case "plugin-panel":
- // Plugin panel tabs are opened from a thread's launcher; the root
- // panel offers no plugin actions, but its persisted state must
- // still render any tab kind without crashing.
+ // Plugin action tabs are opened from a thread's launcher; the
+ // root panel offers no plugin actions, but file-opener tabs open
+ // here too and persisted state must render any kind.
return {
id: tab.id,
filename: tab.title,
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 799db8aeb..d8161f81e 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -37,6 +37,7 @@ import {
import { useHostDaemon } from "@/hooks/useHostDaemon";
import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection";
import { PluginsSettingsSection } from "@/components/settings/PluginsSettingsSection";
+import { FileOpenersSettingsSection } from "@/components/settings/FileOpenersSettingsSection";
import {
useUpdateAppearance,
useUpdateExperiments,
@@ -1021,6 +1022,8 @@ export function SettingsView() {
targets={workspaceOpenTargets}
/>
+
+
(
- (file) => openTab({ kind: "workspace-file-preview", tab: file }),
+ (file, options) =>
+ openTab({ kind: "workspace-file-preview", tab: file }, options),
[openTab],
);
const openPersistedStorageFile =
useCallback(
- (file) => openTab({ kind: "thread-storage-file-preview", tab: file }),
+ (file, options) =>
+ openTab({ kind: "thread-storage-file-preview", tab: file }, options),
[openTab],
);
const openPersistedHostFile =
useCallback(
- (file) => openTab({ kind: "host-file-preview", tab: file }),
+ (file, options) =>
+ openTab({ kind: "host-file-preview", tab: file }, options),
[openTab],
);
const openBrowserTab = useCallback(
@@ -826,6 +835,15 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
// sharing the localStorage draft) can focus its caret at the end, ready for
// the reply under the quote.
const [composerFocusRequestNonce, setComposerFocusRequestNonce] = useState(0);
+ // Plugin useComposer() writes ride the focus bus (they can't reach this
+ // view's local nonce); same storage key = same draft the composer shows.
+ useEffect(
+ () =>
+ subscribeComposerFocusRequests(selectionPromptDraft.storageKey, () =>
+ setComposerFocusRequestNonce((nonce) => nonce + 1),
+ ),
+ [selectionPromptDraft.storageKey],
+ );
const handleSelectionAddToChat = useCallback(
(text: string, attachments?: readonly PromptDraftAttachment[]) => {
addQuoteToComposer(text, attachments);
@@ -1655,7 +1673,10 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
[thread, updateThread],
);
const handleTimelineLocalFileLinkResolution = useCallback(
- (resolution: ThreadLocalFileLinkResolution) => {
+ (
+ resolution: ThreadLocalFileLinkResolution,
+ options?: ThreadSecondaryPanelFileOpenOptions,
+ ) => {
if (resolution.kind === "app-route") {
return false;
}
@@ -1667,33 +1688,45 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
}
if (resolution.kind === "open-workspace-path") {
- openWorkspaceFile({
- lineRange: resolution.request.lineRange,
- path: resolution.request.relativePath,
- source: { kind: "working-tree" },
- statusLabel: null,
- });
+ openWorkspaceFile(
+ {
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.relativePath,
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ options,
+ );
return true;
}
if (resolution.kind === "open-thread-storage-path") {
- openStorageFile({
- lineRange: resolution.request.lineRange,
- path: resolution.request.relativePath,
- });
+ openStorageFile(
+ {
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.relativePath,
+ },
+ options,
+ );
return true;
}
- openHostFile({
- lineRange: resolution.request.lineRange,
- path: resolution.request.path,
- });
+ openHostFile(
+ {
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.path,
+ },
+ options,
+ );
return true;
},
[openHostFile, openStorageFile, openWorkspaceFile],
);
const handleOpenTimelineLocalFileLink = useCallback(
- (link: ThreadTimelineLocalFileLink) => {
+ (
+ link: ThreadTimelineLocalFileLink,
+ options?: ThreadSecondaryPanelFileOpenOptions,
+ ) => {
const resolution = resolveThreadLocalFileLink({
hostFileLinksAvailable:
thread?.environmentId !== null && thread?.environmentId !== undefined,
@@ -1706,7 +1739,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
resolution.kind !== "open-host-path" ||
threadStorageRootPath !== null
) {
- return handleTimelineLocalFileLinkResolution(resolution);
+ return handleTimelineLocalFileLinkResolution(resolution, options);
}
void refetchThreadStorageFiles()
@@ -1726,7 +1759,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
threadStorageRootPath: resolvedThreadStorageRootPath,
workspaceRootPath: workspacePreviewRootPath,
});
- handleTimelineLocalFileLinkResolution(resolvedResolution);
+ handleTimelineLocalFileLinkResolution(resolvedResolution, options);
})
.catch((error: Error) => {
appToast.error("Failed to open file locally", {
@@ -1853,6 +1886,38 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
threadStorageRootPath,
workspaceRootPath: workspacePreviewRootPath,
});
+ // Right-click "Open with" on local file links: per-open viewer choice
+ // between the built-in preview and any plugin opener matching the file's
+ // extension. Only rendered when at least one opener matches.
+ const getLocalFileOpenWithItems = useCallback(
+ (link: ThreadTimelineLocalFileLink) => {
+ const extension = getFileExtension(link.path);
+ if (extension === null) return null;
+ const matching = pluginFileOpeners.filter((opener) =>
+ opener.extensions.includes(extension),
+ );
+ if (matching.length === 0) return null;
+ return [
+ {
+ id: "builtin",
+ label: "Open with built-in preview",
+ onSelect: () => {
+ handleOpenTimelineLocalFileLink(link, { viewer: "builtin" });
+ },
+ },
+ ...matching.map((opener) => ({
+ id: `${opener.pluginId}:${opener.id}`,
+ label: `Open with ${opener.title}`,
+ onSelect: () => {
+ handleOpenTimelineLocalFileLink(link, {
+ viewer: { pluginId: opener.pluginId, openerId: opener.id },
+ });
+ },
+ })),
+ ];
+ },
+ [handleOpenTimelineLocalFileLink, pluginFileOpeners],
+ );
const workspaceMarkdownLinkRouting = useMemo(
() =>
buildMarkdownPreviewLinkRouting({
@@ -2186,6 +2251,9 @@ export function ThreadDetailView(props: ThreadDetailViewProps) {
);
return (
+
) : null}
+
);
}
diff --git a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts
index de4825e4f..c20e40526 100644
--- a/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts
+++ b/apps/app/src/views/thread-detail/useThreadSecondaryPanelVisibility.ts
@@ -5,6 +5,7 @@ import type {
WorkspaceFileTabState,
} from "@/lib/file-preview";
import type { ThreadSecondaryPanel } from "@/lib/thread-secondary-panel";
+import type { FileTabViewerOverride } from "@/components/plugin/file-opener-tabs";
type ThreadSecondaryPanelThreadId = string | undefined;
type ThreadDetailSurface = "page" | "popout";
@@ -14,14 +15,21 @@ export type ThreadSecondaryPanelOpenHandler = (
) => void;
export type ThreadSecondaryPanelDiffFileOpenHandler = (path: string) => void;
export type ThreadSecondaryPanelCommitDiffOpenHandler = (sha: string) => void;
+export interface ThreadSecondaryPanelFileOpenOptions {
+ /** Per-open viewer choice (link context menu); absent = extension default. */
+ viewer?: FileTabViewerOverride;
+}
export type ThreadSecondaryPanelWorkspaceFileOpenHandler = (
file: WorkspaceFileTabState,
+ options?: ThreadSecondaryPanelFileOpenOptions,
) => void;
export type ThreadSecondaryPanelStorageFileOpenHandler = (
file: ThreadStorageFileTabState,
+ options?: ThreadSecondaryPanelFileOpenOptions,
) => void;
export type ThreadSecondaryPanelHostFileOpenHandler = (
file: HostFileTabState,
+ options?: ThreadSecondaryPanelFileOpenOptions,
) => void;
export interface UseThreadSecondaryPanelVisibilityArgs {
@@ -159,11 +167,12 @@ export function useThreadSecondaryPanelVisibility({
const openWorkspaceFile =
useCallback(
- (file) => {
+ (file, options) => {
if (surface === "popout") {
return;
}
- openPersistedWorkspaceFile(file);
+ if (options !== undefined) openPersistedWorkspaceFile(file, options);
+ else openPersistedWorkspaceFile(file);
openCompactDrawer();
},
[openCompactDrawer, openPersistedWorkspaceFile, surface],
@@ -171,22 +180,24 @@ export function useThreadSecondaryPanelVisibility({
const openStorageFile =
useCallback(
- (file) => {
+ (file, options) => {
if (surface === "popout") {
return;
}
- openPersistedStorageFile(file);
+ if (options !== undefined) openPersistedStorageFile(file, options);
+ else openPersistedStorageFile(file);
openCompactDrawer();
},
[openCompactDrawer, openPersistedStorageFile, surface],
);
const openHostFile = useCallback(
- (file) => {
+ (file, options) => {
if (surface === "popout") {
return;
}
- openPersistedHostFile(file);
+ if (options !== undefined) openPersistedHostFile(file, options);
+ else openPersistedHostFile(file);
openCompactDrawer();
},
[openCompactDrawer, openPersistedHostFile, surface],
diff --git a/apps/cli/src/__tests__/notes-example-bundle.test.ts b/apps/cli/src/__tests__/notes-example-bundle.test.ts
new file mode 100644
index 000000000..4dfeafcb1
--- /dev/null
+++ b/apps/cli/src/__tests__/notes-example-bundle.test.ts
@@ -0,0 +1,144 @@
+import { cp, mkdtemp, rm, symlink } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// The notes example bundles Milkdown Crepe (prosemirror + codemirror) —
+// a large graph that blows the 5s default on cold CI runners.
+vi.setConfig({ testTimeout: 120_000 });
+import { buildPluginApp } from "@bb/plugin-build";
+
+/**
+ * Evaluates the notes hero example's built bundle against a stub runtime
+ * (the github-example-bundle.test.ts pattern) and asserts its default
+ * export registers the nav panel, the thread panel action, and the markdown
+ * fileOpener.
+ */
+const NOTES_DIR = fileURLToPath(
+ new URL("../../../../examples/plugins/notes", import.meta.url),
+);
+
+interface SlotRegistration {
+ id: string;
+ title?: string;
+ icon?: string;
+ path?: string;
+ chrome?: string;
+ extensions?: string[];
+ component: unknown;
+}
+
+describe("notes example frontend bundle", () => {
+ let root: string;
+
+ beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "bb-notes-bundle-"));
+ });
+
+ afterEach(async () => {
+ await rm(root, { recursive: true, force: true });
+ delete (globalThis as { __bbPluginRuntime?: unknown }).__bbPluginRuntime;
+ delete (globalThis as { document?: unknown }).document;
+ });
+
+ it("registers the notes nav panel, panel action, and markdown opener", async () => {
+ const pluginDir = join(root, "notes");
+ await cp(NOTES_DIR, pluginDir, {
+ recursive: true,
+ filter: (source) => {
+ const name = basename(source);
+ return name !== "dist" && name !== "node_modules";
+ },
+ });
+ // The example's own node_modules already holds every bundled dep
+ // (Milkdown among them) — link it wholesale instead of per-package.
+ await symlink(join(NOTES_DIR, "node_modules"), join(pluginDir, "node_modules"), "dir");
+ const { jsPath } = await buildPluginApp(pluginDir);
+
+ const registered: Record = {
+ homepageSection: [],
+ navPanel: [],
+ threadPanelAction: [],
+ composerAccessory: [],
+ fileOpener: [],
+ };
+ const componentStub: unknown = new Proxy(function stub() {}, {
+ get: (target, prop) =>
+ prop === "prototype"
+ ? Reflect.get(target, prop)
+ : (componentStub as object),
+ set: () => true,
+ });
+ (globalThis as { __bbPluginRuntime?: unknown }).__bbPluginRuntime = {
+ react: {
+ forwardRef: (render: unknown) => render,
+ createContext: () => ({}),
+ memo: (component: unknown) => component,
+ },
+ reactDom: componentStub,
+ reactDomClient: componentStub,
+ jsxRuntime: { jsx: () => ({}), jsxs: () => ({}), Fragment: {} },
+ pluginSdkApp: {
+ definePluginApp: (setup: unknown) => ({ __bbPluginApp: true, setup }),
+ },
+ sonner: componentStub,
+ vaul: componentStub,
+ pierreDiffs: componentStub,
+ pierreDiffsReact: componentStub,
+ };
+ // decode-named-character-reference and CodeMirror's browser sniffing
+ // touch `document` at module scope (browser bundles legitimately assume
+ // one); registration evaluation never renders, so inert stubs suffice.
+ (globalThis as { document?: unknown }).document = {
+ createElement: () => ({ innerHTML: "", textContent: "", style: {} }),
+ documentElement: { style: {} },
+ };
+ const mod = (await import(
+ /* @vite-ignore */ pathToFileURL(jsPath).href
+ )) as {
+ default: {
+ __bbPluginApp: boolean;
+ setup: (app: {
+ slots: Record void>;
+ }) => void;
+ };
+ };
+ expect(mod.default.__bbPluginApp).toBe(true);
+ mod.default.setup({
+ slots: {
+ homepageSection: (r) => registered.homepageSection.push(r),
+ navPanel: (r) => registered.navPanel.push(r),
+ threadPanelAction: (r) => registered.threadPanelAction.push(r),
+ composerAccessory: (r) => registered.composerAccessory.push(r),
+ fileOpener: (r) => registered.fileOpener.push(r),
+ },
+ });
+
+ expect(registered.navPanel).toHaveLength(1);
+ expect(registered.navPanel[0]).toMatchObject({
+ id: "notes",
+ title: "Notes",
+ path: "notes",
+ chrome: "none",
+ });
+ expect(typeof registered.navPanel[0]?.component).toBe("function");
+
+ expect(registered.threadPanelAction).toHaveLength(1);
+ expect(registered.threadPanelAction[0]).toMatchObject({
+ id: "note",
+ title: "Open note",
+ });
+
+ expect(registered.fileOpener).toHaveLength(1);
+ expect(registered.fileOpener[0]).toMatchObject({
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md", "mdx", "markdown"],
+ });
+ expect(typeof registered.fileOpener[0]?.component).toBe("function");
+
+ expect(registered.homepageSection).toHaveLength(0);
+ expect(registered.composerAccessory).toHaveLength(0);
+ });
+});
diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts
index dc58c5cfd..272f59794 100644
--- a/apps/host-daemon/src/command-dispatch.ts
+++ b/apps/host-daemon/src/command-dispatch.ts
@@ -30,6 +30,7 @@ import {
readHostFileMetadata,
readHostRelativeFile,
} from "./command-handlers/host-files.js";
+import { writeHostFile } from "./command-handlers/file-write.js";
import { resolveInteractiveRequest } from "./command-handlers/interactive.js";
import { pickHostFolder } from "./command-handlers/native-folder-picker.js";
import { runScript } from "./command-handlers/run-script.js";
@@ -324,6 +325,7 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = {
"host.file_metadata": readHostFileMetadata,
"host.read_file": readHostFile,
"host.read_file_relative": readHostRelativeFile,
+ "host.write_file": writeHostFile,
"provider.list_models": async (command, options) =>
(options.listModels ?? defaultListModels)({
providerId: command.providerId,
diff --git a/apps/host-daemon/src/command-handlers/file-read.ts b/apps/host-daemon/src/command-handlers/file-read.ts
index e80e26bde..7f14f5b41 100644
--- a/apps/host-daemon/src/command-handlers/file-read.ts
+++ b/apps/host-daemon/src/command-handlers/file-read.ts
@@ -1,4 +1,5 @@
import { isUtf8 } from "node:buffer";
+import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import mimeTypes from "mime-types";
@@ -16,12 +17,17 @@ export const NON_IMAGE_FILE_SIZE_LIMIT_BYTES = 25 * 1024 * 1024;
type FileContentEncoding = "base64" | "utf8";
+export function sha256Hex(contents: Buffer): string {
+ return createHash("sha256").update(contents).digest("hex");
+}
+
export interface ReadFileForTransportResult {
content: string;
contentEncoding: FileContentEncoding;
mimeType?: string;
modifiedAtMs?: number;
path: string;
+ sha256: string;
sizeBytes: number;
}
@@ -274,6 +280,7 @@ export async function readFileFromGitRef(
content: "",
contentEncoding: "utf8",
...(mimeType ? { mimeType } : {}),
+ sha256: sha256Hex(Buffer.alloc(0)),
sizeBytes: 0,
};
}
@@ -287,6 +294,7 @@ export async function readFileFromGitRef(
: blob.contents.toString("base64"),
contentEncoding,
...(mimeType ? { mimeType } : {}),
+ sha256: sha256Hex(blob.contents),
sizeBytes: blob.sizeBytes,
};
}
@@ -327,6 +335,7 @@ export async function readFileForTransport(
contentEncoding,
...(mimeType ? { mimeType } : {}),
modifiedAtMs: stat.mtimeMs,
+ sha256: sha256Hex(fileContents),
sizeBytes: stat.size,
};
}
@@ -373,6 +382,7 @@ export async function readRootRelativeFileForTransport(
contentEncoding,
...(mimeType ? { mimeType } : {}),
modifiedAtMs: stat.mtimeMs,
+ sha256: sha256Hex(fileContents),
sizeBytes: stat.size,
};
}
diff --git a/apps/host-daemon/src/command-handlers/file-write.test.ts b/apps/host-daemon/src/command-handlers/file-write.test.ts
new file mode 100644
index 000000000..323dac61b
--- /dev/null
+++ b/apps/host-daemon/src/command-handlers/file-write.test.ts
@@ -0,0 +1,273 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ CommandDispatchError,
+ type CommandOf,
+ isExpectedCommandDispatchError,
+} from "../command-dispatch-support.js";
+import { writeHostFile } from "./file-write.js";
+import { readHostFile } from "./host-files.js";
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) =>
+ fs.rm(dir, { recursive: true, force: true }),
+ ),
+ );
+});
+
+function sha256(content: string): string {
+ return createHash("sha256").update(content, "utf8").digest("hex");
+}
+
+function writeCommand(
+ overrides: Partial> &
+ Pick, "path">,
+): CommandOf<"host.write_file"> {
+ return {
+ type: "host.write_file",
+ content: "hello",
+ contentEncoding: "utf8",
+ createParents: false,
+ ...overrides,
+ };
+}
+
+async function captureWriteError(
+ command: CommandOf<"host.write_file">,
+): Promise {
+ try {
+ await writeHostFile(command);
+ } catch (error) {
+ return error;
+ }
+ throw new Error("Expected writeHostFile to fail");
+}
+
+describe("writeHostFile", () => {
+ it("writes a new file unconditionally and returns its hash", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "note.md");
+
+ const result = await writeHostFile(
+ writeCommand({ path: target, content: "# Hello" }),
+ );
+
+ expect(result).toEqual({
+ outcome: "written",
+ sha256: sha256("# Hello"),
+ sizeBytes: 7,
+ });
+ await expect(fs.readFile(target, "utf8")).resolves.toBe("# Hello");
+ });
+
+ it("overwrites an existing file when the expected hash matches", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "note.md");
+ await fs.writeFile(target, "old");
+
+ const result = await writeHostFile(
+ writeCommand({
+ path: target,
+ content: "new",
+ expectedSha256: sha256("old"),
+ }),
+ );
+
+ expect(result).toEqual({
+ outcome: "written",
+ sha256: sha256("new"),
+ sizeBytes: 3,
+ });
+ await expect(fs.readFile(target, "utf8")).resolves.toBe("new");
+ });
+
+ it("returns a conflict with the current hash when the expected hash is stale", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "note.md");
+ await fs.writeFile(target, "current");
+
+ const result = await writeHostFile(
+ writeCommand({
+ path: target,
+ content: "mine",
+ expectedSha256: sha256("stale"),
+ }),
+ );
+
+ expect(result).toEqual({
+ outcome: "conflict",
+ currentSha256: sha256("current"),
+ });
+ await expect(fs.readFile(target, "utf8")).resolves.toBe("current");
+ });
+
+ it("returns a null-hash conflict when the expected file is missing", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+
+ const result = await writeHostFile(
+ writeCommand({
+ path: path.join(dir, "gone.md"),
+ expectedSha256: sha256("anything"),
+ }),
+ );
+
+ expect(result).toEqual({ outcome: "conflict", currentSha256: null });
+ });
+
+ it("treats expectedSha256 null as create-only", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "note.md");
+
+ const created = await writeHostFile(
+ writeCommand({ path: target, expectedSha256: null }),
+ );
+ expect(created).toMatchObject({ outcome: "written" });
+
+ const conflicted = await writeHostFile(
+ writeCommand({ path: target, expectedSha256: null }),
+ );
+ expect(conflicted).toEqual({
+ outcome: "conflict",
+ currentSha256: sha256("hello"),
+ });
+ });
+
+ it("decodes base64 content", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "logo.bin");
+
+ const result = await writeHostFile(
+ writeCommand({
+ path: target,
+ content: Buffer.from([0, 1, 2, 255]).toString("base64"),
+ contentEncoding: "base64",
+ }),
+ );
+
+ expect(result).toMatchObject({ outcome: "written", sizeBytes: 4 });
+ expect(Uint8Array.from(await fs.readFile(target))).toEqual(
+ Uint8Array.from([0, 1, 2, 255]),
+ );
+ });
+
+ it("fails with ENOENT when the parent is missing and createParents is false", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+
+ const error = await captureWriteError(
+ writeCommand({ path: path.join(dir, "nested", "note.md") }),
+ );
+
+ expect(isExpectedCommandDispatchError(error)).toBe(true);
+ expect((error as CommandDispatchError).code).toBe("ENOENT");
+ });
+
+ it("creates missing parents when createParents is true", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "a", "b", "note.md");
+
+ const result = await writeHostFile(
+ writeCommand({ path: target, createParents: true }),
+ );
+
+ expect(result).toMatchObject({ outcome: "written" });
+ await expect(fs.readFile(target, "utf8")).resolves.toBe("hello");
+ });
+
+ it("rejects writes that escape the declared root via symlinks", async () => {
+ const rootDir = await makeTempDir("bb-file-write-root-");
+ const outsideDir = await makeTempDir("bb-file-write-outside-");
+ await fs.symlink(outsideDir, path.join(rootDir, "link"), "dir");
+
+ const error = await captureWriteError(
+ writeCommand({
+ path: path.join(rootDir, "link", "escape.md"),
+ rootPath: rootDir,
+ }),
+ );
+
+ expect(error).toBeInstanceOf(CommandDispatchError);
+ expect((error as CommandDispatchError).code).toBe("invalid_path");
+ await expect(
+ fs.readFile(path.join(outsideDir, "escape.md"), "utf8"),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("rejects lexical escapes from the declared root", async () => {
+ const rootDir = await makeTempDir("bb-file-write-root-");
+ const outsideDir = await makeTempDir("bb-file-write-outside-");
+
+ const error = await captureWriteError(
+ writeCommand({
+ path: path.join(outsideDir, "escape.md"),
+ rootPath: rootDir,
+ }),
+ );
+
+ expect(error).toBeInstanceOf(CommandDispatchError);
+ expect((error as CommandDispatchError).code).toBe("invalid_path");
+ });
+
+ it("allows contained writes under the declared root", async () => {
+ const rootDir = await makeTempDir("bb-file-write-root-");
+
+ const result = await writeHostFile(
+ writeCommand({
+ path: path.join(rootDir, "notes", "note.md"),
+ rootPath: rootDir,
+ createParents: true,
+ }),
+ );
+
+ expect(result).toMatchObject({ outcome: "written" });
+ });
+
+ it("rejects directory targets", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+
+ const error = await captureWriteError(writeCommand({ path: dir }));
+
+ expect(error).toBeInstanceOf(CommandDispatchError);
+ expect((error as CommandDispatchError).code).toBe("invalid_path");
+ });
+
+ it("rejects relative paths", async () => {
+ const error = await captureWriteError(
+ writeCommand({ path: "relative/note.md" }),
+ );
+
+ expect(error).toBeInstanceOf(CommandDispatchError);
+ expect((error as CommandDispatchError).code).toBe("invalid_path");
+ });
+
+ it("round-trips with readHostFile for compare-and-swap saves", async () => {
+ const dir = await makeTempDir("bb-file-write-");
+ const target = path.join(dir, "note.md");
+ await fs.writeFile(target, "v1");
+
+ const read = await readHostFile({ type: "host.read_file", path: target });
+ const result = await writeHostFile(
+ writeCommand({
+ path: target,
+ content: "v2",
+ expectedSha256: read.sha256,
+ }),
+ );
+
+ expect(result).toMatchObject({ outcome: "written" });
+ const reread = await readHostFile({ type: "host.read_file", path: target });
+ expect(reread.content).toBe("v2");
+ expect(reread.sha256).toBe(sha256("v2"));
+ });
+});
diff --git a/apps/host-daemon/src/command-handlers/file-write.ts b/apps/host-daemon/src/command-handlers/file-write.ts
new file mode 100644
index 000000000..dc22b6210
--- /dev/null
+++ b/apps/host-daemon/src/command-handlers/file-write.ts
@@ -0,0 +1,177 @@
+import { Buffer } from "node:buffer";
+import fs from "node:fs/promises";
+import path from "node:path";
+import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract";
+import {
+ CommandDispatchError,
+ ExpectedCommandDispatchError,
+} from "../command-dispatch-support.js";
+import type { CommandOf } from "../command-dispatch-support.js";
+import { isFsErrorWithCode } from "../fs-errors.js";
+import { NON_IMAGE_FILE_SIZE_LIMIT_BYTES, sha256Hex } from "./file-read.js";
+import { resolveNonSymlinkDirectoryPath } from "./root-path.js";
+
+interface ResolvedWriteTarget {
+ /** Real (symlink-resolved) path to write, existing or not. */
+ writePath: string;
+ /** True when the write target's direct parent directory is missing. */
+ parentMissing: boolean;
+}
+
+function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
+ const relativePath = path.relative(rootPath, candidatePath);
+ return (
+ relativePath === "" ||
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
+ );
+}
+
+function createMissingTargetError(
+ resultPath: string,
+): ExpectedCommandDispatchError {
+ return new ExpectedCommandDispatchError(
+ "ENOENT",
+ `Path does not exist: ${resultPath}`,
+ );
+}
+
+/**
+ * Resolve the write target through symlinks even though it may not exist yet:
+ * realpath the nearest existing ancestor and re-append the missing segments.
+ * Containment (when a root is declared) is checked against this resolved
+ * path, so a symlinked directory inside the root cannot smuggle a write
+ * outside it.
+ */
+async function resolveWriteTarget(
+ resolvedPath: string,
+ resultPath: string,
+): Promise {
+ try {
+ return { writePath: await fs.realpath(resolvedPath), parentMissing: false };
+ } catch (error) {
+ if (!isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+
+ const missingSegments = [path.basename(resolvedPath)];
+ let ancestor = path.dirname(resolvedPath);
+ for (;;) {
+ try {
+ const realAncestor = await fs.realpath(ancestor);
+ return {
+ writePath: path.join(realAncestor, ...missingSegments),
+ parentMissing: missingSegments.length > 1,
+ };
+ } catch (error) {
+ if (!isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+ const parent = path.dirname(ancestor);
+ if (parent === ancestor) {
+ throw createMissingTargetError(resultPath);
+ }
+ missingSegments.unshift(path.basename(ancestor));
+ ancestor = parent;
+ }
+}
+
+export async function writeHostFile(
+ command: CommandOf<"host.write_file">,
+): Promise> {
+ if (!path.isAbsolute(command.path)) {
+ throw new CommandDispatchError("invalid_path", "Path must be absolute");
+ }
+ if (command.rootPath !== undefined && !path.isAbsolute(command.rootPath)) {
+ throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ }
+
+ const contents = Buffer.from(command.content, command.contentEncoding);
+ if (contents.length > NON_IMAGE_FILE_SIZE_LIMIT_BYTES) {
+ throw new CommandDispatchError(
+ "file_too_large",
+ `File size ${contents.length} bytes exceeds the ${Math.floor(NON_IMAGE_FILE_SIZE_LIMIT_BYTES / (1024 * 1024))} MB limit`,
+ );
+ }
+
+ const resolvedPath = path.resolve(command.path);
+ const target = await resolveWriteTarget(resolvedPath, command.path);
+
+ if (command.rootPath !== undefined) {
+ let realRootPath: string;
+ try {
+ realRootPath = await resolveNonSymlinkDirectoryPath({
+ description: "Root path",
+ path: command.rootPath,
+ });
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ throw createMissingTargetError(command.path);
+ }
+ throw error;
+ }
+ if (!isPathWithinRoot(target.writePath, realRootPath)) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ `Path "${command.path}" escapes write root`,
+ );
+ }
+ }
+
+ if (target.parentMissing && !command.createParents) {
+ throw createMissingTargetError(path.dirname(command.path));
+ }
+
+ let currentContents: Buffer | null = null;
+ try {
+ const stat = await fs.stat(target.writePath);
+ if (stat.isDirectory()) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ "Path is a directory, not a file",
+ );
+ }
+ currentContents = await fs.readFile(target.writePath);
+ } catch (error) {
+ if (!isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+ const currentSha256 =
+ currentContents === null ? null : sha256Hex(currentContents);
+
+ if (
+ command.expectedSha256 !== undefined &&
+ command.expectedSha256 !== currentSha256
+ ) {
+ return { outcome: "conflict", currentSha256 };
+ }
+
+ if (command.createParents) {
+ await fs.mkdir(path.dirname(target.writePath), { recursive: true });
+ }
+ try {
+ await fs.writeFile(target.writePath, contents);
+ } catch (error) {
+ if (isFsErrorWithCode(error, "ENOENT")) {
+ throw createMissingTargetError(path.dirname(command.path));
+ }
+ if (
+ isFsErrorWithCode(error, "ENOTDIR") ||
+ isFsErrorWithCode(error, "EISDIR")
+ ) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ `Cannot write file at ${command.path}`,
+ );
+ }
+ throw error;
+ }
+
+ return {
+ outcome: "written",
+ sha256: sha256Hex(contents),
+ sizeBytes: contents.length,
+ };
+}
diff --git a/apps/server/src/routes/files.ts b/apps/server/src/routes/files.ts
index be4252d17..cb49423cc 100644
--- a/apps/server/src/routes/files.ts
+++ b/apps/server/src/routes/files.ts
@@ -9,13 +9,22 @@ import {
import { COMMAND_TIMEOUT_MS } from "../constants.js";
import { ApiError } from "../errors.js";
import type { AppDeps, LoggedWorkSessionDeps } from "../types.js";
-import { callHostRetryableOnlineRpc } from "../services/hosts/online-rpc.js";
+import {
+ callHostOnlineRpc,
+ callHostRetryableOnlineRpc,
+} from "../services/hosts/online-rpc.js";
import {
createDaemonFileContentResponse,
type DaemonFileReadResult,
remapDaemonFileRouteError,
} from "../services/hosts/daemon-file-response.js";
-import { requirePublicThreadEnvironment } from "../services/lib/entity-lookup.js";
+import { requirePrimaryHostId } from "../services/hosts/primary-host.js";
+import {
+ requireNonDestroyedHostWithStatus,
+ requirePublicThreadEnvironment,
+} from "../services/lib/entity-lookup.js";
+
+const HOST_FILE_LIST_LIMIT_DEFAULT = 1000;
const HTML_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const HTML_PREVIEW_CONTENT_TYPE = "text/html; charset=utf-8";
@@ -114,7 +123,7 @@ async function serveRawFilesystemHtmlFile(
}
export function registerFileRoutes(app: Hono, deps: AppDeps): void {
- const { get } = typedRoutes(app, {
+ const { get, post } = typedRoutes(app, {
onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
});
const routes = publicApiRoutes.threads;
@@ -122,4 +131,80 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void {
get(routes.rawFile, async (context, query) =>
serveRawFilesystemHtmlFile(deps, context.req.param("id"), query.path),
);
+
+ // Host file primitives (plugin design §4.1): read/write/list against a
+ // connected host. Omitted hostId resolves to the primary (local) host here,
+ // once, at the product boundary — daemon commands always get explicit values.
+ const fileRoutes = publicApiRoutes.files;
+
+ const resolveHostId = (hostId: string | undefined): string => {
+ const resolved = hostId ?? requirePrimaryHostId(deps);
+ requireNonDestroyedHostWithStatus(deps, resolved);
+ return resolved;
+ };
+
+ post(fileRoutes.read, async (context, payload) => {
+ const hostId = resolveHostId(payload.hostId);
+ try {
+ const result = await callHostRetryableOnlineRpc(deps, {
+ hostId,
+ timeoutMs: COMMAND_TIMEOUT_MS,
+ command: {
+ type: "host.read_file",
+ path: payload.path,
+ ...(payload.rootPath !== undefined
+ ? { rootPath: payload.rootPath }
+ : {}),
+ },
+ });
+ return context.json(result);
+ } catch (error) {
+ return remapDaemonFileRouteError(error);
+ }
+ });
+
+ post(fileRoutes.write, async (context, payload) => {
+ const hostId = resolveHostId(payload.hostId);
+ try {
+ const result = await callHostOnlineRpc(deps, {
+ hostId,
+ timeoutMs: COMMAND_TIMEOUT_MS,
+ command: {
+ type: "host.write_file",
+ path: payload.path,
+ content: payload.content,
+ contentEncoding: payload.contentEncoding ?? "utf8",
+ createParents: payload.createParents ?? false,
+ ...(payload.rootPath !== undefined
+ ? { rootPath: payload.rootPath }
+ : {}),
+ ...(payload.expectedSha256 !== undefined
+ ? { expectedSha256: payload.expectedSha256 }
+ : {}),
+ },
+ });
+ return context.json(result);
+ } catch (error) {
+ return remapDaemonFileRouteError(error);
+ }
+ });
+
+ post(fileRoutes.list, async (context, payload) => {
+ const hostId = resolveHostId(payload.hostId);
+ try {
+ const result = await callHostRetryableOnlineRpc(deps, {
+ hostId,
+ timeoutMs: COMMAND_TIMEOUT_MS,
+ command: {
+ type: "host.list_files",
+ path: payload.path,
+ limit: payload.limit ?? HOST_FILE_LIST_LIMIT_DEFAULT,
+ ...(payload.query !== undefined ? { query: payload.query } : {}),
+ },
+ });
+ return context.json(result);
+ } catch (error) {
+ return remapDaemonFileRouteError(error);
+ }
+ });
}
diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
index 6985c6776..88ec0c515 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
@@ -165,6 +165,32 @@ inputs) — never both. Attribution is auto-filled: `origin: "plugin"` and
threadId, mode: "auto", input: [...] })` starts a turn on an idle thread or
queues/steers a running one.
+`bb.sdk.files` reads and writes files on a connected host (not just the
+server machine — this is the right primitive when the user's files may live
+on another host, and its `rootPath` confinement + compare-and-swap guard make
+it the right save path even locally):
+
+```ts
+const file = await bb.sdk.files.read({ path: "/home/me/notes/todo.md" });
+// → { content, contentEncoding, sha256, sizeBytes, modifiedAtMs?, ... }
+
+const saved = await bb.sdk.files.write({
+ path: "/home/me/notes/todo.md",
+ rootPath: "/home/me/notes", // optional: confine writes beneath this root
+ content: "# Todo\n",
+ expectedSha256: file.sha256, // CAS guard; omit for unconditional, null for create-only
+});
+if (saved.outcome === "conflict") {
+ // File changed since the read (saved.currentSha256, null = deleted) —
+ // re-read and merge instead of clobbering.
+}
+```
+
+`hostId` is optional everywhere (defaults to the primary/local host).
+`bb.sdk.files.list({ path, query?, limit? })` is a recursive fuzzy file
+listing under a directory. Writes cap at 25 MB and return
+`{ outcome: "written", sha256, sizeBytes }`.
+
### bb.on — thread lifecycle events
```ts
@@ -378,8 +404,14 @@ Slot props contracts (versioned, additive-only):
- `homepageSection` → `{ projectId: string | null }` (project in view on
the compose surface). Registration: `{ id, title, component }`.
-- `navPanel` → `{}` — owns the whole route at `/plugins//`
- and gets its own sidebar entry.
+- `navPanel` → `{ subPath: string }` — owns the whole route at
+ `/plugins///*` and gets its own sidebar entry. `subPath`
+ is the route remainder after the panel root (`""` at the root), so deep
+ links like `/plugins/notes/notes/work/ideas.md` land with
+ `subPath: "work/ideas.md"`. Navigate within the panel via
+ `useBbNavigate().toPluginPanel(path, { subPath, replace? })` — browser
+ back/forward then walks panel-internal history (prefer this over hash
+ routing).
Registration: `{ id, title, icon, path, component, chrome?, headerContent? }`.
The host renders your plugin logo + `title` into the SHARED app header
(the same chrome as Settings/Automations) with your optional
@@ -409,6 +441,21 @@ Slot props contracts (versioned, additive-only):
or async) are contained and logged, never breaking the launcher.
- `composerAccessory` → `{ projectId: string | null, threadId: string | null }`
— rendered in the composer footer. Registration: `{ id, component }`.
+- `fileOpener` → `{ path: string, source }` — register as a viewer/editor
+ for file extensions: `{ id, title, extensions: ["md"], component }`.
+ Users set the per-extension default under Settings → "File openers", and
+ right-clicking a file link in rendered markdown offers a one-off
+ "Open with …" choice; matching files opened in the right panel then
+ render your component in a plugin tab instead of the built-in preview —
+ this includes links clicked in rendered markdown, the file picker, and
+ `bb thread open`. `source` is
+ `{ kind: "workspace" | "host" | "thread-storage", threadId, environmentId,
+ projectId }` (nullable fields) and `path` follows the source (workspace:
+ worktree-relative; host: absolute; thread-storage: storage-relative).
+ Applies only to live file content — git-ref snapshots and deleted files
+ always use the built-in preview, and a removed/disabled opener degrades
+ back to it. Pair with `bb.sdk.files` (rpc from your server) to load and
+ CAS-save the content.
Hooks:
@@ -419,7 +466,17 @@ Hooks:
- `useSettings()` → `{ values, isLoading }` — effective non-secret values
(secret settings are excluded; read them server-side only).
- `useBbContext()` → `{ projectId, threadId }` from the current route.
-- `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path) }`.
+- `useBbNavigate()` → `{ toThread(id), toProject(id), toPluginPanel(path, { subPath?, replace? }?) }`.
+- `useComposer()` → programmatic access to the chat composer draft (the
+ same one the built-in "Add to chat" affordances write to):
+ `addQuote(text)` appends the text as a `> ` blockquote block and focuses
+ the composer — the "reference this selection in chat" primitive;
+ `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; `scope` reports
+ where writes land (`{ kind: "thread", threadId }` inside a thread
+ context, `{ kind: "new-thread", projectId }` from nav panels and
+ homepage sections — those seed the composer the user lands on next).
UI components — **vendored shadcn source you own** (the shadcn model; the
old host-provided component kit is REMOVED — `@bb/plugin-sdk/app` exports
@@ -507,10 +564,17 @@ hardcoded colors break custom palettes.
Reference examples in `examples/plugins/` (a bb checkout):
- `github` — vendored-component showcase: a gh-CLI-backed issue/PR browser
- in a single navPanel (with `headerContent`), hash-based sub-navigation,
+ in a single navPanel (with `headerContent`), subPath-based sub-navigation,
vendored Tabs/Select/DropdownMenu/Badge/Skeleton + sonner toast
throughout, background sync service, rpc + realtime, project setting, a
`bb github` CLI command, and agent-spawn buttons.
+- `notes` — full-surface markdown notes (Obsidian-style): mounted
+ directories via a setting, `bb.sdk.files` read/CAS-write, Milkdown Crepe
+ WYSIWYG bundled per-plugin (its theme CSS served from a `bb.http` route),
+ navPanel with `chrome: "none"` + subPath deep links, threadPanelAction,
+ a markdown `fileOpener`, `useComposer()` quote/mention buttons, an fs
+ watcher publishing realtime tree refreshes, and a `@Notes` mention
+ provider resolving note content at send.
- `slack-bot` — headless webhook bot: `auth: "none"` route with signature
verification, kv thread mapping, `thread.idle` handler, spawn/send,
needsConfiguration.
diff --git a/apps/server/test/files/host-file-routes.test.ts b/apps/server/test/files/host-file-routes.test.ts
new file mode 100644
index 000000000..b10e22498
--- /dev/null
+++ b/apps/server/test/files/host-file-routes.test.ts
@@ -0,0 +1,148 @@
+import type { HostDaemonOnlineRpcRequestMessage } from "@bb/host-daemon-contract";
+import { describe, expect, it } from "vitest";
+import { registerHostRpcResponder } from "../helpers/host-rpc.js";
+import { readJson } from "../helpers/json.js";
+import { seedHostSession, seedPrimaryHost } from "../helpers/seed.js";
+import { withTestHarness } from "../helpers/test-app.js";
+
+const WRITTEN_RESULT = {
+ outcome: "written",
+ sha256: "a".repeat(64),
+ sizeBytes: 5,
+} as const;
+
+const READ_RESULT = {
+ path: "/home/me/notes/note.md",
+ content: "# Hi",
+ contentEncoding: "utf8",
+ mimeType: "text/markdown",
+ modifiedAtMs: 1234,
+ sha256: "b".repeat(64),
+ sizeBytes: 4,
+} as const;
+
+function postJson(path: string, body: unknown): [string, RequestInit] {
+ return [
+ path,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ ];
+}
+
+describe("host file routes", () => {
+ it("fills write defaults and resolves the primary host at the boundary", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps);
+ seedPrimaryHost(harness.deps, host.id);
+ const requests: HostDaemonOnlineRpcRequestMessage[] = [];
+ registerHostRpcResponder(harness, {
+ hostId: host.id,
+ sessionId: session.id,
+ handle: (request) => {
+ requests.push(request);
+ if (request.command.type !== "host.write_file") {
+ throw new Error(`Unexpected RPC command ${request.command.type}`);
+ }
+ return { ok: true, result: WRITTEN_RESULT };
+ },
+ });
+
+ const response = await harness.app.request(
+ ...postJson("/api/v1/files/write", {
+ path: "/home/me/notes/note.md",
+ content: "hello",
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(await readJson(response)).toEqual(WRITTEN_RESULT);
+ expect(requests).toHaveLength(1);
+ expect(requests[0]?.command).toEqual({
+ type: "host.write_file",
+ path: "/home/me/notes/note.md",
+ content: "hello",
+ contentEncoding: "utf8",
+ createParents: false,
+ });
+ });
+ });
+
+ it("passes the create-only null guard through to the daemon", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps);
+ const commands: unknown[] = [];
+ registerHostRpcResponder(harness, {
+ hostId: host.id,
+ sessionId: session.id,
+ handle: (request) => {
+ commands.push(request.command);
+ return { ok: true, result: { outcome: "conflict", currentSha256: null } };
+ },
+ });
+
+ const response = await harness.app.request(
+ ...postJson("/api/v1/files/write", {
+ hostId: host.id,
+ path: "/home/me/notes/new.md",
+ content: "hello",
+ expectedSha256: null,
+ createParents: true,
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(await readJson(response)).toEqual({
+ outcome: "conflict",
+ currentSha256: null,
+ });
+ expect(commands[0]).toMatchObject({
+ expectedSha256: null,
+ createParents: true,
+ });
+ });
+ });
+
+ it("serves reads and remaps daemon ENOENT to 404", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps);
+ registerHostRpcResponder(harness, {
+ hostId: host.id,
+ sessionId: session.id,
+ handle: (request) => {
+ if (request.command.type !== "host.read_file") {
+ throw new Error(`Unexpected RPC command ${request.command.type}`);
+ }
+ if (request.command.path === "/home/me/notes/note.md") {
+ return { ok: true, result: READ_RESULT };
+ }
+ return {
+ ok: false,
+ errorCode: "ENOENT",
+ errorMessage: "Path does not exist",
+ };
+ },
+ });
+
+ const okResponse = await harness.app.request(
+ ...postJson("/api/v1/files/read", {
+ hostId: host.id,
+ path: "/home/me/notes/note.md",
+ rootPath: "/home/me/notes",
+ }),
+ );
+ expect(okResponse.status).toBe(200);
+ expect(await readJson(okResponse)).toEqual(READ_RESULT);
+
+ const missingResponse = await harness.app.request(
+ ...postJson("/api/v1/files/read", {
+ hostId: host.id,
+ path: "/home/me/notes/missing.md",
+ }),
+ );
+ expect(missingResponse.status).toBe(404);
+ });
+ });
+});
diff --git a/apps/server/test/hosts/online-rpc.test.ts b/apps/server/test/hosts/online-rpc.test.ts
index 49a486e7b..25f596889 100644
--- a/apps/server/test/hosts/online-rpc.test.ts
+++ b/apps/server/test/hosts/online-rpc.test.ts
@@ -260,6 +260,7 @@ describe("host online RPC retry semantics", () => {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: 15,
+ sha256: "0".repeat(64),
},
},
});
@@ -328,6 +329,7 @@ describe("host online RPC retry semantics", () => {
mimeType: "text/html",
modifiedAtMs: 1234,
sizeBytes: 15,
+ sha256: "0".repeat(64),
},
},
});
diff --git a/apps/server/test/public/public-projects-local-host.test.ts b/apps/server/test/public/public-projects-local-host.test.ts
index d67083fa4..214e267a7 100644
--- a/apps/server/test/public/public-projects-local-host.test.ts
+++ b/apps/server/test/public/public-projects-local-host.test.ts
@@ -128,6 +128,7 @@ describe("public project local host routes", () => {
contentEncoding: "utf8",
mimeType: "application/typescript",
sizeBytes: 18,
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
diff --git a/apps/server/test/public/public-thread-data.test.ts b/apps/server/test/public/public-thread-data.test.ts
index 402a4e5dd..301d628e7 100644
--- a/apps/server/test/public/public-thread-data.test.ts
+++ b/apps/server/test/public/public-thread-data.test.ts
@@ -4017,6 +4017,7 @@ describe("public thread data routes", () => {
contentEncoding: "base64",
mimeType: "image/png",
sizeBytes: pngBytes.byteLength,
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
expect(fileResponse.status).toBe(200);
@@ -4067,6 +4068,7 @@ describe("public thread data routes", () => {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: Buffer.byteLength(html),
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
@@ -4124,6 +4126,7 @@ describe("public thread data routes", () => {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: Buffer.byteLength(html),
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
@@ -4175,6 +4178,7 @@ describe("public thread data routes", () => {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: Buffer.byteLength(html),
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
@@ -4268,6 +4272,7 @@ describe("public thread data routes", () => {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: 5 * 1024 * 1024 + 1,
+ sha256: "0".repeat(64),
});
const fileResponse = await filePromise;
@@ -4335,6 +4340,7 @@ describe("public thread data routes", () => {
contentEncoding: "utf8",
mimeType: "text/markdown",
sizeBytes: fileBytes.byteLength,
+ sha256: "0".repeat(64),
},
{ hostId: host.id },
);
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 55776bfa9..50c97fb3d 100644
--- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
+++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
@@ -6,6 +6,7 @@ import {
type BbPluginApi,
type PluginAppSlots,
type PluginComposerAccessoryProps,
+ type PluginFileOpenerProps,
type PluginHomepageSectionProps,
type PluginHttpAuthMode,
type PluginNavPanelProps,
@@ -133,6 +134,7 @@ type SlotPropsByName = {
navPanel: PluginNavPanelProps;
threadPanelAction: PluginThreadPanelProps;
composerAccessory: PluginComposerAccessoryProps;
+ fileOpener: PluginFileOpenerProps;
};
type MissingSlot = Exclude;
@@ -141,9 +143,10 @@ void _assertAllSlotsListed;
const FRONTEND_SLOT_PROP_FIELDS = {
homepageSection: ["projectId"],
- navPanel: [],
+ navPanel: ["subPath"],
threadPanelAction: ["threadId", "params"],
composerAccessory: ["projectId", "threadId"],
+ fileOpener: ["path", "source"],
} as const satisfies {
[S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[];
};
diff --git a/examples/plugins/github/app.tsx b/examples/plugins/github/app.tsx
index 4031c1cd9..4f486e263 100644
--- a/examples/plugins/github/app.tsx
+++ b/examples/plugins/github/app.tsx
@@ -7,7 +7,7 @@
// GitHub integration, shrunk to a panel). "Send agent" buttons everywhere an
// issue or PR shows up. Deep links use the URL hash
// (#/issues///, #/pulls///) since navPanel
-// owns a single route today. A threadPanelAction opens the same PR view in a
+// owns /plugins/github/github/* via subPath routing. A threadPanelAction opens the same PR view in a
// thread's right panel, auto-resolved to that thread's PR.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -15,6 +15,7 @@ import {
useBbNavigate,
useRealtime,
useRpc,
+ type PluginNavPanelProps,
type PluginThreadPanelProps,
} from "@bb/plugin-sdk/app";
// Shimmed to the host's copy at build time (shared worker-pool context +
@@ -162,10 +163,14 @@ function relativeTime(iso: string): string {
}
// ---------------------------------------------------------------------------
-// Hash routing — the navPanel owns one route, so sub-navigation lives in the
-// URL hash: #/issues, #/pulls, #/new, #/issues///.
+// Sub-routing — the navPanel owns /plugins/github/github/*, so sub-navigation
+// lives in the route's subPath: "issues", "pulls", "new",
+// "issues///". Deep-linkable, and browser back/forward
+// walks panel history.
// ---------------------------------------------------------------------------
+const PANEL_PATH = "github";
+
type Route =
| { view: "issues" }
| { view: "pulls" }
@@ -173,8 +178,8 @@ type Route =
| { view: "issue"; repo: string; number: number }
| { view: "pull"; repo: string; number: number };
-function parseHash(hash: string): Route {
- const parts = hash.replace(/^#\/?/, "").split("/").filter((p) => p.length > 0);
+function parseSubPath(subPath: string): Route {
+ const parts = subPath.split("/").filter((p) => p.length > 0);
if (parts[0] === "pulls" && parts.length === 4) {
const number = Number(parts[3]);
if (Number.isFinite(number)) {
@@ -192,32 +197,30 @@ function parseHash(hash: string): Route {
return { view: "issues" };
}
-function routeToHash(route: Route): string {
+function routeToSubPath(route: Route): string {
switch (route.view) {
case "issues":
- return "#/issues";
+ return "issues";
case "pulls":
- return "#/pulls";
+ return "pulls";
case "new":
- return "#/new";
+ return "new";
case "issue":
- return `#/issues/${route.repo}/${route.number}`;
+ return `issues/${route.repo}/${route.number}`;
case "pull":
- return `#/pulls/${route.repo}/${route.number}`;
+ return `pulls/${route.repo}/${route.number}`;
}
}
-function useHashRoute(): [Route, (route: Route) => void] {
- const [route, setRoute] = useState(() => parseHash(window.location.hash));
- useEffect(() => {
- const onChange = () => setRoute(parseHash(window.location.hash));
- window.addEventListener("hashchange", onChange);
- return () => window.removeEventListener("hashchange", onChange);
- }, []);
- const navigate = useCallback((next: Route) => {
- window.location.hash = routeToHash(next);
- setRoute(next);
- }, []);
+function useSubPathRoute(subPath: string): [Route, (route: Route) => void] {
+ const bbNavigate = useBbNavigate();
+ const route = useMemo(() => parseSubPath(subPath), [subPath]);
+ const navigate = useCallback(
+ (next: Route) => {
+ bbNavigate.toPluginPanel(PANEL_PATH, { subPath: routeToSubPath(next) });
+ },
+ [bbNavigate],
+ );
return [route, navigate];
}
@@ -2117,8 +2120,8 @@ function PanelHeader() {
const QUERY_KEY = "bb-plugin-github:query";
const DEFAULT_QUERY = "is:open ";
-function GithubPanel() {
- const [route, navigate] = useHashRoute();
+function GithubPanel({ subPath }: PluginNavPanelProps) {
+ const [route, navigate] = useSubPathRoute(subPath);
const { status } = useStatus();
const [query, setQueryState] = useState(() => {
try {
diff --git a/examples/plugins/notes/README.md b/examples/plugins/notes/README.md
new file mode 100644
index 000000000..315a8f616
--- /dev/null
+++ b/examples/plugins/notes/README.md
@@ -0,0 +1,37 @@
+# bb-plugin-notes
+
+An Obsidian-style markdown notes plugin — the hero example for the
+`bb.sdk.files` host file API and the `fileOpener` / `useComposer()` /
+navPanel-`subPath` frontend surfaces.
+
+- **Notes nav panel** (`chrome: "none"`): mounted-directory tree + a
+ [Milkdown Crepe](https://milkdown.dev/) WYSIWYG editor, deep-linked via
+ the panel's `subPath` (`/plugins/notes/notes//`).
+- **Mounted directories** come from the `directories` setting
+ (comma-separated, `~` expands). Reads and saves go through
+ `bb.sdk.files` with `expectedSha256` compare-and-swap — a save that
+ races an agent editing the same file surfaces a reload/overwrite banner
+ instead of clobbering.
+- **File opener**: registers as an opener for `md`/`mdx`/`markdown`. Set
+ it as the default under Settings → "File openers" and markdown links
+ land in the editor instead of the read-only preview; right-click any
+ file link for a one-off "Open with…" choice in either direction.
+- **Chat integration**: "Add to chat" quotes the current selection (or the
+ whole note) into the composer draft via `useComposer().addQuote`;
+ "@-mention" inserts a pill that resolves the note's content at send time
+ through the plugin's mention provider. Typing `@` in the composer also
+ searches notes directly.
+- **Live refresh**: a background fs watcher publishes `notes-changed` over
+ `bb.realtime`, keeping the tree current while agents write notes.
+- Crepe's stylesheet is served from the plugin's own `bb.http` route
+ (`/api/v1/plugins/notes/http/crepe.css`) because plugin bundles ship only
+ Tailwind-compiled CSS; `--crepe-*` variables are remapped to host theme
+ tokens so the editor follows light/dark and custom palettes.
+
+Install from a bb checkout:
+
+```
+bb plugin install examples/plugins/notes
+bb plugin config notes set directories "~/Notes"
+bb plugin reload notes
+```
diff --git a/examples/plugins/notes/app.tsx b/examples/plugins/notes/app.tsx
new file mode 100644
index 000000000..a539d62ad
--- /dev/null
+++ b/examples/plugins/notes/app.tsx
@@ -0,0 +1,718 @@
+// bb-plugin-notes frontend: an Obsidian-style notes surface.
+// - navPanel "Notes" (chrome none): mounted-directory tree + Milkdown Crepe
+// WYSIWYG editor, deep-linked via the panel's subPath.
+// - threadPanelAction "Open note": the same editor in a thread's side panel.
+// - fileOpener "Notes editor" for md/mdx/markdown: workspace/host markdown
+// opened in the panel renders here instead of the read-only preview (set
+// as default via the tab's "Open with" menu).
+// - useComposer(): quote a selection (or the whole note) into the chat
+// draft, or insert an @-mention pill that resolves the note at send time.
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ definePluginApp,
+ useBbNavigate,
+ useComposer,
+ useRealtime,
+ useRpc,
+ type PluginFileOpenerProps,
+ type PluginNavPanelProps,
+ type PluginThreadPanelProps,
+} from "@bb/plugin-sdk/app";
+import { Crepe } from "@milkdown/crepe";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+
+// ---------------------------------------------------------------------------
+// rpc result shapes (rpc is untyped in V1 — narrow at the boundary).
+// ---------------------------------------------------------------------------
+
+interface MountListing {
+ name: string;
+ root: string;
+ files: { path: string; name: string }[];
+ error: string | null;
+}
+
+interface NoteContent {
+ content: string;
+ sha256: string;
+}
+
+type SaveResult =
+ | { outcome: "written"; sha256: string }
+ | { outcome: "conflict"; currentSha256: string | null };
+
+interface ResolvedFile {
+ absPath: string;
+ rootPath: string | null;
+ hostId: string | null;
+}
+
+function asMounts(value: unknown): MountListing[] {
+ const mounts = (value as { mounts?: unknown })?.mounts;
+ return Array.isArray(mounts) ? (mounts as MountListing[]) : [];
+}
+
+// ---------------------------------------------------------------------------
+// Crepe theme wiring. The plugin server serves Crepe's own stylesheet (the
+// frontend bundle ships only Tailwind CSS); host-token variable overrides
+// keep the editor on the app palette in both light and dark themes.
+// ---------------------------------------------------------------------------
+
+const CREPE_CSS_URL = "/api/v1/plugins/notes/http/crepe.css";
+const STYLE_MARKER = "data-bb-notes-styles";
+
+const EDITOR_THEME_CSS = `
+.bb-notes-editor .milkdown {
+ --crepe-color-background: transparent;
+ --crepe-color-on-background: var(--foreground);
+ --crepe-color-surface: var(--background);
+ --crepe-color-surface-low: var(--muted);
+ --crepe-color-on-surface: var(--foreground);
+ --crepe-color-on-surface-variant: var(--muted-foreground);
+ --crepe-color-outline: var(--border);
+ --crepe-color-primary: var(--primary);
+ --crepe-color-secondary: var(--secondary);
+ --crepe-color-on-secondary: var(--secondary-foreground);
+ --crepe-color-inverse: var(--foreground);
+ --crepe-color-on-inverse: var(--background);
+ --crepe-color-inline-code: var(--destructive);
+ --crepe-color-error: var(--destructive);
+ --crepe-color-hover: var(--accent);
+ --crepe-color-selected: var(--accent);
+ --crepe-color-inline-area: var(--muted);
+ --crepe-font-title: inherit;
+ --crepe-font-default: inherit;
+ --crepe-font-code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ height: 100%;
+ font-size: 14px;
+}
+.bb-notes-editor .milkdown .ProseMirror {
+ padding: 20px 28px 96px;
+}
+`;
+
+function ensureEditorStyles(): void {
+ if (document.head.querySelector(`[${STYLE_MARKER}]`)) return;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = CREPE_CSS_URL;
+ link.setAttribute(STYLE_MARKER, "link");
+ document.head.appendChild(link);
+ const style = document.createElement("style");
+ style.setAttribute(STYLE_MARKER, "style");
+ style.textContent = EDITOR_THEME_CSS;
+ document.head.appendChild(style);
+}
+
+// ---------------------------------------------------------------------------
+// The Crepe editor. Remounted (via key) per note; markdown flows out through
+// a ref so the effect never re-runs mid-edit.
+// ---------------------------------------------------------------------------
+
+function CrepeEditor({
+ initialValue,
+ onMarkdownChange,
+}: {
+ initialValue: string;
+ onMarkdownChange: (markdown: string) => void;
+}) {
+ const rootRef = useRef(null);
+ const onChangeRef = useRef(onMarkdownChange);
+ onChangeRef.current = onMarkdownChange;
+
+ useEffect(() => {
+ ensureEditorStyles();
+ const root = rootRef.current;
+ if (!root) return;
+ const crepe = new Crepe({ root, defaultValue: initialValue });
+ crepe.on((listener) => {
+ listener.markdownUpdated((_ctx, markdown) => {
+ onChangeRef.current(markdown);
+ });
+ });
+ void crepe.create();
+ return () => {
+ void crepe.destroy();
+ };
+ // initialValue is intentionally captured once — the editor owns the
+ // document after mount; callers remount with a new key to reload.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// One editor pane over an abstract load/save target.
+// ---------------------------------------------------------------------------
+
+interface NoteTarget {
+ /** Stable identity — remounts the editor when it changes. */
+ key: string;
+ /** Filename shown in the pane header. */
+ name: string;
+ load(): Promise;
+ save(content: string, expectedSha256: string | null | undefined): Promise;
+ /** Present for mount-backed notes: enables the @-mention button. */
+ mention?: { provider: string; id: string; label: string };
+}
+
+type PaneState =
+ | { phase: "loading" }
+ | { phase: "error"; message: string }
+ | { phase: "ready"; initialContent: string; sha256: string };
+
+function NoteEditorPane({ target }: { target: NoteTarget }) {
+ const composer = useComposer();
+ const [state, setState] = useState({ phase: "loading" });
+ const [dirty, setDirty] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [conflict, setConflict] = useState(false);
+ const [notice, setNotice] = useState(null);
+ // Latest markdown + the sha we loaded/saved against, outside render state
+ // so keystrokes don't re-render the pane.
+ const markdownRef = useRef("");
+ const sha256Ref = useRef(null);
+ const [reloadNonce, setReloadNonce] = useState(0);
+
+ useEffect(() => {
+ let alive = true;
+ setState({ phase: "loading" });
+ setDirty(false);
+ setConflict(false);
+ setNotice(null);
+ target
+ .load()
+ .then((note) => {
+ if (!alive) return;
+ markdownRef.current = note.content;
+ sha256Ref.current = note.sha256;
+ setState({
+ phase: "ready",
+ initialContent: note.content,
+ sha256: note.sha256,
+ });
+ })
+ .catch((error: unknown) => {
+ if (!alive) return;
+ setState({
+ phase: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ });
+ return () => {
+ alive = false;
+ };
+ }, [target.key, reloadNonce]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const save = useCallback(
+ async (options?: { force?: boolean }) => {
+ if (saving) return;
+ setSaving(true);
+ setNotice(null);
+ try {
+ const result = await target.save(
+ markdownRef.current,
+ options?.force ? undefined : sha256Ref.current,
+ );
+ if (result.outcome === "conflict") {
+ setConflict(true);
+ } else {
+ sha256Ref.current = result.sha256;
+ setDirty(false);
+ setConflict(false);
+ }
+ } catch (error) {
+ setNotice(error instanceof Error ? error.message : String(error));
+ } finally {
+ setSaving(false);
+ }
+ },
+ [saving, target],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "s") {
+ event.preventDefault();
+ void save();
+ }
+ },
+ [save],
+ );
+
+ const quoteToChat = useCallback(() => {
+ const selection = window.getSelection()?.toString() ?? "";
+ composer.addQuote(
+ selection.trim().length > 0 ? selection : markdownRef.current,
+ );
+ }, [composer]);
+
+ const mentionInChat = useCallback(() => {
+ if (target.mention) composer.insertMention(target.mention);
+ }, [composer, target.mention]);
+
+ return (
+
+
+
+ {target.name}
+
+ {dirty ? (
+
edited
+ ) : null}
+
+
+ Add to chat
+
+ {target.mention ? (
+
+ @-mention
+
+ ) : null}
+
void save()}
+ >
+ {saving ? "Saving…" : "Save"}
+
+
+ {conflict ? (
+
+ This file changed on disk since it was loaded.
+ setReloadNonce((nonce) => nonce + 1)}
+ >
+ Reload
+
+ void save({ force: true })}
+ >
+ Overwrite
+
+
+ ) : null}
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {state.phase === "loading" ? (
+
Loading…
+ ) : state.phase === "error" ? (
+
{state.message}
+ ) : (
+
{
+ markdownRef.current = markdown;
+ setDirty(true);
+ }}
+ />
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Mount-backed note targets (nav panel + thread panel action).
+// ---------------------------------------------------------------------------
+
+function useMounts(): {
+ mounts: MountListing[] | null;
+ error: string | null;
+ refresh: () => void;
+} {
+ const rpc = useRpc();
+ const [mounts, setMounts] = useState(null);
+ const [error, setError] = useState(null);
+ const refresh = useCallback(() => {
+ rpc
+ .call("listNotes")
+ .then((result) => {
+ setMounts(asMounts(result));
+ setError(null);
+ })
+ .catch((rpcError: unknown) => {
+ setError(
+ rpcError instanceof Error ? rpcError.message : String(rpcError),
+ );
+ });
+ }, [rpc]);
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+ useRealtime("notes-changed", refresh);
+ return { mounts, error, refresh };
+}
+
+function useMountNoteTarget(
+ mount: MountListing | undefined,
+ notePath: string | null,
+): NoteTarget | null {
+ const rpc = useRpc();
+ return useMemo(() => {
+ if (!mount || notePath === null) return null;
+ const root = mount.root;
+ return {
+ key: `${root}\n${notePath}`,
+ name: notePath.split("/").at(-1) ?? notePath,
+ async load() {
+ return (await rpc.call("readNote", {
+ root,
+ path: notePath,
+ })) as NoteContent;
+ },
+ async save(content, expectedSha256) {
+ return (await rpc.call("saveNote", {
+ root,
+ path: notePath,
+ content,
+ ...(expectedSha256 !== undefined ? { expectedSha256 } : {}),
+ })) as SaveResult;
+ },
+ mention: {
+ provider: "notes",
+ id: `${root}\n${notePath}`,
+ label: notePath.split("/").at(-1) ?? notePath,
+ },
+ };
+ }, [mount, notePath, rpc]);
+}
+
+// ---------------------------------------------------------------------------
+// The notes tree (shared by the nav panel and the thread panel picker).
+// ---------------------------------------------------------------------------
+
+function NotesTree({
+ mounts,
+ error,
+ selectedKey,
+ onSelect,
+ onCreate,
+}: {
+ mounts: MountListing[] | null;
+ error: string | null;
+ selectedKey: string | null;
+ onSelect: (mountIndex: number, path: string) => void;
+ onCreate?: (mountIndex: number, name: string) => void;
+}) {
+ const [draftByMount, setDraftByMount] = useState>({});
+ if (error) {
+ return {error}
;
+ }
+ if (mounts === null) {
+ return Loading…
;
+ }
+ if (mounts.length === 0) {
+ return (
+
+ No note directories yet. Add one under Settings → Plugins → notes
+ (e.g. ~/Notes), then reload the plugin.
+
+ );
+ }
+ return (
+
+ {mounts.map((mount, mountIndex) => (
+
+
+ {mount.name}
+
+ {mount.error ? (
+
{mount.error}
+ ) : (
+
+ {mount.files.map((file) => {
+ const key = `${mountIndex}/${file.path}`;
+ return (
+
onSelect(mountIndex, file.path)}
+ className={cn(
+ "truncate rounded px-2 py-1 text-left text-sm hover:bg-state-hover",
+ selectedKey === key && "bg-state-active font-medium",
+ )}
+ title={file.path}
+ >
+ {file.path}
+
+ );
+ })}
+ {mount.files.length === 0 ? (
+
+ No markdown files yet.
+
+ ) : null}
+
+ )}
+ {onCreate ? (
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// navPanel: tree + editor, deep-linked via subPath ("/").
+// ---------------------------------------------------------------------------
+
+function parseSubPath(subPath: string): { mountIndex: number; path: string } | null {
+ const slash = subPath.indexOf("/");
+ if (slash === -1) return null;
+ const mountIndex = Number(subPath.slice(0, slash));
+ const path = subPath.slice(slash + 1);
+ if (!Number.isInteger(mountIndex) || mountIndex < 0 || path.length === 0) {
+ return null;
+ }
+ return { mountIndex, path };
+}
+
+function NotesPanel({ subPath }: PluginNavPanelProps) {
+ const rpc = useRpc();
+ const navigate = useBbNavigate();
+ const { mounts, error, refresh } = useMounts();
+ const selected = useMemo(() => parseSubPath(subPath), [subPath]);
+ const selectedMount =
+ selected === null ? undefined : mounts?.[selected.mountIndex];
+ const target = useMountNoteTarget(
+ selectedMount,
+ selected === null ? null : selected.path,
+ );
+
+ const openNote = useCallback(
+ (mountIndex: number, path: string) => {
+ navigate.toPluginPanel("notes", { subPath: `${mountIndex}/${path}` });
+ },
+ [navigate],
+ );
+
+ const createNote = useCallback(
+ (mountIndex: number, name: string) => {
+ const mount = mounts?.[mountIndex];
+ if (!mount) return;
+ rpc
+ .call("saveNote", {
+ root: mount.root,
+ path: name,
+ content: `# ${name.replace(/\.mdx?$|\.markdown$/i, "")}\n\n`,
+ expectedSha256: null,
+ })
+ .then(() => {
+ refresh();
+ openNote(mountIndex, name);
+ })
+ .catch((createError: unknown) => {
+ console.warn(`[plugin:notes] create failed:`, createError);
+ });
+ },
+ [mounts, openNote, refresh, rpc],
+ );
+
+ return (
+
+
+
+
+ {target ? (
+
+ ) : (
+
+ Select a note to start writing.
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// threadPanelAction: the same editor inside a thread's side panel.
+// ---------------------------------------------------------------------------
+
+function NotePanelTab(_props: PluginThreadPanelProps) {
+ const { mounts, error } = useMounts();
+ const [selected, setSelected] = useState<{
+ mountIndex: number;
+ path: string;
+ } | null>(null);
+ const selectedMount =
+ selected === null ? undefined : mounts?.[selected.mountIndex];
+ const target = useMountNoteTarget(
+ selectedMount,
+ selected === null ? null : selected.path,
+ );
+
+ if (target) {
+ return (
+
+
+ setSelected(null)}>
+ ← All notes
+
+
+
+
+ );
+ }
+ return (
+
+ setSelected({ mountIndex, path })}
+ />
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// fileOpener: workspace/host markdown in the notes editor.
+// ---------------------------------------------------------------------------
+
+function NotesFileOpener({ path, source }: PluginFileOpenerProps) {
+ const rpc = useRpc();
+ const [resolved, setResolved] = useState(null);
+ const [resolveError, setResolveError] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ setResolved(null);
+ setResolveError(null);
+ rpc
+ .call("resolveFile", { source, path })
+ .then((result) => {
+ if (alive) setResolved(result as ResolvedFile);
+ })
+ .catch((error: unknown) => {
+ if (alive) {
+ setResolveError(
+ error instanceof Error ? error.message : String(error),
+ );
+ }
+ });
+ return () => {
+ alive = false;
+ };
+ }, [path, rpc, source]);
+
+ const target = useMemo(() => {
+ if (resolved === null) return null;
+ return {
+ key: `file:${resolved.absPath}`,
+ name: path.split("/").at(-1) ?? path,
+ async load() {
+ return (await rpc.call("readFile", resolved)) as NoteContent;
+ },
+ async save(content, expectedSha256) {
+ return (await rpc.call("writeFile", {
+ ...resolved,
+ content,
+ ...(expectedSha256 !== undefined ? { expectedSha256 } : {}),
+ })) as SaveResult;
+ },
+ };
+ }, [path, resolved, rpc]);
+
+ if (resolveError !== null) {
+ return (
+
+ {resolveError} — use the tab's "Open with" menu to switch back to the
+ built-in preview.
+
+ );
+ }
+ if (target === null) {
+ return Loading…
;
+ }
+ return (
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+
+export default definePluginApp((app) => {
+ app.slots.navPanel({
+ id: "notes",
+ title: "Notes",
+ icon: "FileText",
+ path: "notes",
+ chrome: "none",
+ component: NotesPanel,
+ });
+ app.slots.threadPanelAction({
+ id: "note",
+ title: "Open note",
+ icon: "FileText",
+ component: NotePanelTab,
+ });
+ app.slots.fileOpener({
+ id: "editor",
+ title: "Notes editor",
+ extensions: ["md", "mdx", "markdown"],
+ component: NotesFileOpener,
+ });
+});
diff --git a/examples/plugins/notes/components/ui/button.tsx b/examples/plugins/notes/components/ui/button.tsx
new file mode 100644
index 000000000..acc7c180d
--- /dev/null
+++ b/examples/plugins/notes/components/ui/button.tsx
@@ -0,0 +1,61 @@
+/* shadcn/ui-derived */
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { CONTROL_HOVER_TRANSITION } from "./motion.js";
+
+const buttonVariants = cva(
+ `inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ${CONTROL_HOVER_TRANSITION} focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-foreground text-background hover:bg-foreground/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-transparent hover:bg-state-hover hover:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-state-hover hover:text-foreground aria-pressed:bg-state-active aria-pressed:text-foreground aria-pressed:hover:bg-state-active data-[state=open]:bg-state-active data-[state=open]:text-foreground data-[state=open]:hover:bg-state-active",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends
+ Omit, "title">,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts b/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts
new file mode 100644
index 000000000..b371ccb99
--- /dev/null
+++ b/examples/plugins/notes/components/ui/coarse-pointer-sizing.ts
@@ -0,0 +1,62 @@
+export const COARSE_POINTER_TEXT_BASE_CLASS =
+ "text-sm max-md:pointer-coarse:text-base";
+
+export const COARSE_POINTER_TEXT_SM_CLASS =
+ "text-xs max-md:pointer-coarse:text-sm";
+
+export const COARSE_POINTER_ICON_SIZE_CLASS =
+ "size-4 max-md:pointer-coarse:size-5";
+
+export const COARSE_POINTER_ICON_SIZE_SHRINK_CLASS =
+ "size-4 shrink-0 max-md:pointer-coarse:size-5";
+
+export const COARSE_POINTER_COMPACT_ICON_SIZE_CLASS =
+ "size-3.5 max-md:pointer-coarse:size-5";
+
+export const COARSE_POINTER_COMPACT_ICON_SIZE_SHRINK_CLASS =
+ "size-3.5 shrink-0 max-md:pointer-coarse:size-5";
+
+export const COARSE_POINTER_DOT_SIZE_CLASS =
+ "size-1.5 max-md:pointer-coarse:size-2";
+
+export const COARSE_POINTER_GLYPH_BOX_CLASS =
+ "h-4 w-4 max-md:pointer-coarse:h-5 max-md:pointer-coarse:w-5";
+
+export const COARSE_POINTER_CHECK_SLOT_CLASS =
+ "h-3.5 w-3.5 max-md:pointer-coarse:h-5 max-md:pointer-coarse:w-5";
+
+export const COARSE_POINTER_HEADER_ICON_BUTTON_CLASS =
+ "h-[28px] w-[28px] rounded-md p-0 [&_svg]:size-[16px] max-md:pointer-coarse:h-[36px] max-md:pointer-coarse:w-[36px] max-md:pointer-coarse:[&_svg]:size-[20px]";
+
+export const COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS =
+ "h-7 w-7 rounded-md p-0 [&_svg]:size-3.5 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&_svg]:size-5";
+
+export const COARSE_POINTER_CHILD_ICON_BUTTON_CLASS =
+ "h-8 w-8 justify-center p-0 [&>svg]:size-4 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&>svg]:size-5";
+
+export const COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS =
+ "h-7 rounded-md border-border bg-transparent px-2 text-xs font-medium text-foreground shadow-none hover:bg-state-hover hover:text-foreground max-md:pointer-coarse:h-9";
+
+export const COARSE_POINTER_PROMPT_ACTION_BUTTON_CLASS =
+ "h-8 px-2 transition-all max-md:pointer-coarse:h-10 max-md:pointer-coarse:px-2.5";
+
+export const COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS =
+ "size-auto h-8 px-2 transition-all max-md:pointer-coarse:h-10 max-md:pointer-coarse:px-2.5";
+
+export const COARSE_POINTER_PROMPT_COMBO_BUTTON_CLASS =
+ "h-8 w-8 rounded-l-none border-l border-l-primary-foreground/20 px-0 transition-all hover:border-l-primary-foreground/30 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10";
+
+export const COARSE_POINTER_INPUT_HEIGHT_CLASS =
+ "h-9 max-md:pointer-coarse:h-10";
+
+export const COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS =
+ "h-7 max-md:pointer-coarse:h-9";
+
+export const COARSE_POINTER_ROW_HEIGHT_CLASS =
+ "h-[var(--bb-sidebar-row-height)] max-md:pointer-coarse:h-[var(--bb-sidebar-row-height-coarse)]";
+
+export const COARSE_POINTER_PROVIDER_TAB_SIZE_CLASS =
+ "h-7 w-6 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9";
+
+export const COARSE_POINTER_ROW_ACTION_SIZE_CLASS =
+ "h-7 w-7 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9";
diff --git a/examples/plugins/notes/components/ui/input.tsx b/examples/plugins/notes/components/ui/input.tsx
new file mode 100644
index 000000000..64087b6ed
--- /dev/null
+++ b/examples/plugins/notes/components/ui/input.tsx
@@ -0,0 +1,31 @@
+/* shadcn/ui-derived */
+import * as React from "react";
+
+import {
+ COARSE_POINTER_INPUT_HEIGHT_CLASS,
+ COARSE_POINTER_TEXT_BASE_CLASS,
+} from "./coarse-pointer-sizing.js";
+import { cn } from "@/lib/utils";
+import { CONTROL_HOVER_TRANSITION } from "./motion.js";
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/examples/plugins/notes/components/ui/motion.ts b/examples/plugins/notes/components/ui/motion.ts
new file mode 100644
index 000000000..e40d4c62a
--- /dev/null
+++ b/examples/plugins/notes/components/ui/motion.ts
@@ -0,0 +1,21 @@
+/**
+ * Shared hover-motion heuristic for primitives, so the whole UI speaks one
+ * timing language instead of ad-hoc per-component transitions:
+ *
+ * - CONTROL_HOVER_TRANSITION — interactive controls (buttons, icon buttons):
+ * the hover/active fill snaps IN instantly (0ms) and eases OUT lazily (150ms).
+ * Immediate feedback on the way in, never twitchy on the way out. The trick:
+ * CSS applies the *end state's* transition-duration for each direction, so a
+ * base `duration-150` governs hover-out while `hover:duration-0` makes
+ * hover-in instant.
+ * - LIST_HOVER_TRANSITION — dense list/menu rows (menu items, list rows): no
+ * transition at all (instant both ways), so the highlight tracks the pointer
+ * and arrow keys exactly, with no lag during fast navigation.
+ *
+ * Reach for one of these rather than a bare `transition-colors` on anything with
+ * a hover/active state.
+ */
+export const CONTROL_HOVER_TRANSITION =
+ "transition-colors duration-150 hover:duration-0";
+
+export const LIST_HOVER_TRANSITION = "transition-none";
diff --git a/examples/plugins/notes/components/ui/skeleton.tsx b/examples/plugins/notes/components/ui/skeleton.tsx
new file mode 100644
index 000000000..971d1d851
--- /dev/null
+++ b/examples/plugins/notes/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+/* shadcn/ui-derived */
+import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+export function Skeleton({
+ className,
+ ...props
+}: HTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/examples/plugins/notes/lib/utils.ts b/examples/plugins/notes/lib/utils.ts
new file mode 100644
index 000000000..365058ceb
--- /dev/null
+++ b/examples/plugins/notes/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/examples/plugins/notes/logo.svg b/examples/plugins/notes/logo.svg
new file mode 100644
index 000000000..f62e895f5
--- /dev/null
+++ b/examples/plugins/notes/logo.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/examples/plugins/notes/package.json b/examples/plugins/notes/package.json
new file mode 100644
index 000000000..2e9c50711
--- /dev/null
+++ b/examples/plugins/notes/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "bb-plugin-notes",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "bb": ">=0.0"
+ },
+ "bb": {
+ "server": "./server.ts",
+ "app": "./app.tsx"
+ },
+ "keywords": [
+ "bb-plugin"
+ ],
+ "devDependencies": {
+ "@bb/plugin-sdk": "workspace:*",
+ "@types/node": "^22.0.0",
+ "@types/react": "^19.0.0",
+ "typescript": "^5.7.0"
+ },
+ "dependencies": {
+ "@milkdown/crepe": "^7.5.0",
+ "@radix-ui/react-slot": "^1.2.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^3.4.0"
+ }
+}
diff --git a/examples/plugins/notes/server.ts b/examples/plugins/notes/server.ts
new file mode 100644
index 000000000..7476da9d4
--- /dev/null
+++ b/examples/plugins/notes/server.ts
@@ -0,0 +1,378 @@
+// bb-plugin-notes — Obsidian-style markdown notes (plugin design hero for
+// bb.sdk.files + the fileOpener/useComposer/subPath surfaces).
+//
+// The backend is a thin file service over `bb.sdk.files`: the user mounts
+// directories via a setting, the rpc surface lists/reads/CAS-saves markdown
+// under those mounts, an fs watcher pushes tree refreshes over realtime, and
+// a mention provider lets `@`-mentions resolve a note's content at send
+// time. Milkdown Crepe's theme CSS is served from the plugin's own http
+// route (the frontend bundle pipeline ships only Tailwind-compiled CSS).
+import { watch, type FSWatcher } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import os from "node:os";
+import path from "node:path";
+import type { BbPluginApi } from "@bb/plugin-sdk";
+
+interface NotesMount {
+ name: string;
+ root: string;
+}
+
+interface MountListing {
+ name: string;
+ root: string;
+ files: { path: string; name: string }[];
+ error: string | null;
+}
+
+interface OpenerSource {
+ kind: "workspace" | "host" | "thread-storage";
+ threadId: string | null;
+ environmentId: string | null;
+ projectId: string | null;
+}
+
+const NOTE_EXTENSIONS = new Set(["md", "mdx", "markdown"]);
+const LIST_LIMIT = 2000;
+
+function isNotePath(filePath: string): boolean {
+ const dotIndex = filePath.lastIndexOf(".");
+ if (dotIndex === -1) return false;
+ return NOTE_EXTENSIONS.has(filePath.slice(dotIndex + 1).toLowerCase());
+}
+
+function expandHome(rawPath: string): string {
+ if (rawPath === "~") return os.homedir();
+ if (rawPath.startsWith("~/")) return path.join(os.homedir(), rawPath.slice(2));
+ return rawPath;
+}
+
+function requireString(value: unknown, field: string): string {
+ if (typeof value !== "string" || value.length === 0) {
+ throw new Error(`"${field}" must be a non-empty string`);
+ }
+ return value;
+}
+
+/** Reject note paths that could step outside a mount. */
+function requireRelativeNotePath(value: unknown): string {
+ const notePath = requireString(value, "path");
+ const segments = notePath.split("/");
+ if (
+ path.isAbsolute(notePath) ||
+ segments.some((s) => s.length === 0 || s === "." || s === "..")
+ ) {
+ throw new Error(`Invalid note path: ${notePath}`);
+ }
+ return notePath;
+}
+
+/**
+ * Inline the `@import './x.css'` chain of Crepe's common theme stylesheet so
+ * one http response carries the whole thing (a to this route has no
+ * base URL the relative imports could resolve against).
+ */
+async function loadCrepeCss(): Promise {
+ const require = createRequire(import.meta.url);
+ const entry = require.resolve("@milkdown/crepe/theme/common/style.css");
+ // Bare-specifier imports (e.g. @milkdown/kit/...) are Crepe's own deps —
+ // resolve them from Crepe's package context, not the plugin's.
+ const crepeRequire = createRequire(entry);
+ const seen = new Set();
+ const inline = async (file: string): Promise => {
+ if (seen.has(file)) return "";
+ seen.add(file);
+ const source = await readFile(file, "utf8");
+ const parts = await Promise.all(
+ source.split("\n").map(async (line) => {
+ const match = /^@import\s+['"](.+\.css)['"];/.exec(line.trim());
+ if (!match?.[1]) return line;
+ const specifier = match[1];
+ // Relative imports resolve against the importing file; bare
+ // specifiers (e.g. @milkdown/kit/prose/view/style/prosemirror.css)
+ // resolve through node — both end up inlined.
+ const resolved = specifier.startsWith(".")
+ ? path.join(path.dirname(file), specifier)
+ : crepeRequire.resolve(specifier);
+ return inline(resolved);
+ }),
+ );
+ return parts.join("\n");
+ };
+ return inline(entry);
+}
+
+export default async function plugin(bb: BbPluginApi) {
+ const settings = bb.settings.define({
+ directories: {
+ type: "string",
+ label: "Note directories (comma-separated, ~ ok)",
+ default: "",
+ },
+ });
+
+ async function getMounts(): Promise {
+ const { directories } = await settings.get();
+ const roots = directories
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ .map((entry) => path.resolve(expandHome(entry)));
+ const seenNames = new Map();
+ return roots.map((root) => {
+ const base = path.basename(root) || root;
+ const count = seenNames.get(base) ?? 0;
+ seenNames.set(base, count + 1);
+ return { name: count === 0 ? base : `${base} (${count + 1})`, root };
+ });
+ }
+
+ async function requireMount(rootInput: unknown): Promise {
+ const root = requireString(rootInput, "root");
+ const mount = (await getMounts()).find((m) => m.root === root);
+ if (!mount) {
+ throw new Error(
+ `"${root}" is not a configured notes directory — add it in Settings → Plugins → notes`,
+ );
+ }
+ return mount;
+ }
+
+ if ((await getMounts()).length === 0) {
+ bb.status.needsConfiguration(
+ "Set the notes directories setting (e.g. ~/Notes), then `bb plugin reload notes`.",
+ );
+ }
+
+ // --- rpc ------------------------------------------------------------------
+
+ bb.rpc.register({
+ async listNotes(): Promise<{ mounts: MountListing[] }> {
+ const mounts = await getMounts();
+ const listings = await Promise.all(
+ mounts.map(async (mount): Promise => {
+ try {
+ const result = await bb.sdk.files.list({
+ path: mount.root,
+ limit: LIST_LIMIT,
+ });
+ const files = result.files
+ .filter((file) => isNotePath(file.path))
+ .sort((a, b) => a.path.localeCompare(b.path))
+ .map((file) => ({ path: file.path, name: file.name }));
+ return { ...mount, files, error: null };
+ } catch (error) {
+ return {
+ ...mount,
+ files: [],
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }),
+ );
+ return { mounts: listings };
+ },
+
+ async readNote(input: { root?: unknown; path?: unknown }) {
+ const mount = await requireMount(input.root);
+ const notePath = requireRelativeNotePath(input.path);
+ const file = await bb.sdk.files.read({
+ path: path.join(mount.root, notePath),
+ rootPath: mount.root,
+ });
+ return { content: file.content, sha256: file.sha256 };
+ },
+
+ async saveNote(input: {
+ root?: unknown;
+ path?: unknown;
+ content?: unknown;
+ expectedSha256?: unknown;
+ }) {
+ const mount = await requireMount(input.root);
+ const notePath = requireRelativeNotePath(input.path);
+ if (typeof input.content !== "string") {
+ throw new Error(`"content" must be a string`);
+ }
+ const result = await bb.sdk.files.write({
+ path: path.join(mount.root, notePath),
+ rootPath: mount.root,
+ content: input.content,
+ createParents: true,
+ ...(input.expectedSha256 === null ||
+ typeof input.expectedSha256 === "string"
+ ? { expectedSha256: input.expectedSha256 }
+ : {}),
+ });
+ return result;
+ },
+
+ /**
+ * Resolve a fileOpener target into { absPath, rootPath, hostId } the
+ * read/write file rpcs below can use. Workspace paths are relative to
+ * the environment's worktree; host paths are already absolute.
+ */
+ async resolveFile(input: { source?: OpenerSource; path?: unknown }) {
+ const filePath = requireString(input.path, "path");
+ const source = input.source;
+ if (source?.kind === "host") {
+ return { absPath: filePath, rootPath: null, hostId: null };
+ }
+ if (source?.kind === "workspace" && source.environmentId) {
+ const environment = await bb.sdk.environments.get({
+ environmentId: source.environmentId,
+ });
+ if (!environment.path) {
+ throw new Error("This environment has no workspace path");
+ }
+ return {
+ absPath: path.join(environment.path, filePath),
+ rootPath: environment.path,
+ hostId: environment.hostId,
+ };
+ }
+ throw new Error(
+ "Only workspace and host files can open in the notes editor",
+ );
+ },
+
+ async readFile(input: {
+ absPath?: unknown;
+ rootPath?: unknown;
+ hostId?: unknown;
+ }) {
+ const file = await bb.sdk.files.read({
+ path: requireString(input.absPath, "absPath"),
+ ...(typeof input.rootPath === "string"
+ ? { rootPath: input.rootPath }
+ : {}),
+ ...(typeof input.hostId === "string" ? { hostId: input.hostId } : {}),
+ });
+ return { content: file.content, sha256: file.sha256 };
+ },
+
+ async writeFile(input: {
+ absPath?: unknown;
+ rootPath?: unknown;
+ hostId?: unknown;
+ content?: unknown;
+ expectedSha256?: unknown;
+ }) {
+ if (typeof input.content !== "string") {
+ throw new Error(`"content" must be a string`);
+ }
+ return bb.sdk.files.write({
+ path: requireString(input.absPath, "absPath"),
+ content: input.content,
+ ...(typeof input.rootPath === "string"
+ ? { rootPath: input.rootPath }
+ : {}),
+ ...(typeof input.hostId === "string" ? { hostId: input.hostId } : {}),
+ ...(input.expectedSha256 === null ||
+ typeof input.expectedSha256 === "string"
+ ? { expectedSha256: input.expectedSha256 }
+ : {}),
+ });
+ },
+ });
+
+ // --- live tree refresh ------------------------------------------------------
+
+ bb.background.service("notes-watcher", {
+ start(signal) {
+ const watchers: FSWatcher[] = [];
+ let debounce: NodeJS.Timeout | null = null;
+ void getMounts().then((mounts) => {
+ if (signal.aborted) return;
+ for (const mount of mounts) {
+ try {
+ // Recursive fs.watch is unavailable on some platforms — the
+ // tree then refreshes only on demand, which is fine.
+ const watcher = watch(mount.root, { recursive: true }, () => {
+ if (debounce) clearTimeout(debounce);
+ debounce = setTimeout(() => {
+ bb.realtime.publish("notes-changed", {});
+ }, 250);
+ });
+ watcher.on("error", () => watcher.close());
+ watchers.push(watcher);
+ } catch {
+ bb.log.warn(`cannot watch ${mount.root}; refresh is manual`);
+ }
+ }
+ });
+ return new Promise((resolve) => {
+ signal.addEventListener("abort", () => {
+ if (debounce) clearTimeout(debounce);
+ for (const watcher of watchers) watcher.close();
+ resolve();
+ });
+ });
+ },
+ });
+
+ // --- Crepe theme css ---------------------------------------------------------
+
+ let crepeCss: string | null = null;
+ bb.http.route("GET", "/crepe.css", async () => {
+ crepeCss ??= await loadCrepeCss();
+ return new Response(crepeCss, {
+ headers: {
+ "content-type": "text/css; charset=utf-8",
+ "cache-control": "no-store",
+ },
+ });
+ });
+
+ // --- @note mentions ------------------------------------------------------------
+
+ bb.ui.registerMentionProvider({
+ id: "notes",
+ label: "Notes",
+ async search({ query }) {
+ const mounts = await getMounts();
+ const needle = query.toLowerCase();
+ const items: { id: string; title: string; subtitle: string }[] = [];
+ for (const mount of mounts) {
+ try {
+ const result = await bb.sdk.files.list({
+ path: mount.root,
+ query,
+ limit: 25,
+ });
+ for (const file of result.files) {
+ if (!isNotePath(file.path)) continue;
+ if (
+ needle.length > 0 &&
+ !file.path.toLowerCase().includes(needle)
+ ) {
+ continue;
+ }
+ items.push({
+ id: `${mount.root} ${file.path}`,
+ title: file.name,
+ subtitle: `${mount.name}/${file.path}`,
+ });
+ }
+ } catch {
+ // Unreachable mount: contribute nothing from it.
+ }
+ }
+ return items.slice(0, 25);
+ },
+ async resolve(itemId) {
+ const [root, notePath] = itemId.split(" ");
+ if (!root || !notePath) throw new Error(`Unknown note: ${itemId}`);
+ const mount = (await getMounts()).find((m) => m.root === root);
+ if (!mount) throw new Error(`"${root}" is no longer a notes directory`);
+ const file = await bb.sdk.files.read({
+ path: path.join(root, notePath),
+ rootPath: root,
+ });
+ return {
+ context: `Note ${notePath} (from ${mount.name}):\n\n${file.content}`,
+ };
+ },
+ });
+}
diff --git a/examples/plugins/notes/tsconfig.json b/examples/plugins/notes/tsconfig.json
new file mode 100644
index 000000000..12f3586d8
--- /dev/null
+++ b/examples/plugins/notes/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "lib": [
+ "ES2022",
+ "DOM"
+ ],
+ "noEmit": true,
+ "skipLibCheck": true,
+ "types": [
+ "node"
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ }
+ },
+ "include": [
+ "server.ts",
+ "app.tsx",
+ "components",
+ "lib",
+ "hooks"
+ ]
+}
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index c5e57bb8f..dc9bdc6d0 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -400,6 +400,32 @@ const hostFileMetadataCommandSchema = z
})
.strict();
+/**
+ * Write a file at an absolute host path. Mirrors `host.read_file`'s
+ * containment contract: when `rootPath` is provided, the daemon enforces that
+ * the resolved target stays under that declared absolute root (following
+ * symlinks on the nearest existing ancestor).
+ *
+ * `expectedSha256` is the optimistic-concurrency guard for read-modify-write
+ * callers (editors saving over files agents may also touch):
+ * - omitted → unconditional write
+ * - a hash → write only when the current content hashes to it
+ * - null → write only when the file does not exist yet (create)
+ * A failed guard is the `conflict` result, not an error, so the caller gets
+ * the current hash to re-read against.
+ */
+const hostWriteFileCommandSchema = z
+ .object({
+ type: z.literal("host.write_file"),
+ path: z.string().min(1),
+ rootPath: z.string().min(1).optional(),
+ content: z.string(),
+ contentEncoding: z.enum(["utf8", "base64"]),
+ createParents: z.boolean(),
+ expectedSha256: z.string().nullable().optional(),
+ })
+ .strict();
+
const hostListFilesCommandSchema = z.object({
type: z.literal("host.list_files"),
path: z.string().min(1),
@@ -770,8 +796,29 @@ const fileReadResultSchema = z.object({
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative(),
modifiedAtMs: z.number().nonnegative().optional(),
+ // Hash of the returned bytes, so editors can do compare-and-swap saves via
+ // `host.write_file`'s `expectedSha256`.
+ sha256: z.string(),
});
+const fileWriteResultSchema = z.discriminatedUnion("outcome", [
+ z
+ .object({
+ outcome: z.literal("written"),
+ sha256: z.string(),
+ sizeBytes: z.number().int().nonnegative(),
+ })
+ .strict(),
+ z
+ .object({
+ outcome: z.literal("conflict"),
+ // Hash of the content currently on disk; null when the file does not
+ // exist (the caller expected it to).
+ currentSha256: z.string().nullable(),
+ })
+ .strict(),
+]);
+
const fileMetadataResultSchema = z.object({
path: z.string(),
modifiedAtMs: z.number().nonnegative(),
@@ -1276,6 +1323,15 @@ export const hostDaemonCommandRegistry = {
flushEventsBeforeResult: false,
envLane: null,
}),
+ "host.write_file": defineHostDaemonCommandDescriptor({
+ type: "host.write_file",
+ schema: hostWriteFileCommandSchema,
+ resultSchema: fileWriteResultSchema,
+ transport: "onlineRpc",
+ retryable: false,
+ flushEventsBeforeResult: false,
+ envLane: null,
+ }),
"provider.list_models": defineHostDaemonCommandDescriptor({
type: "provider.list_models",
schema: providerListModelsCommandSchema,
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index 4c680499a..bc36ae01c 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -307,6 +307,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion(
onlineRpcResponseSuccessSchemaFor("host.list_branches"),
onlineRpcResponseSuccessSchemaFor("host.read_file"),
onlineRpcResponseSuccessSchemaFor("host.read_file_relative"),
+ onlineRpcResponseSuccessSchemaFor("host.write_file"),
onlineRpcResponseSuccessSchemaFor("provider.list_models"),
onlineRpcResponseSuccessSchemaFor("known_acp_agents.status"),
onlineRpcResponseSuccessSchemaFor("provider.usage"),
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index 607bd858f..71c686084 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -231,6 +231,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
contentEncoding: "utf8",
mimeType: "text/html",
sizeBytes: 15,
+ sha256: "a".repeat(64),
},
"host.read_file_relative": {
path: "assets/logo.png",
@@ -238,6 +239,12 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
contentEncoding: "base64",
mimeType: "image/png",
sizeBytes: 8,
+ sha256: "b".repeat(64),
+ },
+ "host.write_file": {
+ outcome: "written",
+ sha256: "c".repeat(64),
+ sizeBytes: 12,
},
"provider.list_models": {
models: [
@@ -581,6 +588,8 @@ const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = {
"dynamic ACP model selection omits selectFlag when the agent cannot pin a model at launch.",
"hostDaemonCommandSchema.checkout":
"environment.provision only includes checkout instructions for unmanaged workspaces that requested a branch mutation.",
+ "hostDaemonOnlineRpcCommandSchema.expectedSha256":
+ "host.write_file may omit expectedSha256 for unconditional writes; a hash is the compare-and-swap guard and null means create-only.",
"hostDaemonOnlineRpcCommandSchema.mergeBaseBranch":
"workspace.status may omit mergeBaseBranch when the caller only needs working-tree state.",
"hostDaemonOnlineRpcCommandSchema.acpLaunchSpec":
@@ -2209,6 +2218,7 @@ describe("host-daemon command schemas", () => {
contentEncoding: "utf8",
mimeType: "text/markdown",
sizeBytes: 13,
+ sha256: "d".repeat(64),
}),
).toMatchObject({
path: "/tmp/bb-data/thread-storage/thread-123/notes.md",
@@ -2223,6 +2233,7 @@ describe("host-daemon command schemas", () => {
contentEncoding: "base64",
mimeType: "image/png",
sizeBytes: 8,
+ sha256: "f".repeat(64),
}),
).toMatchObject({
path: "assets/logo.png",
@@ -2789,6 +2800,7 @@ describe("host-daemon session schemas", () => {
mimeType: "text/markdown",
modifiedAtMs: 1234.5,
sizeBytes: 13,
+ sha256: "e".repeat(64),
},
}),
).toEqual({
@@ -2803,6 +2815,7 @@ describe("host-daemon session schemas", () => {
mimeType: "text/markdown",
modifiedAtMs: 1234.5,
sizeBytes: 13,
+ sha256: "e".repeat(64),
},
});
@@ -2819,6 +2832,7 @@ describe("host-daemon session schemas", () => {
mimeType: "image/png",
modifiedAtMs: 1234.5,
sizeBytes: 8,
+ sha256: "f".repeat(64),
},
}),
).toEqual({
@@ -2833,6 +2847,7 @@ describe("host-daemon session schemas", () => {
mimeType: "image/png",
modifiedAtMs: 1234.5,
sizeBytes: 8,
+ sha256: "f".repeat(64),
},
});
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
index 5d3b61172..affec91dc 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts
@@ -23,6 +23,15 @@ interface PluginHomepageSectionProps {
}
/** Props passed to a `navPanel` component (it owns its whole route). */
interface PluginNavPanelProps {
+ /**
+ * The route remainder after the panel root, "" at the root. The panel's
+ * route is `/plugins///*`, so a deep link like
+ * `/plugins/notes/notes/work/ideas.md` renders the panel with
+ * `subPath: "work/ideas.md"`. Navigate within the panel via
+ * `useBbNavigate().toPluginPanel(path, { subPath })` — browser
+ * back/forward then walks panel-internal history.
+ */
+ subPath: string;
}
/** Props passed to a panel tab opened by a `threadPanelAction`. */
interface PluginThreadPanelProps {
@@ -39,6 +48,23 @@ interface PluginComposerAccessoryProps {
projectId: string | null;
threadId: string | null;
}
+/**
+ * Where a file being opened by a `fileOpener` lives. `path` semantics follow
+ * the source: workspace paths are relative to the environment's worktree,
+ * thread-storage paths are relative to the thread's storage root, host paths
+ * are absolute on the thread's host.
+ */
+interface PluginFileOpenerSource {
+ kind: "workspace" | "host" | "thread-storage";
+ threadId: string | null;
+ environmentId: string | null;
+ projectId: string | null;
+}
+/** Props passed to a `fileOpener` component (rendered as a panel file tab). */
+interface PluginFileOpenerProps {
+ path: string;
+ source: PluginFileOpenerSource;
+}
/**
* Slot/panel ids and nav-panel paths must match this pattern (letters,
* digits, `-`, `_`): they ride URLs and persisted panel-tab keys.
@@ -117,11 +143,30 @@ interface PluginComposerAccessoryRegistration {
id: string;
component: ComponentType;
}
+/**
+ * Register this plugin as a viewer/editor for file extensions. The user
+ * picks (and can set as default) an opener per extension via the file tab's
+ * "Open with" menu; matching files opened in the panel then render
+ * `component` in a plugin tab instead of the built-in preview. Applies to
+ * working-tree, host, and thread-storage files — never to git-ref snapshots
+ * (diff views always use the built-in preview). The built-in preview stays
+ * one menu click away, and a missing/disabled opener falls back to it.
+ */
+interface PluginFileOpenerRegistration {
+ /** Unique within the plugin; letters, digits, `-`, `_`. */
+ id: string;
+ /** Label in the "Open with" menu (e.g. "Notes editor"). */
+ title: string;
+ /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */
+ extensions: readonly string[];
+ component: ComponentType;
+}
interface PluginAppSlots {
homepageSection(registration: PluginHomepageSectionRegistration): void;
navPanel(registration: PluginNavPanelRegistration): void;
threadPanelAction(registration: PluginThreadPanelActionRegistration): void;
composerAccessory(registration: PluginComposerAccessoryRegistration): void;
+ fileOpener(registration: PluginFileOpenerRegistration): void;
}
interface PluginAppBuilder {
slots: PluginAppSlots;
@@ -154,6 +199,47 @@ interface PluginSettingsState {
values: Record | undefined;
isLoading: boolean;
}
+/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */
+type PluginComposerScope = {
+ kind: "thread";
+ threadId: string;
+} | {
+ kind: "new-thread";
+ projectId: string | null;
+};
+/** An @-mention pill bound to one of the calling plugin's mention providers. */
+interface PluginComposerMention {
+ /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */
+ provider: string;
+ /** Item id your provider's `resolve` will receive at send time. */
+ id: string;
+ /** Pill text shown in the composer. */
+ label: string;
+}
+/**
+ * Programmatic access to the chat composer draft — the same shared draft the
+ * built-in "Add to chat" affordances (file preview, diff, terminal selections)
+ * write to. Inside a thread context writes land in that thread's draft;
+ * anywhere else (nav panel, homepage section) they seed the new-thread
+ * composer draft, which persists until the user sends or clears it.
+ */
+interface PluginComposerApi {
+ scope: PluginComposerScope;
+ /**
+ * Append text to the draft as a `> ` blockquote block and focus the
+ * composer. Blank text is a no-op. This is the "reference this selection
+ * in chat" primitive.
+ */
+ 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.
+ */
+ insertMention(mention: PluginComposerMention): void;
+ /** Focus the composer caret at the end of the draft. */
+ focus(): void;
+}
/** Current app selection, derived from the route. */
interface BbContext {
projectId: string | null;
@@ -162,8 +248,16 @@ interface BbContext {
interface BbNavigate {
toThread(threadId: string): void;
toProject(projectId: string): void;
- /** Navigate to one of this plugin's own nav panels by its `path`. */
- toPluginPanel(path: string): void;
+ /**
+ * Navigate to one of this plugin's own nav panels by its `path`.
+ * `subPath` targets a location inside the panel (the component's
+ * `subPath` prop); `replace` swaps the current history entry instead of
+ * pushing — use it for redirects so back does not bounce.
+ */
+ toPluginPanel(path: string, options?: {
+ subPath?: string;
+ replace?: boolean;
+ }): void;
}
/**
* Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds
@@ -177,6 +271,7 @@ interface PluginSdkApp {
useSettings(): PluginSettingsState;
useBbContext(): BbContext;
useBbNavigate(): BbNavigate;
+ useComposer(): PluginComposerApi;
}
/**
* Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single
@@ -184,7 +279,7 @@ interface PluginSdkApp {
* implementation-key test — adding a surface member without updating this
* list fails the type assertion below.
*/
-declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useRealtime", "useRpc", "useSettings"];
+declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useComposer", "useRealtime", "useRpc", "useSettings"];
declare const definePluginApp: (setup: PluginAppSetup) => PluginAppDefinition;
declare const useRpc: () => PluginRpcClient;
@@ -192,6 +287,7 @@ declare const useRealtime: (channel: string, handler: (payload: unknown) => void
declare const useSettings: () => PluginSettingsState;
declare const useBbContext: () => BbContext;
declare const useBbNavigate: () => BbNavigate;
+declare const useComposer: () => PluginComposerApi;
-export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useRealtime, useRpc, useSettings };
-export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };
+export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN, definePluginApp, useBbContext, useBbNavigate, useComposer, useRealtime, useRpc, useSettings };
+export type { BbContext, BbNavigate, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginNavPanelProps, PluginNavPanelRegistration, PluginRpcClient, PluginSdkApp, PluginSettingsState, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps };
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
index 0765436e9..98b6e02c6 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
@@ -27,6 +27,15 @@ interface PluginHomepageSectionProps {
}
/** Props passed to a `navPanel` component (it owns its whole route). */
interface PluginNavPanelProps {
+ /**
+ * The route remainder after the panel root, "" at the root. The panel's
+ * route is `/plugins///*`, so a deep link like
+ * `/plugins/notes/notes/work/ideas.md` renders the panel with
+ * `subPath: "work/ideas.md"`. Navigate within the panel via
+ * `useBbNavigate().toPluginPanel(path, { subPath })` — browser
+ * back/forward then walks panel-internal history.
+ */
+ subPath: string;
}
/** Props passed to a panel tab opened by a `threadPanelAction`. */
interface PluginThreadPanelProps {
@@ -43,6 +52,23 @@ interface PluginComposerAccessoryProps {
projectId: string | null;
threadId: string | null;
}
+/**
+ * Where a file being opened by a `fileOpener` lives. `path` semantics follow
+ * the source: workspace paths are relative to the environment's worktree,
+ * thread-storage paths are relative to the thread's storage root, host paths
+ * are absolute on the thread's host.
+ */
+interface PluginFileOpenerSource {
+ kind: "workspace" | "host" | "thread-storage";
+ threadId: string | null;
+ environmentId: string | null;
+ projectId: string | null;
+}
+/** Props passed to a `fileOpener` component (rendered as a panel file tab). */
+interface PluginFileOpenerProps {
+ path: string;
+ source: PluginFileOpenerSource;
+}
/**
* Slot/panel ids and nav-panel paths must match this pattern (letters,
* digits, `-`, `_`): they ride URLs and persisted panel-tab keys.
@@ -121,11 +147,30 @@ interface PluginComposerAccessoryRegistration {
id: string;
component: ComponentType;
}
+/**
+ * Register this plugin as a viewer/editor for file extensions. The user
+ * picks (and can set as default) an opener per extension via the file tab's
+ * "Open with" menu; matching files opened in the panel then render
+ * `component` in a plugin tab instead of the built-in preview. Applies to
+ * working-tree, host, and thread-storage files — never to git-ref snapshots
+ * (diff views always use the built-in preview). The built-in preview stays
+ * one menu click away, and a missing/disabled opener falls back to it.
+ */
+interface PluginFileOpenerRegistration {
+ /** Unique within the plugin; letters, digits, `-`, `_`. */
+ id: string;
+ /** Label in the "Open with" menu (e.g. "Notes editor"). */
+ title: string;
+ /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */
+ extensions: readonly string[];
+ component: ComponentType;
+}
interface PluginAppSlots {
homepageSection(registration: PluginHomepageSectionRegistration): void;
navPanel(registration: PluginNavPanelRegistration): void;
threadPanelAction(registration: PluginThreadPanelActionRegistration): void;
composerAccessory(registration: PluginComposerAccessoryRegistration): void;
+ fileOpener(registration: PluginFileOpenerRegistration): void;
}
interface PluginAppBuilder {
slots: PluginAppSlots;
@@ -158,6 +203,47 @@ interface PluginSettingsState {
values: Record | undefined;
isLoading: boolean;
}
+/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */
+type PluginComposerScope = {
+ kind: "thread";
+ threadId: string;
+} | {
+ kind: "new-thread";
+ projectId: string | null;
+};
+/** An @-mention pill bound to one of the calling plugin's mention providers. */
+interface PluginComposerMention {
+ /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */
+ provider: string;
+ /** Item id your provider's `resolve` will receive at send time. */
+ id: string;
+ /** Pill text shown in the composer. */
+ label: string;
+}
+/**
+ * Programmatic access to the chat composer draft — the same shared draft the
+ * built-in "Add to chat" affordances (file preview, diff, terminal selections)
+ * write to. Inside a thread context writes land in that thread's draft;
+ * anywhere else (nav panel, homepage section) they seed the new-thread
+ * composer draft, which persists until the user sends or clears it.
+ */
+interface PluginComposerApi {
+ scope: PluginComposerScope;
+ /**
+ * Append text to the draft as a `> ` blockquote block and focus the
+ * composer. Blank text is a no-op. This is the "reference this selection
+ * in chat" primitive.
+ */
+ 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.
+ */
+ insertMention(mention: PluginComposerMention): void;
+ /** Focus the composer caret at the end of the draft. */
+ focus(): void;
+}
/** Current app selection, derived from the route. */
interface BbContext {
projectId: string | null;
@@ -166,8 +252,16 @@ interface BbContext {
interface BbNavigate {
toThread(threadId: string): void;
toProject(projectId: string): void;
- /** Navigate to one of this plugin's own nav panels by its `path`. */
- toPluginPanel(path: string): void;
+ /**
+ * Navigate to one of this plugin's own nav panels by its `path`.
+ * `subPath` targets a location inside the panel (the component's
+ * `subPath` prop); `replace` swaps the current history entry instead of
+ * pushing — use it for redirects so back does not bounce.
+ */
+ toPluginPanel(path: string, options?: {
+ subPath?: string;
+ replace?: boolean;
+ }): void;
}
/**
* Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds
@@ -181,6 +275,7 @@ interface PluginSdkApp {
useSettings(): PluginSettingsState;
useBbContext(): BbContext;
useBbNavigate(): BbNavigate;
+ useComposer(): PluginComposerApi;
}
/**
* Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single
@@ -188,7 +283,7 @@ interface PluginSdkApp {
* implementation-key test — adding a surface member without updating this
* list fails the type assertion below.
*/
-declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useRealtime", "useRpc", "useSettings"];
+declare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly ["definePluginApp", "useBbContext", "useBbNavigate", "useComposer", "useRealtime", "useRpc", "useSettings"];
declare const appThemeSchema: z$1.ZodObject<{
themeId: z$1.ZodString;
@@ -329,9 +424,9 @@ declare const threadTimelinePendingTodosSchema: z$1.ZodObject<{
id: z$1.ZodString;
text: z$1.ZodString;
status: z$1.ZodEnum<{
- completed: "completed";
pending: "pending";
in_progress: "in_progress";
+ completed: "completed";
}>;
}, z$1.core.$strip>>;
}, z$1.core.$strip>;
@@ -1237,6 +1332,48 @@ interface EnvironmentsArea {
}
declare function createEnvironmentsArea(args: CreateSdkAreaArgs): EnvironmentsArea;
+/**
+ * Host file primitives. `hostId` may be omitted to target the server's
+ * primary (local) host. `rootPath`, when set, confines the target beneath
+ * that absolute root on the host (symlink-safe).
+ */
+interface FileReadArgs {
+ hostId?: string;
+ path: string;
+ rootPath?: string;
+}
+interface FileWriteArgs {
+ hostId?: string;
+ path: string;
+ rootPath?: string;
+ content: string;
+ /** Defaults to "utf8". */
+ contentEncoding?: "utf8" | "base64";
+ /** Defaults to false. */
+ createParents?: boolean;
+ /**
+ * Optimistic-concurrency guard: omitted → unconditional write; a hash →
+ * write only when the current content hashes to it (use `read().sha256`);
+ * null → create-only. A failed guard resolves to the `conflict` outcome.
+ */
+ expectedSha256?: string | null;
+}
+interface FileListArgs {
+ hostId?: string;
+ path: string;
+ query?: string;
+ limit?: number;
+}
+type FileReadResult = PublicApiOutput<"/files/read", "$post">;
+type FileWriteResult = PublicApiOutput<"/files/write", "$post">;
+type FileListResult = PublicApiOutput<"/files/list", "$post">;
+interface FilesArea {
+ read(args: FileReadArgs): Promise;
+ write(args: FileWriteArgs): Promise;
+ list(args: FileListArgs): Promise;
+}
+declare function createFilesArea(args: CreateSdkAreaArgs): FilesArea;
+
interface GuideRenderArgs {
chapter?: string;
}
@@ -1623,6 +1760,7 @@ declare function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea;
interface BbSdk extends BbRealtime {
automations: ReturnType;
environments: ReturnType;
+ files: ReturnType;
guide: ReturnType;
hosts: ReturnType;
projects: ReturnType;
@@ -2040,4 +2178,4 @@ interface BbPluginApi {
}
export { PLUGIN_SDK_APP_EXPORT_NAMES, PLUGIN_SLOT_ID_PATTERN };
-export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };
+export type { BbContext, BbNavigate, BbPluginApi, PluginAgentToolContentPart, PluginAgentToolContext, PluginAgentToolRegistrationBase, PluginAgentToolResult, PluginAgents, PluginAppBuilder, PluginAppDefinition, PluginAppSetup, PluginAppSlots, PluginBackground, PluginCli, PluginCliCommandInfo, PluginCliContext, PluginCliRegistration, PluginCliResult, PluginComposerAccessoryProps, PluginComposerAccessoryRegistration, PluginComposerApi, PluginComposerMention, PluginComposerScope, PluginFileOpenerProps, PluginFileOpenerRegistration, PluginFileOpenerSource, PluginHomepageSectionProps, PluginHomepageSectionRegistration, PluginHttp, PluginHttpAuthMode, PluginHttpHandler, PluginKvStorage, PluginLogger, PluginMentionItem, PluginMentionProviderRegistration, PluginMentionSearchContext, PluginNavPanelProps, PluginNavPanelRegistration, PluginRealtime, PluginRpc, PluginRpcClient, PluginSdkApp, PluginSettingDescriptor, PluginSettingDescriptors, PluginSettingValue, PluginSettings, PluginSettingsHandle, PluginSettingsState, PluginSettingsValues, PluginStatusApi, PluginStorage, PluginThreadActionContext, PluginThreadActionRegistration, PluginThreadActionResult, PluginThreadActionToast, PluginThreadEventHandler, PluginThreadEventName, PluginThreadEventPayloads, PluginThreadPanelActionContext, PluginThreadPanelActionRegistration, PluginThreadPanelProps, PluginUi };
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index f130c7041..92081fe0a 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -24,8 +24,17 @@ export interface PluginHomepageSectionProps {
}
/** Props passed to a `navPanel` component (it owns its whole route). */
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type
-export interface PluginNavPanelProps {}
+export interface PluginNavPanelProps {
+ /**
+ * The route remainder after the panel root, "" at the root. The panel's
+ * route is `/plugins///*`, so a deep link like
+ * `/plugins/notes/notes/work/ideas.md` renders the panel with
+ * `subPath: "work/ideas.md"`. Navigate within the panel via
+ * `useBbNavigate().toPluginPanel(path, { subPath })` — browser
+ * back/forward then walks panel-internal history.
+ */
+ subPath: string;
+}
/** Props passed to a panel tab opened by a `threadPanelAction`. */
export interface PluginThreadPanelProps {
@@ -44,6 +53,25 @@ export interface PluginComposerAccessoryProps {
threadId: string | null;
}
+/**
+ * Where a file being opened by a `fileOpener` lives. `path` semantics follow
+ * the source: workspace paths are relative to the environment's worktree,
+ * thread-storage paths are relative to the thread's storage root, host paths
+ * are absolute on the thread's host.
+ */
+export interface PluginFileOpenerSource {
+ kind: "workspace" | "host" | "thread-storage";
+ threadId: string | null;
+ environmentId: string | null;
+ projectId: string | null;
+}
+
+/** Props passed to a `fileOpener` component (rendered as a panel file tab). */
+export interface PluginFileOpenerProps {
+ path: string;
+ source: PluginFileOpenerSource;
+}
+
// ---------------------------------------------------------------------------
// Slot registrations (the arguments to `app.slots.*`).
// ---------------------------------------------------------------------------
@@ -129,6 +157,25 @@ export interface PluginComposerAccessoryRegistration {
component: ComponentType;
}
+/**
+ * Register this plugin as a viewer/editor for file extensions. The user
+ * picks (and can set as default) an opener per extension via the file tab's
+ * "Open with" menu; matching files opened in the panel then render
+ * `component` in a plugin tab instead of the built-in preview. Applies to
+ * working-tree, host, and thread-storage files — never to git-ref snapshots
+ * (diff views always use the built-in preview). The built-in preview stays
+ * one menu click away, and a missing/disabled opener falls back to it.
+ */
+export interface PluginFileOpenerRegistration {
+ /** Unique within the plugin; letters, digits, `-`, `_`. */
+ id: string;
+ /** Label in the "Open with" menu (e.g. "Notes editor"). */
+ title: string;
+ /** Lowercase extensions without the dot (e.g. ["md", "mdx"]). */
+ extensions: readonly string[];
+ component: ComponentType;
+}
+
// ---------------------------------------------------------------------------
// definePluginApp
// ---------------------------------------------------------------------------
@@ -138,6 +185,7 @@ export interface PluginAppSlots {
navPanel(registration: PluginNavPanelRegistration): void;
threadPanelAction(registration: PluginThreadPanelActionRegistration): void;
composerAccessory(registration: PluginComposerAccessoryRegistration): void;
+ fileOpener(registration: PluginFileOpenerRegistration): void;
}
export interface PluginAppBuilder {
@@ -180,6 +228,46 @@ export interface PluginSettingsState {
isLoading: boolean;
}
+/** Where `useComposer()` writes: the active thread's draft or the new-thread draft. */
+export type PluginComposerScope =
+ | { kind: "thread"; threadId: string }
+ | { kind: "new-thread"; projectId: string | null };
+
+/** An @-mention pill bound to one of the calling plugin's mention providers. */
+export interface PluginComposerMention {
+ /** Mention provider id registered by THIS plugin via `bb.ui.registerMentionProvider`. */
+ provider: string;
+ /** Item id your provider's `resolve` will receive at send time. */
+ id: string;
+ /** Pill text shown in the composer. */
+ label: string;
+}
+
+/**
+ * Programmatic access to the chat composer draft — the same shared draft the
+ * built-in "Add to chat" affordances (file preview, diff, terminal selections)
+ * write to. Inside a thread context writes land in that thread's draft;
+ * anywhere else (nav panel, homepage section) they seed the new-thread
+ * composer draft, which persists until the user sends or clears it.
+ */
+export interface PluginComposerApi {
+ scope: PluginComposerScope;
+ /**
+ * Append text to the draft as a `> ` blockquote block and focus the
+ * composer. Blank text is a no-op. This is the "reference this selection
+ * in chat" primitive.
+ */
+ 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.
+ */
+ insertMention(mention: PluginComposerMention): void;
+ /** Focus the composer caret at the end of the draft. */
+ focus(): void;
+}
+
/** Current app selection, derived from the route. */
export interface BbContext {
projectId: string | null;
@@ -189,8 +277,16 @@ export interface BbContext {
export interface BbNavigate {
toThread(threadId: string): void;
toProject(projectId: string): void;
- /** Navigate to one of this plugin's own nav panels by its `path`. */
- toPluginPanel(path: string): void;
+ /**
+ * Navigate to one of this plugin's own nav panels by its `path`.
+ * `subPath` targets a location inside the panel (the component's
+ * `subPath` prop); `replace` swaps the current history entry instead of
+ * pushing — use it for redirects so back does not bounce.
+ */
+ toPluginPanel(
+ path: string,
+ options?: { subPath?: string; replace?: boolean },
+ ): void;
}
// ---------------------------------------------------------------------------
@@ -216,6 +312,7 @@ export interface PluginSdkApp {
useSettings(): PluginSettingsState;
useBbContext(): BbContext;
useBbNavigate(): BbNavigate;
+ useComposer(): PluginComposerApi;
}
/**
@@ -228,6 +325,7 @@ export const PLUGIN_SDK_APP_EXPORT_NAMES = [
"definePluginApp",
"useBbContext",
"useBbNavigate",
+ "useComposer",
"useRealtime",
"useRpc",
"useSettings",
diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts
index 2973fbe04..13043d5d3 100644
--- a/packages/plugin-sdk/src/app.ts
+++ b/packages/plugin-sdk/src/app.ts
@@ -34,3 +34,4 @@ export const useRealtime = runtime.useRealtime;
export const useSettings = runtime.useSettings;
export const useBbContext = runtime.useBbContext;
export const useBbNavigate = runtime.useBbNavigate;
+export const useComposer = runtime.useComposer;
diff --git a/packages/sdk/src/areas/files.ts b/packages/sdk/src/areas/files.ts
new file mode 100644
index 000000000..866230b41
--- /dev/null
+++ b/packages/sdk/src/areas/files.ts
@@ -0,0 +1,67 @@
+import type { CreateSdkAreaArgs, PublicApiOutput } from "./common.js";
+
+/**
+ * Host file primitives. `hostId` may be omitted to target the server's
+ * primary (local) host. `rootPath`, when set, confines the target beneath
+ * that absolute root on the host (symlink-safe).
+ */
+export interface FileReadArgs {
+ hostId?: string;
+ path: string;
+ rootPath?: string;
+}
+
+export interface FileWriteArgs {
+ hostId?: string;
+ path: string;
+ rootPath?: string;
+ content: string;
+ /** Defaults to "utf8". */
+ contentEncoding?: "utf8" | "base64";
+ /** Defaults to false. */
+ createParents?: boolean;
+ /**
+ * Optimistic-concurrency guard: omitted → unconditional write; a hash →
+ * write only when the current content hashes to it (use `read().sha256`);
+ * null → create-only. A failed guard resolves to the `conflict` outcome.
+ */
+ expectedSha256?: string | null;
+}
+
+export interface FileListArgs {
+ hostId?: string;
+ path: string;
+ query?: string;
+ limit?: number;
+}
+
+export type FileReadResult = PublicApiOutput<"/files/read", "$post">;
+export type FileWriteResult = PublicApiOutput<"/files/write", "$post">;
+export type FileListResult = PublicApiOutput<"/files/list", "$post">;
+
+export interface FilesArea {
+ read(args: FileReadArgs): Promise;
+ write(args: FileWriteArgs): Promise;
+ list(args: FileListArgs): Promise;
+}
+
+export function createFilesArea(args: CreateSdkAreaArgs): FilesArea {
+ const { transport } = args;
+ return {
+ async read(input) {
+ return transport.readJson(
+ transport.api.v1.files.read.$post({ json: input }),
+ );
+ },
+ async write(input) {
+ return transport.readJson(
+ transport.api.v1.files.write.$post({ json: input }),
+ );
+ },
+ async list(input) {
+ return transport.readJson(
+ transport.api.v1.files.list.$post({ json: input }),
+ );
+ },
+ };
+}
diff --git a/packages/sdk/src/core.ts b/packages/sdk/src/core.ts
index 4568aa1d7..e0aafb0b1 100644
--- a/packages/sdk/src/core.ts
+++ b/packages/sdk/src/core.ts
@@ -1,6 +1,7 @@
import type { BbSdkContext, BbSdkTransport } from "./transport.js";
import { createAutomationsArea } from "./areas/automations.js";
import { createEnvironmentsArea } from "./areas/environments.js";
+import { createFilesArea } from "./areas/files.js";
import { createGuideArea } from "./areas/guide.js";
import { createHostsArea } from "./areas/hosts.js";
import { createProjectsArea } from "./areas/projects.js";
@@ -19,6 +20,7 @@ export interface CreateBbSdkArgs {
export interface BbSdk extends BbRealtime {
automations: ReturnType;
environments: ReturnType;
+ files: ReturnType;
guide: ReturnType;
hosts: ReturnType;
projects: ReturnType;
@@ -37,6 +39,7 @@ export function createBbSdk(args: CreateBbSdkArgs): BbSdk {
return {
automations: createAutomationsArea(sdkContext),
environments: createEnvironmentsArea(sdkContext),
+ files: createFilesArea(sdkContext),
guide: createGuideArea(),
hosts: createHostsArea(sdkContext),
on(args) {
diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts
index f58d69f5a..8ca55c65e 100644
--- a/packages/server-contract/src/api-types.ts
+++ b/packages/server-contract/src/api-types.ts
@@ -2,6 +2,7 @@ export * from "./api/shared.js";
export * from "./api/automations.js";
export * from "./api/projects.js";
export * from "./api/environments.js";
+export * from "./api/files.js";
export * from "./api/hosts.js";
export * from "./api/system.js";
export * from "./api/terminals.js";
diff --git a/packages/server-contract/src/api/files.ts b/packages/server-contract/src/api/files.ts
new file mode 100644
index 000000000..1e32752a0
--- /dev/null
+++ b/packages/server-contract/src/api/files.ts
@@ -0,0 +1,55 @@
+import { z } from "zod";
+import {
+ FILE_LIST_LIMIT_MAX,
+ type HostDaemonOnlineRpcResultByType,
+} from "@bb/host-daemon-contract";
+
+/**
+ * Host file read/write API (`POST /files/*`). Unlike the preview-oriented
+ * thread file routes, these are host-scoped primitives for callers (plugins,
+ * editors) that do read-modify-write against files on a connected host.
+ *
+ * `hostId` omission has real semantics: the server resolves it to the primary
+ * (local) host once at the route boundary. `rootPath`, when set, confines the
+ * resolved target beneath that absolute root on the daemon side.
+ */
+export const hostFileReadRequestSchema = z
+ .object({
+ hostId: z.string().min(1).optional(),
+ path: z.string().min(1),
+ rootPath: z.string().min(1).optional(),
+ })
+ .strict();
+export type HostFileReadRequest = z.infer;
+
+export const hostFileWriteRequestSchema = z
+ .object({
+ hostId: z.string().min(1).optional(),
+ path: z.string().min(1),
+ rootPath: z.string().min(1).optional(),
+ content: z.string(),
+ contentEncoding: z.enum(["utf8", "base64"]).optional(),
+ createParents: z.boolean().optional(),
+ // Omitted → unconditional write; hash → compare-and-swap against the
+ // current content; null → create-only (fail if the file exists).
+ expectedSha256: z.string().nullable().optional(),
+ })
+ .strict();
+export type HostFileWriteRequest = z.infer;
+
+export const hostFileListRequestSchema = z
+ .object({
+ hostId: z.string().min(1).optional(),
+ path: z.string().min(1),
+ query: z.string().optional(),
+ limit: z.number().int().positive().max(FILE_LIST_LIMIT_MAX).optional(),
+ })
+ .strict();
+export type HostFileListRequest = z.infer;
+
+export type HostFileReadResponse =
+ HostDaemonOnlineRpcResultByType["host.read_file"];
+export type HostFileWriteResponse =
+ HostDaemonOnlineRpcResultByType["host.write_file"];
+export type HostFileListResponse =
+ HostDaemonOnlineRpcResultByType["host.list_files"];
diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts
index 73f09c1c8..ab59507bd 100644
--- a/packages/server-contract/src/public-api.ts
+++ b/packages/server-contract/src/public-api.ts
@@ -74,6 +74,12 @@ import type {
EnvironmentStatusResponse,
HostDirectoryListing,
HostDirectoryQuery,
+ HostFileListRequest,
+ HostFileListResponse,
+ HostFileReadRequest,
+ HostFileReadResponse,
+ HostFileWriteRequest,
+ HostFileWriteResponse,
HostPickFolderRequest,
HostPickFolderResponse,
HostPathsExistRequest,
@@ -182,6 +188,9 @@ import {
environmentPathsQuerySchema,
environmentStatusQuerySchema,
hostDirectoryQuerySchema,
+ hostFileListRequestSchema,
+ hostFileReadRequestSchema,
+ hostFileWriteRequestSchema,
hostPickFolderRequestSchema,
hostPathsExistRequestSchema,
hostProviderCliInstallRequestSchema,
@@ -381,6 +390,33 @@ export const publicApiRoutes = {
}),
},
+ files: {
+ read: defineRoute({
+ path: "/files/read",
+ method: "post",
+ request: jsonRequest(
+ hostFileReadRequestSchema,
+ ),
+ response: jsonResponse(),
+ }),
+ write: defineRoute({
+ path: "/files/write",
+ method: "post",
+ request: jsonRequest(
+ hostFileWriteRequestSchema,
+ ),
+ response: jsonResponse(),
+ }),
+ list: defineRoute({
+ path: "/files/list",
+ method: "post",
+ request: jsonRequest(
+ hostFileListRequestSchema,
+ ),
+ response: jsonResponse(),
+ }),
+ },
+
hosts: {
list: defineRoute({
path: "/hosts",
diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
index eced5daea..62eadb56e 100644
--- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts
+++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
@@ -2,6 +2,6 @@
// Generated by packages/templates/scripts/generate-templates.mjs from
// @bb/plugin-sdk/bundled-types. Do not edit directly.
-export const PLUGIN_SDK_DTS = "// Bundled type declarations for `@bb/plugin-sdk`, shipped into scaffolded\n// plugins so they typecheck without the @bb/* workspace on disk.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/ymichael/bb\n\nimport { ComponentType } from 'react';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\n\n/**\n * The `@bb/plugin-sdk/app` contract (plugin design §5.2) — pure types plus\n * the runtime export-name list, with no side effects. This module is what the\n * BB app imports to keep its real implementation in sync (`satisfies\n * PluginSdkApp`) and what `bb plugin build` imports to generate the shim's\n * named-export list. Plugin authors import the same shapes through\n * `@bb/plugin-sdk/app`.\n *\n * Per-slot props are versioned contracts: additive-only within an SDK major.\n */\n/** Props passed to a `homepageSection` component. */\ninterface PluginHomepageSectionProps {\n /** Project in view on the compose surface; null when none is selected. */\n projectId: string | null;\n}\n/** Props passed to a `navPanel` component (it owns its whole route). */\ninterface PluginNavPanelProps {\n}\n/** Props passed to a panel tab opened by a `threadPanelAction`. */\ninterface PluginThreadPanelProps {\n threadId: string;\n /**\n * The JSON value the action's `openPanel` call passed (round-tripped\n * through persistence, so the tab restores across reloads); null when the\n * action opened the panel without params.\n */\n params: unknown;\n}\n/** Props passed to a `composerAccessory` component. */\ninterface PluginComposerAccessoryProps {\n projectId: string | null;\n threadId: string | null;\n}\n/**\n * Slot/panel ids and nav-panel paths must match this pattern (letters,\n * digits, `-`, `_`): they ride URLs and persisted panel-tab keys.\n */\ndeclare const PLUGIN_SLOT_ID_PATTERN: RegExp;\ninterface PluginHomepageSectionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n component: ComponentType;\n}\ninterface PluginNavPanelRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n title: string;\n /** Icon hint (BB icon name); unknown names fall back to a generic icon. */\n icon: string;\n /** URL segment under `/plugins//`; letters, digits, `-`, `_`. */\n path: string;\n component: ComponentType;\n /**\n * Panel chrome (default \"page\"): \"page\" renders the host title bar (plugin\n * logo + `title` + your `headerContent`) above a full-width padded body;\n * \"none\" hands the ENTIRE panel area to `component` — no host padding, no\n * title bar (`headerContent` is ignored) — only the per-plugin error\n * boundary remains.\n */\n chrome?: \"page\" | \"none\";\n /**\n * Optional component rendered on the right side of the \"page\" title bar\n * (e.g. a sync button or a count). Contained separately from the body: a\n * throwing headerContent is hidden without breaking the title bar.\n */\n headerContent?: ComponentType;\n}\n/** Context handed to a `threadPanelAction`'s `run`. */\ninterface PluginThreadPanelActionContext {\n /** The thread whose panel launcher invoked the action. */\n threadId: string;\n /**\n * Open a tab in the thread's side panel rendering this action's\n * `component`. `title` labels the tab (default: the action's `title`);\n * `params` must be JSON-serializable — it is persisted with the tab and\n * reaches the component as its `params` prop. Opening with params\n * identical to an already-open tab of this action focuses that tab\n * (updating its title) instead of duplicating it. May be called more than\n * once (different params ⇒ multiple tabs) or not at all.\n */\n openPanel(options?: {\n title?: string;\n params?: unknown;\n }): void;\n}\ninterface PluginThreadPanelActionRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n /** Label of the action row in the panel's new-tab launcher. */\n title: string;\n /**\n * Icon hint (BB icon name) used when the plugin ships no logo; the\n * launcher row and opened tabs prefer the plugin's logo.\n */\n icon?: string;\n /** Rendered inside every panel tab this action opens. */\n component: ComponentType;\n /**\n * Runs when the user activates the action: call your RPC methods, show a\n * toast, and/or open panel tabs via `context.openPanel`. Omitted =\n * immediately open a panel tab with defaults. Errors (sync or async) are\n * contained and logged; they never break the launcher.\n */\n run?(context: PluginThreadPanelActionContext): void | Promise;\n}\ninterface PluginComposerAccessoryRegistration {\n /** Unique within the plugin; letters, digits, `-`, `_`. */\n id: string;\n component: ComponentType;\n}\ninterface PluginAppSlots {\n homepageSection(registration: PluginHomepageSectionRegistration): void;\n navPanel(registration: PluginNavPanelRegistration): void;\n threadPanelAction(registration: PluginThreadPanelActionRegistration): void;\n composerAccessory(registration: PluginComposerAccessoryRegistration): void;\n}\ninterface PluginAppBuilder {\n slots: PluginAppSlots;\n}\ntype PluginAppSetup = (app: PluginAppBuilder) => void;\n/**\n * The opaque product of `definePluginApp` — a plugin's `app.tsx` default\n * export. The host re-runs `setup` against a fresh collector on every\n * (re)interpretation, replacing that plugin's registrations wholesale.\n */\ninterface PluginAppDefinition {\n /** Brand the host checks before interpreting a bundle's default export. */\n readonly __bbPluginApp: true;\n readonly setup: PluginAppSetup;\n}\ninterface PluginRpcClient {\n /**\n * Invoke one of the plugin's `bb.rpc` methods (POST\n * /api/v1/plugins/<id>/rpc/<method>). Resolves with the method's\n * result; rejects with an `Error` carrying the server's message when the\n * handler fails or the plugin is not running.\n */\n call(method: string, input?: unknown): Promise;\n}\ninterface PluginSettingsState {\n /**\n * Effective non-secret setting values (secret settings are excluded —\n * read them server-side). Undefined while loading or unavailable.\n */\n values: Record | undefined;\n isLoading: boolean;\n}\n/** Current app selection, derived from the route. */\ninterface BbContext {\n projectId: string | null;\n threadId: string | null;\n}\ninterface BbNavigate {\n toThread(threadId: string): void;\n toProject(projectId: string): void;\n /** Navigate to one of this plugin's own nav panels by its `path`. */\n toPluginPanel(path: string): void;\n}\n/**\n * Everything `@bb/plugin-sdk/app` resolves to at runtime. The BB app builds\n * the real implementation and `satisfies` this interface; `bb plugin build`\n * shims the specifier to that object on `globalThis.__bbPluginRuntime`.\n */\ninterface PluginSdkApp {\n definePluginApp(setup: PluginAppSetup): PluginAppDefinition;\n useRpc(): PluginRpcClient;\n useRealtime(channel: string, handler: (payload: unknown) => void): void;\n useSettings(): PluginSettingsState;\n useBbContext(): BbContext;\n useBbNavigate(): BbNavigate;\n}\n/**\n * Named runtime exports of `@bb/plugin-sdk/app`, in sorted order. Single\n * source of truth for the build shim's export list and the app's\n * implementation-key test — adding a surface member without updating this\n * list fails the type assertion below.\n */\ndeclare const PLUGIN_SDK_APP_EXPORT_NAMES: readonly [\"definePluginApp\", \"useBbContext\", \"useBbNavigate\", \"useRealtime\", \"useRpc\", \"useSettings\"];\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\n\ndeclare const threadStatusSchema: z$1.ZodEnum<{\n error: \"error\";\n active: \"active\";\n starting: \"starting\";\n idle: \"idle\";\n stopping: \"stopping\";\n}>;\ntype ThreadStatus = z$1.infer;\n\ndeclare const threadTimelinePendingTodosSchema: z$1.ZodObject<{\n sourceSeq: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n items: z$1.ZodArray;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype ThreadTimelinePendingTodos = z$1.infer;\n\ndeclare const createAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n enabled: z$1.ZodDefault;\n trigger: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"schedule\">;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">;\n execution: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"agent\">;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">;\n environment: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reuse\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">;\n autoArchive: z$1.ZodDefault;\n origin: z$1.ZodEnum<{\n agent: \"agent\";\n human: \"human\";\n app: \"app\";\n }>;\n createdByThreadId: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateAutomationRequest = z$1.input;\ndeclare const updateAutomationRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n trigger: z$1.ZodOptional;\n cron: z$1.ZodString;\n timezone: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n triggerType: z$1.ZodLiteral<\"once\">;\n runAt: z$1.ZodNumber;\n }, z$1.core.$strip>], \"triggerType\">>;\n execution: z$1.ZodOptional;\n prompt: z$1.ZodString;\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n permissionMode: z$1.ZodEnum<{\n readonly: \"readonly\";\n full: \"full\";\n \"workspace-write\": \"workspace-write\";\n }>;\n targetThreadId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"script\">;\n script: z$1.ZodOptional;\n scriptFile: z$1.ZodOptional;\n interpreter: z$1.ZodOptional>;\n timeoutMs: z$1.ZodDefault;\n env: z$1.ZodOptional>;\n }, z$1.core.$strip>], \"mode\">>;\n environment: z$1.ZodOptional;\n environmentId: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"host\">;\n hostId: z$1.ZodOptional;\n workspace: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unmanaged\">;\n path: z$1.ZodNullable;\n branch: z$1.ZodOptional;\n name: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"new\">;\n baseBranch: z$1.ZodString;\n }, z$1.core.$strict>], \"kind\">>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"managed-worktree\">;\n baseBranch: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"named\">;\n name: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"default\">;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"personal\">;\n }, z$1.core.$strip>], \"type\">;\n }, z$1.core.$strip>], \"type\">>;\n autoArchive: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype UpdateAutomationRequest = z$1.infer;\ndeclare const runAutomationRequestSchema: z$1.ZodObject<{\n idempotencyKey: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype RunAutomationRequest = z$1.infer;\n\ndeclare const createProjectSourceRequestSchema: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n}, z$1.core.$strict>;\ntype CreateProjectSourceRequest = z$1.infer;\ndeclare const createProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodString;\n source: z$1.ZodObject<{\n hostId: z$1.ZodString;\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodPipe>;\n }, z$1.core.$strict>;\n}, z$1.core.$strip>;\ntype CreateProjectRequest = z$1.infer;\ndeclare const projectListQuerySchema: z$1.ZodObject<{\n include: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype ProjectListQuery = z$1.infer;\ndeclare const updateProjectRequestSchema: z$1.ZodObject<{\n name: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype UpdateProjectRequest = z$1.infer;\ndeclare const updateProjectSourceRequestSchema: z$1.ZodObject<{\n type: z$1.ZodLiteral<\"local_path\">;\n path: z$1.ZodOptional>>;\n isDefault: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype UpdateProjectSourceRequest = z$1.infer;\n\ndeclare const updateEnvironmentRequestSchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n name: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype UpdateEnvironmentRequest = z$1.infer;\ndeclare const environmentDiffBranchesQuerySchema: z$1.ZodObject<{\n query: z$1.ZodOptional;\n limit: z$1.ZodOptional;\n selectedBranch: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype EnvironmentDiffBranchesQuery = z$1.infer;\ndeclare const environmentStatusQuerySchema: z$1.ZodObject<{\n mergeBaseBranch: z$1.ZodOptional>;\n}, z$1.core.$strip>;\ntype EnvironmentStatusQuery = z$1.infer;\ndeclare const environmentDiffQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseBranch: z$1.ZodPipe;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffQuery = z$1.infer;\n/**\n * Query for fetching a single file's contents at one side of a diff target.\n * Used by the diff card to reparse the card's patch with full old/new contents\n * so `@pierre/diffs` can render expand-context buttons between hunks.\n *\n * For `branch_committed` / `all`, callers pass the resolved merge-base SHA\n * (`mergeBaseRef`, surfaced by `workspace.diff`) rather than the branch name\n * — the diff itself was computed against that SHA, so reading the old side\n * from the same SHA keeps the file content aligned with the hunk line\n * numbers. Reading from the branch tip is wrong whenever the branch has\n * moved past the merge-base since the file existed there.\n */\ndeclare const environmentDiffFileQuerySchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n target: z$1.ZodLiteral<\"uncommitted\">;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"branch_committed\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"all\">;\n mergeBaseRef: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n target: z$1.ZodLiteral<\"commit\">;\n sha: z$1.ZodString;\n path: z$1.ZodString;\n side: z$1.ZodEnum<{\n new: \"new\";\n old: \"old\";\n }>;\n}, z$1.core.$strip>], \"target\">;\ntype EnvironmentDiffFileQuery = z$1.infer;\n\ndeclare const systemExecutionOptionsQuerySchema: z$1.ZodObject<{\n providerId: z$1.ZodOptional;\n hostId: z$1.ZodOptional;\n environmentId: z$1.ZodOptional;\n}, z$1.core.$strip>;\ntype SystemExecutionOptionsQuery = z$1.infer;\n/**\n * Theme catalog: the on-disk custom-theme directory plus the discovered custom\n * themes and the active palette. Drives `bb theme list` / `bb theme dir`.\n */\ndeclare const themeCatalogResponseSchema: z$1.ZodObject<{\n dir: z$1.ZodString;\n custom: z$1.ZodArray;\n active: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n }, z$1.core.$strip>;\n}, z$1.core.$strip>;\ntype ThemeCatalogResponse = z$1.infer;\n\ndeclare const createTerminalRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n start: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n mode: z$1.ZodLiteral<\"command\">;\n command: z$1.ZodString;\n }, z$1.core.$strict>], \"mode\">>;\n target: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"environment\">;\n environmentId: z$1.ZodString;\n }, z$1.core.$strict>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"host_path\">;\n hostId: z$1.ZodString;\n cwd: z$1.ZodNullable;\n }, z$1.core.$strict>], \"kind\">;\n title: z$1.ZodOptional;\n}, z$1.core.$strict>;\ntype CreateTerminalRequest = z$1.infer;\ndeclare const closeTerminalRequestSchema: z$1.ZodObject<{\n mode: z$1.ZodEnum<{\n force: \"force\";\n \"if-clean\": \"if-clean\";\n }>;\n reason: z$1.ZodLiteral<\"user\">;\n}, z$1.core.$strict>;\ntype CloseTerminalRequest = z$1.infer;\ndeclare const updateTerminalRequestSchema: z$1.ZodObject<{\n title: z$1.ZodString;\n}, z$1.core.$strict>;\ntype UpdateTerminalRequest = z$1.infer;\ndeclare const terminalInputRequestSchema: z$1.ZodObject<{\n dataBase64: z$1.ZodString;\n}, z$1.core.$strict>;\ntype TerminalInputRequest = z$1.infer;\ndeclare const terminalResizeRequestSchema: z$1.ZodObject<{\n cols: z$1.ZodNumber;\n rows: z$1.ZodNumber;\n}, z$1.core.$strict>;\ntype TerminalResizeRequest = z$1.infer;\ndeclare const terminalOutputQuerySchema: z$1.ZodObject<{\n sinceSeq: z$1.ZodOptional>;\n tailBytes: z$1.ZodOptional>;\n limitChunks: z$1.ZodOptional>;\n}, z$1.core.$strict>;\ntype TerminalOutputQuery = z$1.infer;\n\ndeclare const createThreadRequestSchema: z$1.ZodObject<{\n projectId: z$1.ZodString;\n providerId: z$1.ZodOptional;\n origin: z$1.ZodEnum<{\n plugin: \"plugin\";\n app: \"app\";\n automation: \"automation\";\n cli: \"cli\";\n sdk: \"sdk\";\n }>;\n originPluginId: z$1.ZodOptional;\n title: z$1.ZodOptional;\n input: z$1.ZodArray>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n }, z$1.core.$strip>>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">>;\n model: z$1.ZodOptional;\n serviceTier: z$1.ZodOptional