diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 2283307a3..692fd15c8 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -20,6 +20,7 @@ import {
PROJECT_ARCHIVED_ROUTE_PATH,
PROJECTLESS_THREAD_DETAIL_ROUTE_PATH,
PROJECT_SETTINGS_ROUTE_PATH,
+ STANDALONE_APP_ROUTE_PATH,
THREAD_DETAIL_ROUTE_PATH,
} from "./lib/app-route-paths";
@@ -46,6 +47,11 @@ const InternalReplayListView = lazy(() =>
default: m.InternalReplayListView,
})),
);
+const StandaloneAppView = lazy(() =>
+ import("./views/standalone-app/StandaloneAppView").then((m) => ({
+ default: m.StandaloneAppView,
+ })),
+);
function AppRoutes() {
return (
@@ -54,6 +60,10 @@ function AppRoutes() {
} />
} />
+ }
+ />
{import.meta.env.DEV ? (
{
+ if (markdownEntryPath === null) {
+ return null;
+ }
+ return buildAppPublicBaseUrl(applicationId, markdownEntryPath);
+ }, [applicationId, markdownEntryPath]);
+ const markdownUrlTransform = useMemo(() => {
+ if (markdownAssetBaseUrl === null) {
+ return undefined;
+ }
+ return createAssetMarkdownUrlTransform(markdownAssetBaseUrl);
+ }, [markdownAssetBaseUrl]);
+ const htmlEntryUrl = useMemo(
+ () =>
+ buildAppEntryUrl({
+ applicationId,
+ targetThreadId,
+ reloadToken: appDetail.dataUpdatedAt,
+ }),
+ [appDetail.dataUpdatedAt, applicationId, targetThreadId],
+ );
+
+ if (appDetail.isError) {
+ return (
+
+ );
+ }
+
+ if (!appDetail.data) {
+ return (
+
+ );
+ }
+
+ if (appDetail.data.entry.kind === "html") {
+ return (
+
+ );
+ }
+
+ if (markdownPreview.isError) {
+ return (
+
+ );
+ }
+
+ if (!markdownPreview.data) {
+ return (
+
+ );
+ }
+
+ if (markdownPreview.data.kind !== "text") {
+ return (
+
+ );
+ }
+
+ if (markdownPreview.data.content.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/components/layout/AppLayout.test.tsx b/apps/app/src/components/layout/AppLayout.test.tsx
index 093741282..0d9853275 100644
--- a/apps/app/src/components/layout/AppLayout.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.test.tsx
@@ -351,12 +351,12 @@ describe("AppLayout desktop chrome", () => {
await renderAppLayout({
desktopInfo: null,
initialEntry: "/projects/proj_sidebar_resize",
- children: ,
+ children: ,
});
const appLayoutRoot = await screen.findByTestId("app-layout-root");
const resizeHandle = screen.getByTestId("app-sidebar-resize-handle");
- const iframe = screen.getByTitle("Status app");
+ const iframe = screen.getByTitle("Review Board app");
expect(appLayoutRoot.contains(iframe)).toBe(true);
expect(screen.queryByTestId("iframe-drag-guard-overlay")).toBeNull();
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index f6c0004ec..dd1b55716 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -24,6 +24,7 @@ import {
stripProjectThreads,
} from "@/hooks/queries/project-queries";
import {
+ useApp,
useThread,
useThreadDetailBootstrap,
} from "@/hooks/queries/thread-queries";
@@ -53,6 +54,7 @@ import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
import { useStandardManagerTimelinePreference } from "@/lib/manager-timeline-view-preference";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard";
+import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync";
const SIDEBAR_WIDTH_KEY = "bb.sidebar.width";
const SIDEBAR_OPEN_KEY = "bb.sidebar.open";
@@ -112,6 +114,7 @@ interface SidebarStateBridgeProps {
}
type SidebarResizeMouseEvent = ReactMouseEvent;
+type SidebarOpenChangeHandler = (open: boolean) => void;
type SidebarProviderStyle = CSSProperties & {
"--sidebar-width": string;
@@ -124,6 +127,13 @@ function SidebarStateBridge({
children,
}: SidebarStateBridgeProps) {
const [open, setOpen] = useAtom(sidebarOpenAtom);
+ const handleOpenChange = useCallback(
+ (nextOpen) => {
+ setOpen(nextOpen);
+ window.requestAnimationFrame(dispatchBrowserViewBoundsSync);
+ },
+ [setOpen],
+ );
return (
{children}
@@ -340,6 +350,8 @@ export function AppLayout({ children }: AppLayoutProps) {
const {
projectId,
threadId,
+ applicationId,
+ isAppView,
isThreadView,
isArchivedView,
isSettingsView,
@@ -400,6 +412,11 @@ export function AppLayout({ children }: AppLayoutProps) {
: threadId
? `Thread ${threadId.slice(0, 8)}`
: "Thread";
+ // The standalone app route has no thread/project chrome, so the global header
+ // carries the app name (resolved from the manifest) the way it carries thread
+ // and project titles elsewhere.
+ const { data: app } = useApp(applicationId, { enabled: isAppView });
+ const appDisplayTitle = app?.name ?? "App";
useEffect(() => {
if (!thread?.projectId) return;
setRootComposeProjectId(thread.projectId);
@@ -409,7 +426,12 @@ export function AppLayout({ children }: AppLayoutProps) {
title: thread ? getThreadDisplayTitle(thread) : "Thread",
subtitle: undefined,
}
- : isArchivedView && projectId
+ : isAppView
+ ? {
+ title: appDisplayTitle,
+ subtitle: undefined,
+ }
+ : isArchivedView && projectId
? {
title: "",
subtitle: undefined,
@@ -444,6 +466,9 @@ export function AppLayout({ children }: AppLayoutProps) {
if (isThreadView) {
return threadDisplayTitle;
}
+ if (isAppView) {
+ return appDisplayTitle;
+ }
if (isArchivedView && projectId) {
return `${projectLabel ?? projectId} · Archived`;
}
@@ -479,6 +504,7 @@ export function AppLayout({ children }: AppLayoutProps) {
"--sidebar-width",
`${liveWidthRef.current}px`,
);
+ dispatchBrowserViewBoundsSync();
setSidebarWidth(liveWidthRef.current);
setIsSidebarResizing(false);
resetSidebarResizeDocumentState();
@@ -493,6 +519,7 @@ export function AppLayout({ children }: AppLayoutProps) {
"--sidebar-width",
`${liveWidthRef.current}px`,
);
+ dispatchBrowserViewBoundsSync();
};
const handleMouseMove = (event: MouseEvent) => {
diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
index 21c48d36a..0c9ee5686 100644
--- a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
+++ b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx
@@ -1,13 +1,10 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
-import type { AppDetail, AppSummary } from "@bb/server-contract";
+import type { AppDetail } from "@bb/server-contract";
import * as api from "@/lib/api";
import { afterEach, describe, expect, it, vi } from "vitest";
-import {
- threadAppQueryKey,
- threadAppsQueryKey,
-} from "@/hooks/queries/query-keys";
+import { appQueryKey } from "@/hooks/queries/query-keys";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { AppTabContent } from "./AppTabContent";
@@ -16,25 +13,31 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
- getThreadApp: vi.fn(),
- getThreadAppMarkdownPreview: vi.fn(),
+ getApp: vi.fn(),
+ getAppMarkdownPreview: vi.fn(),
};
});
const HTML_APP: AppDetail = {
- id: "status",
- name: "Status",
+ applicationId: "status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
};
const MARKDOWN_APP: AppDetail = {
- id: "readme",
+ applicationId: "readme",
name: "Readme",
entry: { path: "docs/index.md", kind: "md" },
capabilities: [],
icon: { kind: "builtin", name: "GridView" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/readme",
+ appDataPath: "/tmp/bb-data/apps/readme/data",
};
afterEach(() => {
@@ -44,58 +47,42 @@ afterEach(() => {
});
describe("AppTabContent", () => {
- it("renders app-list metadata while the detail refetch is still pending", () => {
- vi.mocked(api.getThreadApp).mockReturnValue(new Promise(() => {}));
- const { queryClient, wrapper } = createQueryClientTestHarness();
- queryClient.setQueryData(threadAppsQueryKey("thr_1"), [
- HTML_APP,
- ]);
-
- render( , { wrapper });
-
- const frame = screen.getByTitle("Status");
- expect(frame.getAttribute("src")).toMatch(
- /^\/api\/v1\/threads\/thr_1\/apps\/status\/\?v=\d+$/u,
- );
- expect(api.getThreadApp).toHaveBeenCalledWith(
- "thr_1",
- "status",
- expect.any(AbortSignal),
- );
- });
-
it("renders HTML apps in the injected app iframe route", async () => {
- vi.mocked(api.getThreadApp).mockResolvedValue(HTML_APP);
+ vi.mocked(api.getApp).mockResolvedValue(HTML_APP);
const { wrapper } = createQueryClientTestHarness();
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
- const frame = await screen.findByTitle("Status");
+ const frame = await screen.findByTitle("Review Board");
expect(frame.getAttribute("src")).toMatch(
- /^\/api\/v1\/threads\/thr_1\/apps\/status\/\?v=\d+$/u,
+ /^\/api\/v1\/apps\/status\/\?targetThreadId=thr_1&v=\d+$/u,
);
expect(frame.getAttribute("sandbox")).toBeNull();
- expect(api.getThreadAppMarkdownPreview).not.toHaveBeenCalled();
+ expect(api.getAppMarkdownPreview).not.toHaveBeenCalled();
});
it("reloads HTML app iframes when app detail data refreshes", async () => {
- vi.mocked(api.getThreadApp).mockReturnValue(new Promise(() => {}));
+ vi.mocked(api.getApp).mockReturnValue(new Promise(() => {}));
const { queryClient, wrapper } = createQueryClientTestHarness();
- const appQueryKey = threadAppQueryKey("thr_1", "status");
- queryClient.setQueryData(appQueryKey, HTML_APP, {
+ const queryKey = appQueryKey("status");
+ queryClient.setQueryData(queryKey, HTML_APP, {
updatedAt: 1_000,
});
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
- const firstFrame = screen.getByTitle("Status");
+ const firstFrame = screen.getByTitle("Review Board");
const firstSrc = firstFrame.getAttribute("src");
act(() => {
queryClient.setQueryData(
- appQueryKey,
+ queryKey,
{
...HTML_APP,
- name: "Status",
+ name: "Review Board",
},
{
updatedAt: 2_000,
@@ -104,30 +91,31 @@ describe("AppTabContent", () => {
});
await waitFor(() => {
- expect(screen.getByTitle("Status").getAttribute("src")).not.toBe(
+ expect(screen.getByTitle("Review Board").getAttribute("src")).not.toBe(
firstSrc,
);
});
});
it("renders markdown apps through the static markdown preview path", async () => {
- vi.mocked(api.getThreadApp).mockResolvedValue(MARKDOWN_APP);
- vi.mocked(api.getThreadAppMarkdownPreview).mockResolvedValue({
+ vi.mocked(api.getApp).mockResolvedValue(MARKDOWN_APP);
+ vi.mocked(api.getAppMarkdownPreview).mockResolvedValue({
kind: "text",
path: "docs/index.md",
name: "index.md",
- url: "/api/v1/threads/thr_1/apps/readme/docs/index.md",
+ url: "/api/v1/apps/readme/docs/index.md",
mimeType: "text/markdown",
content: "# App Notes\n\nStatic content.",
});
const { wrapper } = createQueryClientTestHarness();
- render( , { wrapper });
+ render( , {
+ wrapper,
+ });
expect(await screen.findByText("App Notes")).toBeTruthy();
expect(screen.getByText("Static content.")).toBeTruthy();
- expect(api.getThreadAppMarkdownPreview).toHaveBeenCalledWith(
- "thr_1",
+ expect(api.getAppMarkdownPreview).toHaveBeenCalledWith(
"readme",
"docs/index.md",
expect.any(AbortSignal),
diff --git a/apps/app/src/components/secondary-panel/AppTabContent.tsx b/apps/app/src/components/secondary-panel/AppTabContent.tsx
index 4fe39a9b1..a5ed9c7ef 100644
--- a/apps/app/src/components/secondary-panel/AppTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/AppTabContent.tsx
@@ -1,176 +1,15 @@
-import { useMemo } from "react";
-import {
- useThreadApp,
- useThreadAppMarkdownPreview,
-} from "@/hooks/queries/thread-queries";
-import {
- buildThreadAppAssetBaseUrl,
- buildThreadAppEntryUrl,
-} from "@/lib/file-content-urls";
-import { createAssetMarkdownUrlTransform } from "@/lib/markdown-url-transform";
-import { FilePreview as FilePreviewSurface } from "./FilePreview";
-
-const APP_HEADER_MODE = "none";
-
-interface BuildReloadableAppEntryUrlArgs {
- appId: string;
- reloadToken: number;
- threadId: string;
-}
+import { AppViewer } from "@/components/app-viewer/AppViewer";
export interface AppTabContentProps {
- appId: string;
+ applicationId: string;
threadId: string;
}
-function buildReloadableAppEntryUrl({
- appId,
- reloadToken,
- threadId,
-}: BuildReloadableAppEntryUrlArgs): string {
- return `${buildThreadAppEntryUrl(threadId, appId)}?v=${encodeURIComponent(
- String(reloadToken),
- )}`;
-}
-
-export function AppTabContent({ appId, threadId }: AppTabContentProps) {
- const appDetail = useThreadApp(threadId, appId);
- const markdownEntryPath =
- appDetail.data?.entry.kind === "md" ? appDetail.data.entry.path : null;
- const markdownPreview = useThreadAppMarkdownPreview(
- threadId,
- appId,
- markdownEntryPath,
- {
- enabled: markdownEntryPath !== null,
- },
- );
- const markdownAssetBaseUrl = useMemo(() => {
- if (markdownEntryPath === null) {
- return null;
- }
- return buildThreadAppAssetBaseUrl(threadId, appId, markdownEntryPath);
- }, [appId, markdownEntryPath, threadId]);
- const markdownUrlTransform = useMemo(() => {
- if (markdownAssetBaseUrl === null) {
- return undefined;
- }
- return createAssetMarkdownUrlTransform(markdownAssetBaseUrl);
- }, [markdownAssetBaseUrl]);
- const htmlEntryUrl = useMemo(
- () =>
- buildReloadableAppEntryUrl({
- appId,
- reloadToken: appDetail.dataUpdatedAt,
- threadId,
- }),
- [appDetail.dataUpdatedAt, appId, threadId],
- );
-
- if (appDetail.isError) {
- return (
-
- );
- }
-
- if (!appDetail.data) {
- return (
-
- );
- }
-
- if (appDetail.data.entry.kind === "html") {
- return (
-
- );
- }
-
- if (markdownPreview.isError) {
- return (
-
- );
- }
-
- if (!markdownPreview.data) {
- return (
-
- );
- }
-
- if (markdownPreview.data.kind !== "text") {
- return (
-
- );
- }
-
- if (markdownPreview.data.content.length === 0) {
- return (
-
- );
- }
-
- return (
-
- );
+/**
+ * In-thread secondary-panel host for a global app. Delegates to the shared
+ * {@link AppViewer}, targeting the panel's thread so the app's `message`
+ * capability posts into that thread.
+ */
+export function AppTabContent({ applicationId, threadId }: AppTabContentProps) {
+ return ;
}
diff --git a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
index fe0a6858c..79e7ffca8 100644
--- a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx
@@ -10,8 +10,10 @@ import {
import type {
BbDesktopBrowserApi,
BbDesktopBrowserState,
+ BbDesktopBrowserViewportBounds,
BbDesktopBrowserViewBounds,
} from "@bb/server-contract";
+import { clampBbDesktopBrowserViewBounds } from "@bb/server-contract";
import { Icon } from "@/components/ui/icon.js";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import {
@@ -19,8 +21,12 @@ import {
resolveBrowserAddressInput,
} from "@/lib/browser-url";
import { useBrowserHistory } from "@/lib/browser-history";
+import { BROWSER_VIEW_BOUNDS_SYNC_EVENT } from "@/lib/browser-view-bounds-sync";
import { BrowserNewTabScreen } from "./BrowserNewTabScreen";
-import type { BrowserViewVisibilityCoordinator } from "./browserViewVisibilityCoordinator";
+import {
+ registerBrowserView,
+ type BrowserViewVisibilityCoordinator,
+} from "./browserViewVisibilityCoordinator";
import type { UpdateBrowserTabArgs } from "./useThreadFileTabs";
export interface BrowserTabContentProps {
@@ -38,6 +44,7 @@ export interface BrowserTabContentProps {
* visible at once). Null on the web build, where there is no native view.
*/
visibilityCoordinator: BrowserViewVisibilityCoordinator | null;
+ environmentId: string | null;
threadId: string;
onUpdate: (args: UpdateBrowserTabArgs) => void;
}
@@ -63,6 +70,17 @@ interface NavButtonProps {
onClick: () => void;
}
+interface BrowserViewBoundsFromElementArgs {
+ element: HTMLElement;
+}
+
+const EMPTY_BROWSER_VIEW_BOUNDS: BbDesktopBrowserViewBounds = {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+};
+
function roundedBoundsFromRect(rect: DOMRect): BbDesktopBrowserViewBounds {
return {
x: Math.round(rect.left),
@@ -72,6 +90,22 @@ function roundedBoundsFromRect(rect: DOMRect): BbDesktopBrowserViewBounds {
};
}
+function browserViewportBounds(): BbDesktopBrowserViewportBounds {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+}
+
+function browserViewBoundsFromElement(
+ args: BrowserViewBoundsFromElementArgs,
+): BbDesktopBrowserViewBounds {
+ return clampBbDesktopBrowserViewBounds({
+ bounds: roundedBoundsFromRect(args.element.getBoundingClientRect()),
+ viewport: browserViewportBounds(),
+ });
+}
+
function NavButton({ icon, label, disabled, onClick }: NavButtonProps) {
return (
0;
-
// Pending rAF handle for the coalesced bounds sync, so a burst of resize ticks
// collapses to a single native setBounds per frame.
const boundsSyncFrameRef = useRef(null);
+ const readBounds = useCallback(() => {
+ const element = contentRef.current;
+ if (element === null) {
+ return null;
+ }
+ return browserViewBoundsFromElement({ element });
+ }, []);
+
+ const sendBounds = useCallback(
+ (bounds: BbDesktopBrowserViewBounds) => {
+ if (desktopBrowser === null) {
+ return;
+ }
+ desktopBrowser.setBounds({
+ tabId,
+ bounds,
+ });
+ },
+ [desktopBrowser, tabId],
+ );
+
// Push the current content-rect to the native overlay immediately. The
// coordinator's show() calls this synchronously so bounds always land before
// the view is made visible (never a stale/zero-bounds flash on activation).
const syncBounds = useCallback(() => {
- const element = contentRef.current;
- if (element === null || desktopBrowser === null) {
+ const bounds = readBounds();
+ if (bounds === null) {
return;
}
- desktopBrowser.setBounds({
- tabId,
- bounds: roundedBoundsFromRect(element.getBoundingClientRect()),
- });
- }, [desktopBrowser, tabId]);
+ sendBounds(bounds);
+ }, [readBounds, sendBounds]);
+
+ const syncInitialBounds = useCallback(() => {
+ return readBounds() ?? EMPTY_BROWSER_VIEW_BOUNDS;
+ }, [readBounds]);
// rAF-coalesced bounds sync for live resize tracking. A drag-resize fires the
// ResizeObserver (and `window` resize) repeatedly; batching to one setBounds
@@ -254,21 +310,17 @@ export function BrowserTabContent({
});
}, [syncBounds]);
- // Create the native view on mount, stream navigation state back, and tear it
- // down on unmount. Because the deck keeps every open browser tab mounted, this
- // unmounts only when the tab is CLOSED (or the panel/thread unmounts) — never
- // on mere deactivation — so the view (and its page + scroll) survives tab
- // switches. Destroying on unmount keeps the lifecycle leak-free.
+ // Create (or re-attach to) the native view on mount and stream navigation
+ // state back. Unmount is not ownership teardown: switching threads unmounts
+ // the deck, but the native view is intentionally retained so returning to the
+ // thread can show the existing page without recreating/reloading it.
useEffect(() => {
if (desktopBrowser === null) {
return;
}
- const element = contentRef.current;
- const initialBounds =
- element !== null
- ? roundedBoundsFromRect(element.getBoundingClientRect())
- : { x: 0, y: 0, width: 0, height: 0 };
+ const initialBounds = syncInitialBounds();
const mountUrl = initialUrlRef.current;
+ registerBrowserView({ environmentId, tabId, threadId });
desktopBrowser.attach({
tabId,
url: mountUrl,
@@ -299,12 +351,19 @@ export function BrowserTabContent({
return () => {
unsubscribe();
- // Forget this tab before destroying its view so a subsequent show on
- // another tab does not try to hide a view that no longer exists.
+ // The native view survives this unmount. Only explicit tab close/thread
+ // deletion owns detach; unmount just disconnects this component's state
+ // listener and forgets any stale visibility ownership.
visibilityCoordinator?.release(tabId);
- desktopBrowser.detach(tabId);
};
- }, [desktopBrowser, visibilityCoordinator, tabId]);
+ }, [
+ desktopBrowser,
+ environmentId,
+ syncInitialBounds,
+ visibilityCoordinator,
+ tabId,
+ threadId,
+ ]);
// Track the panel content rect so the native overlay stays aligned — including
// throughout a drag-resize, where the rect changes every frame. Coalesced via
@@ -329,13 +388,29 @@ export function BrowserTabContent({
};
}, [desktopBrowser, scheduleBoundsSync]);
+ // ResizeObserver only reports size changes; dragging the left sidebar can
+ // move this content rect without changing its width. AppLayout emits this
+ // event from the same rAF that applies the live sidebar width, so the native
+ // view is re-pinned to the content edge even on position-only layout shifts.
+ useEffect(() => {
+ if (desktopBrowser === null) {
+ return;
+ }
+
+ window.addEventListener(BROWSER_VIEW_BOUNDS_SYNC_EVENT, syncBounds);
+
+ return () => {
+ window.removeEventListener(BROWSER_VIEW_BOUNDS_SYNC_EVENT, syncBounds);
+ };
+ }, [desktopBrowser, syncBounds]);
+
// The native view is shown whenever this tab is the active panel tab and has a
// page. It is NOT hidden during a drag-resize — the overlay tracks the live
- // bounds (see the ResizeObserver effect) so it follows the panel smoothly
- // instead of blanking and flashing. It stays attached when hidden, so
- // deactivation never reloads it. (Collapse/expand of the panel toggles
- // `isActive`, which hides the view outright rather than chasing a CSS
- // transition the overlay cannot clip to.)
+ // bounds (see the ResizeObserver and layout-sync effects) so it follows
+ // the panel smoothly instead of blanking and flashing. It stays attached when
+ // hidden, so deactivation never reloads it. (Collapse/expand of the panel
+ // toggles `isActive`, which hides the view outright rather than chasing a
+ // CSS transition the overlay cannot clip to.)
const isViewVisible = isActive && hasPage;
// A layout effect (pre-paint) declares visibility so showing/hiding lands in
// the same frame as the DOM tab swap — no flash. Ordering across tabs (hide
@@ -348,7 +423,9 @@ export function BrowserTabContent({
}
if (isViewVisible) {
visibilityCoordinator.show(tabId, syncBounds);
- return;
+ return () => {
+ visibilityCoordinator.hide(tabId);
+ };
}
visibilityCoordinator.hide(tabId);
}, [visibilityCoordinator, tabId, isViewVisible, syncBounds]);
diff --git a/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx b/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx
index 3dbc07583..6048b647c 100644
--- a/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx
+++ b/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx
@@ -6,15 +6,19 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type {
BbDesktopApi,
BbDesktopBrowserApi,
+ BbDesktopBrowserViewBounds,
BbDesktopInfo,
BbDesktopInfoChangeHandler,
} from "@bb/server-contract";
import type { BrowserFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
+import { BROWSER_VIEW_BOUNDS_SYNC_EVENT } from "@/lib/browser-view-bounds-sync";
import { BrowserTabDeck } from "./BrowserTabDeck";
+import { resetBrowserViewPersistence } from "./browserViewVisibilityCoordinator";
import { threadSecondaryPanelResizingAtom } from "./threadSecondaryPanelAtoms";
interface RecordedBrowserCall {
method: "attach" | "detach" | "setVisible" | "setBounds" | "navigate";
+ bounds: BbDesktopBrowserViewBounds | null;
tabId: string;
visible: boolean | null;
}
@@ -40,26 +44,38 @@ function createRecordingBrowserApi(): RecordingBrowserApi {
attach(request) {
calls.push({
method: "attach",
+ bounds: request.bounds,
tabId: request.tabId,
visible: request.visible,
});
},
detach(tabId) {
- calls.push({ method: "detach", tabId, visible: null });
+ calls.push({ method: "detach", bounds: null, tabId, visible: null });
},
navigate(request) {
- calls.push({ method: "navigate", tabId: request.tabId, visible: null });
+ calls.push({
+ method: "navigate",
+ bounds: null,
+ tabId: request.tabId,
+ visible: null,
+ });
},
goBack() {},
goForward() {},
reload() {},
stop() {},
setBounds(request) {
- calls.push({ method: "setBounds", tabId: request.tabId, visible: null });
+ calls.push({
+ method: "setBounds",
+ bounds: request.bounds,
+ tabId: request.tabId,
+ visible: null,
+ });
},
setVisible(request) {
calls.push({
method: "setVisible",
+ bounds: null,
tabId: request.tabId,
visible: request.visible,
});
@@ -101,6 +117,56 @@ function browserTab(id: string, url: string): BrowserFixedPanelTab {
const TAB_A = browserTab("browser:a", "https://a.example/");
const TAB_B = browserTab("browser:b", "https://b.example/");
+const TAB_C = browserTab("browser:c", "https://c.example/");
+
+type RestoreHandler = () => void;
+
+interface BrowserContentRect {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+interface BrowserViewportSize {
+ height: number;
+ width: number;
+}
+
+interface QueuedAnimationFrame {
+ callback: FrameRequestCallback;
+ canceled: boolean;
+ id: number;
+}
+
+interface QueuedAnimationFrameController {
+ flushNext(): void;
+ restore(): void;
+}
+
+interface ThreadDeckHostProps {
+ activeBrowserTabId: string | null;
+ browserTabs: readonly BrowserFixedPanelTab[];
+ threadId: string;
+}
+
+function ThreadDeckHost({
+ activeBrowserTabId,
+ browserTabs,
+ threadId,
+}: ThreadDeckHostProps) {
+ return (
+ {}}
+ />
+ );
+}
function visibilityFor(
calls: readonly RecordedBrowserCall[],
@@ -140,9 +206,106 @@ function maxConcurrentVisible(calls: readonly RecordedBrowserCall[]): number {
return max;
}
+function installBrowserContentRect(
+ rect: BrowserContentRect,
+): RestoreHandler {
+ const rectMock = vi
+ .spyOn(HTMLElement.prototype, "getBoundingClientRect")
+ .mockImplementation(
+ () => new DOMRect(rect.left, rect.top, rect.width, rect.height),
+ );
+ return () => rectMock.mockRestore();
+}
+
+function installViewportSize(size: BrowserViewportSize): RestoreHandler {
+ const previousWidth = window.innerWidth;
+ const previousHeight = window.innerHeight;
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: size.width,
+ });
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: size.height,
+ });
+ return () => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: previousWidth,
+ });
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ value: previousHeight,
+ });
+ };
+}
+
+function installQueuedAnimationFrame(): QueuedAnimationFrameController {
+ const frames: QueuedAnimationFrame[] = [];
+ let nextId = 1;
+ const requestFrame = vi
+ .spyOn(window, "requestAnimationFrame")
+ .mockImplementation((callback) => {
+ const frame: QueuedAnimationFrame = {
+ callback,
+ canceled: false,
+ id: nextId,
+ };
+ nextId += 1;
+ frames.push(frame);
+ return frame.id;
+ });
+ const cancelFrame = vi
+ .spyOn(window, "cancelAnimationFrame")
+ .mockImplementation((id) => {
+ const frame = frames.find((candidate) => candidate.id === id);
+ if (frame !== undefined) {
+ frame.canceled = true;
+ }
+ });
+
+ return {
+ flushNext() {
+ const frame = frames.shift();
+ if (frame === undefined || frame.canceled) {
+ return;
+ }
+ frame.callback(0);
+ },
+ restore() {
+ requestFrame.mockRestore();
+ cancelFrame.mockRestore();
+ },
+ };
+}
+
+function lastSetBoundsFor(
+ calls: readonly RecordedBrowserCall[],
+ tabId: string,
+): BbDesktopBrowserViewBounds | null {
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
+ const call = calls[index];
+ if (call?.method === "setBounds" && call.tabId === tabId) {
+ return call.bounds;
+ }
+ }
+ return null;
+}
+
+function attachBoundsFor(
+ calls: readonly RecordedBrowserCall[],
+ tabId: string,
+): BbDesktopBrowserViewBounds | null {
+ const call = calls.find(
+ (candidate) => candidate.method === "attach" && candidate.tabId === tabId,
+ );
+ return call?.bounds ?? null;
+}
+
afterEach(() => {
cleanup();
delete window.bbDesktop;
+ resetBrowserViewPersistence();
window.localStorage.clear();
// The resizing flag lives in the default jotai store, which persists across
// tests in this module; reset it so a resize test never leaks into the next.
@@ -158,6 +321,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -182,6 +346,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -195,6 +360,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -204,6 +370,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -218,6 +385,76 @@ describe("BrowserTabDeck", () => {
expect(visibilityFor(calls, TAB_B.id)).toBe(false);
});
+ it("hides but does not destroy the active view when a thread deck unmounts", () => {
+ const { api, calls } = createRecordingBrowserApi();
+ installDesktopBrowserApi(api);
+
+ const { unmount } = render(
+ {}}
+ />,
+ );
+
+ calls.length = 0;
+
+ unmount();
+
+ expect(calls.some((call) => call.method === "detach")).toBe(false);
+ expect(visibilityFor(calls, TAB_A.id)).toBe(false);
+ });
+
+ it("switches thread decks by hiding the old thread before showing the new one", () => {
+ const { api, calls } = createRecordingBrowserApi();
+ installDesktopBrowserApi(api);
+
+ const { rerender } = render(
+ ,
+ );
+
+ calls.length = 0;
+
+ rerender(
+ ,
+ );
+
+ const hideOldThread = calls.findIndex(
+ (call) =>
+ call.method === "setVisible" &&
+ call.tabId === TAB_A.id &&
+ call.visible === false,
+ );
+ const newThreadBounds = calls.findIndex(
+ (call) => call.method === "setBounds" && call.tabId === TAB_C.id,
+ );
+ const showNewThread = calls.findIndex(
+ (call) =>
+ call.method === "setVisible" &&
+ call.tabId === TAB_C.id &&
+ call.visible === true,
+ );
+
+ expect(hideOldThread).toBeGreaterThanOrEqual(0);
+ expect(newThreadBounds).toBeGreaterThanOrEqual(0);
+ expect(showNewThread).toBeGreaterThanOrEqual(0);
+ expect(hideOldThread).toBeLessThan(showNewThread);
+ expect(newThreadBounds).toBeLessThan(showNewThread);
+ expect(calls.some((call) => call.method === "detach")).toBe(false);
+ expect(maxConcurrentVisible(calls)).toBe(1);
+ });
+
it("syncs bounds before showing a newly-activated view (no stale-bounds flash)", () => {
const { api, calls } = createRecordingBrowserApi();
installDesktopBrowserApi(api);
@@ -226,6 +463,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -238,6 +476,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -256,6 +495,85 @@ describe("BrowserTabDeck", () => {
expect(setBoundsIndex).toBeLessThan(showIndex);
});
+ it("resyncs bounds when the panel position changes without resizing", () => {
+ const { api, calls } = createRecordingBrowserApi();
+ installDesktopBrowserApi(api);
+ const rect: BrowserContentRect = {
+ left: 320,
+ top: 96,
+ width: 480,
+ height: 360,
+ };
+ const restoreRect = installBrowserContentRect(rect);
+ try {
+ render(
+ {}}
+ />,
+ );
+
+ calls.length = 0;
+
+ rect.left = 248;
+ rect.top = 88;
+
+ act(() => {
+ window.dispatchEvent(new Event(BROWSER_VIEW_BOUNDS_SYNC_EVENT));
+ });
+
+ expect(lastSetBoundsFor(calls, TAB_A.id)).toEqual({
+ x: 248,
+ y: 88,
+ width: 480,
+ height: 360,
+ });
+ } finally {
+ restoreRect();
+ }
+ });
+
+ it("anchors browser bounds to the content left edge and clamps them to the viewport", () => {
+ const { api, calls } = createRecordingBrowserApi();
+ installDesktopBrowserApi(api);
+ const restoreViewport = installViewportSize({ width: 500, height: 360 });
+ const restoreRect = installBrowserContentRect({
+ left: 180,
+ top: 48,
+ width: 400,
+ height: 420,
+ });
+
+ try {
+ render(
+ {}}
+ />,
+ );
+
+ const expectedBounds: BbDesktopBrowserViewBounds = {
+ x: 180,
+ y: 48,
+ width: 320,
+ height: 312,
+ };
+ expect(attachBoundsFor(calls, TAB_A.id)).toEqual(expectedBounds);
+ expect(lastSetBoundsFor(calls, TAB_A.id)).toEqual(expectedBounds);
+ } finally {
+ restoreRect();
+ restoreViewport();
+ }
+ });
+
it("hides the later view before showing the earlier one when switching B -> A", () => {
const { api, calls } = createRecordingBrowserApi();
installDesktopBrowserApi(api);
@@ -264,6 +582,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -276,6 +595,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -288,6 +608,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -320,6 +641,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -331,6 +653,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -349,6 +672,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -362,6 +686,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -380,6 +705,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -392,6 +718,7 @@ describe("BrowserTabDeck", () => {
{}}
@@ -421,19 +748,13 @@ describe("BrowserTabDeck", () => {
disconnect() {}
}
vi.stubGlobal("ResizeObserver", CapturingResizeObserver);
- // Run the rAF-coalesced bounds sync synchronously so the resize tick lands a
- // setBounds within this test's `act` scope.
- const requestFrame = vi
- .spyOn(window, "requestAnimationFrame")
- .mockImplementation((callback) => {
- callback(0);
- return 0;
- });
+ const animationFrame = installQueuedAnimationFrame();
render(
{}}
@@ -449,6 +770,8 @@ describe("BrowserTabDeck", () => {
for (const fireResizeTick of resizeCallbacks) {
fireResizeTick();
}
+ animationFrame.flushNext();
+ animationFrame.flushNext();
});
const duringResize = calls.slice(resizeStart);
@@ -471,7 +794,7 @@ describe("BrowserTabDeck", () => {
// Net effect: the active view stays visible throughout the resize.
expect(visibilityFor(calls, TAB_A.id)).toBe(true);
- requestFrame.mockRestore();
+ animationFrame.restore();
vi.unstubAllGlobals();
});
});
diff --git a/apps/app/src/components/secondary-panel/BrowserTabDeck.tsx b/apps/app/src/components/secondary-panel/BrowserTabDeck.tsx
index b8d04803e..ade72db8a 100644
--- a/apps/app/src/components/secondary-panel/BrowserTabDeck.tsx
+++ b/apps/app/src/components/secondary-panel/BrowserTabDeck.tsx
@@ -1,31 +1,47 @@
-import { useMemo, useRef } from "react";
+import { useEffect, useMemo, useRef } from "react";
import type { BrowserFixedPanelTab } from "@/lib/fixed-panel-tabs-state";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import { cn } from "@/lib/utils";
import { BrowserTabContent } from "./BrowserTabContent";
import {
- createBrowserViewVisibilityCoordinator,
- type BrowserViewVisibilityCoordinator,
+ destroyPersistedBrowserView,
+ getBrowserViewVisibilityCoordinator,
} from "./browserViewVisibilityCoordinator";
import type { UpdateBrowserTabArgs } from "./useThreadFileTabs";
export interface BrowserTabDeckProps {
browserTabs: readonly BrowserFixedPanelTab[];
activeBrowserTabId: string | null;
+ environmentId: string | null;
/** Whether the secondary panel is open; gates the active view's visibility. */
isPanelOpen: boolean;
threadId: string;
onUpdate: (args: UpdateBrowserTabArgs) => void;
}
+interface BrowserTabIdSnapshot {
+ tabIds: ReadonlySet;
+ threadId: string;
+}
+
+interface BuildBrowserTabIdSetArgs {
+ browserTabs: readonly BrowserFixedPanelTab[];
+}
+
+function buildBrowserTabIdSet({
+ browserTabs,
+}: BuildBrowserTabIdSetArgs): ReadonlySet {
+ return new Set(browserTabs.map((tab) => tab.id));
+}
+
/**
* Renders every open browser tab at once, keeping each tab's native
* `WebContentsView` mounted (and its page + scroll intact) for the tab's whole
* lifetime. Only the active tab is laid out and visible; the rest are
* `display:none` with their views hidden — so switching tabs is a visibility
- * toggle, never a destroy/recreate + reload. A tab's view is torn down only when
- * its `BrowserTabContent` unmounts, i.e. when the tab is closed or the panel /
- * thread unmounts.
+ * toggle, never a destroy/recreate + reload. A tab's view is torn down when it
+ * leaves this thread's open-tab list; thread navigation only unmounts this deck,
+ * so retained views stay alive for when the user returns.
*
* Mounted regardless of which panel tab is active so the views survive switching
* to a non-browser tab; the whole deck collapses to `display:none` when no
@@ -34,19 +50,34 @@ export interface BrowserTabDeckProps {
export function BrowserTabDeck({
browserTabs,
activeBrowserTabId,
+ environmentId,
isPanelOpen,
threadId,
onUpdate,
}: BrowserTabDeckProps) {
const desktopBrowser = useMemo(() => getDesktopBrowserApi(), []);
- // One coordinator per deck owns the cross-tab visibility ordering for the
- // lifetime of the panel; created once (the native bridge identity is stable).
- const coordinatorRef = useRef(null);
- if (desktopBrowser !== null && coordinatorRef.current === null) {
- coordinatorRef.current =
- createBrowserViewVisibilityCoordinator(desktopBrowser);
- }
- const visibilityCoordinator = coordinatorRef.current;
+ const previousTabIdsRef = useRef(null);
+ const visibilityCoordinator =
+ desktopBrowser === null
+ ? null
+ : getBrowserViewVisibilityCoordinator(desktopBrowser);
+
+ useEffect(() => {
+ const tabIds = buildBrowserTabIdSet({ browserTabs });
+ const previous = previousTabIdsRef.current;
+ if (
+ desktopBrowser !== null &&
+ previous !== null &&
+ previous.threadId === threadId
+ ) {
+ for (const tabId of previous.tabIds) {
+ if (!tabIds.has(tabId)) {
+ destroyPersistedBrowserView({ desktopBrowser, tabId });
+ }
+ }
+ }
+ previousTabIdsRef.current = { tabIds, threadId };
+ }, [browserTabs, desktopBrowser, threadId]);
if (browserTabs.length === 0) {
return null;
@@ -71,6 +102,7 @@ export function BrowserTabDeck({
initialUrl={tab.url}
isActive={isActive && isPanelOpen}
visibilityCoordinator={visibilityCoordinator}
+ environmentId={environmentId}
threadId={threadId}
onUpdate={onUpdate}
/>
diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
index 15b39bba2..f904ebb49 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
@@ -26,8 +26,8 @@ describe("FilePreview", () => {
state={{
kind: "iframe",
sandbox: null,
- title: "Status",
- url: "/api/v1/threads/thr_1/apps/status/",
+ title: "Review Board",
+ url: "/api/v1/apps/review-board/?targetThreadId=thr_1",
}}
/>,
);
@@ -54,13 +54,13 @@ describe("FilePreview", () => {
state={{
kind: "iframe",
sandbox: null,
- title: "Status",
- url: "/api/v1/threads/thr_1/apps/status/",
+ title: "Review Board",
+ url: "/api/v1/apps/review-board/?targetThreadId=thr_1",
}}
/>,
);
- fireEvent.load(screen.getByTitle("Status"));
+ fireEvent.load(screen.getByTitle("Review Board"));
act(() => {
vi.advanceTimersByTime(160);
});
diff --git a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
index dc1fe0d6a..72d6d6be1 100644
--- a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx
@@ -28,8 +28,8 @@ function makeFile(path: string): WorkspaceFile {
const FILES: WorkspaceFile[] = [
makeFile("ASYNC.md"),
- makeFile("apps/status/manifest.json"),
- makeFile("apps/status/data/state.json"),
+ makeFile("notes/current-work.md"),
+ makeFile("plans/kickoff.md"),
makeFile("PREFERENCES.md"),
];
diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
index 39327117c..9848e167c 100644
--- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
+++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx
@@ -33,16 +33,16 @@ import { isDesktopBrowserAvailable } from "@/lib/bb-desktop";
import { formatRelativeTime } from "@/lib/relative-time";
import { isPromptDraftEmpty, type PromptDraftState } from "@/lib/prompt-draft";
-export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new bb app for this thread.
+export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new global bb app.
Apps system reference — run \`bb guide app\` for full detail. Layout:
-- apps//manifest.json — { manifestVersion: 1, id, name, icon | logo.svg, entry, contributions: ["thread.app"], capabilities: ["data"?, "message"?] }
-- apps//assets/index.html — self-contained static HTML/CSS/JS/SVG served by bb; use inline/relative files, no web server, npm, or build step
-- apps//data/state.json — initial state if the app uses window.bb.data
+- /apps//manifest.json — { manifestVersion: 1, id: applicationId, name?, icon | logo.svg, entry, capabilities: ["data"?, "message"?] }
+- /apps//public/index.html — self-contained static HTML/CSS/JS/SVG served by bb; use inline/relative files, no web server, npm, or build step
+- /apps//data/state.json — state if the app uses window.bb.data
In the page, use window.bb.data for live state (read / write / delete / list / onChange; onChange replays + streams) and window.bb.message(text) to send the thread a prompt. Guard with \`window.bb?.data?.…\` since capabilities are advisory.
-Scaffold with \`bb app new\` — the default styling is wired up already, so build on top of it and keep the UI polished, accessible, and dense like the rest of bb.
+Scaffold with \`bb app new --name "Name"\` or \`bb app new --slug my-app\`; inside an app-capable runtime, inspect \`bb app current --json\` and write directly to \`BB_APP_ROOT\` / \`BB_APP_DATA_PATH\`. The application id is the lowercase slug folder name; display names are optional labels, not identifiers.
What I want:
@@ -217,14 +217,6 @@ const RECENT_CHIP_ICON_NAME = {
} satisfies Record;
const RECENT_ENTRY_ID_PREFIX = "file-search-result-recent";
-function slugifyAppName(name: string): string {
- return name
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "");
-}
-
function getAvailableFileSearchSources({
projectId,
currentThreadId,
@@ -245,7 +237,7 @@ function getAvailableFileSearchSources({
function getFileSearchResultId(suggestion: FileSearchSuggestion): string {
const idSegment =
- suggestion.entryKind === "app" ? suggestion.appId : suggestion.path;
+ suggestion.entryKind === "app" ? suggestion.applicationId : suggestion.path;
return `file-search-result-${suggestion.source}-${encodeURIComponent(
idSegment,
)}`;
@@ -442,8 +434,6 @@ function AppResultRow({
const handleSelect = useCallback(() => {
onSelect(suggestion);
}, [onSelect, suggestion]);
- const showAppId = suggestion.appId !== slugifyAppName(suggestion.name);
-
return (
{suggestion.name}
- {showAppId ? (
- <>
-
- ·
-
-
- {suggestion.appId}
-
- >
- ) : null}
+
+ ·
+
+
+ {suggestion.applicationId}
+
);
@@ -597,7 +583,10 @@ function RecentResultRow({
const { chip, label } = resolveRecentFileKind(item.path);
const name = getRecentItemName(item.path);
const { directory } = splitPath(item.path);
- const relativeTime = formatRelativeTime({ timestamp: item.openedAt, now: nowMs });
+ const relativeTime = formatRelativeTime({
+ timestamp: item.openedAt,
+ now: nowMs,
+ });
return (
@@ -757,9 +747,7 @@ export function NewTabFileSearch({
);
const navigableEntries = useMemo(
() =>
- sections.flatMap((section) =>
- section.items.map(({ entry }) => entry),
- ),
+ sections.flatMap((section) => section.items.map(({ entry }) => entry)),
[sections],
);
const activeEntry = useMemo(
@@ -787,7 +775,7 @@ export function NewTabFileSearch({
const handleAppSelect = useCallback(
(suggestion: AppSearchSuggestion) => {
- onSelect({ source: "app", appId: suggestion.appId });
+ onSelect({ source: "app", applicationId: suggestion.applicationId });
},
[onSelect],
);
@@ -1006,7 +994,8 @@ function NewTabResults({
const showAppsSection = appsSection !== undefined;
const showOpenSection = openSection !== undefined;
const showFilesSection = filesSection !== undefined;
- const showRecentSection = recentSection !== undefined || recent.emptyHintVisible;
+ const showRecentSection =
+ recentSection !== undefined || recent.emptyHintVisible;
const hasSectionsAbove =
showAppsSection || showOpenSection || showFilesSection;
const showLoading = isLoading && !showFilesSection;
@@ -1067,7 +1056,7 @@ function NewTabResults({
const suggestion = entry.suggestion;
return (
{
return {
...actual,
searchProjectPaths: vi.fn(),
- listThreadApps: vi.fn(),
+ listApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -74,9 +80,9 @@ function makePathResponse(
};
}
-const STATUS_APP: AppSummary = {
- id: "status",
- name: "Status",
+const APP: AppSummary = {
+ applicationId: "status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -124,7 +130,7 @@ function seedRecentItems(threadId: string, items: ThreadRecentItem[]): void {
}
function mockEmptySearchSources(): void {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(makePathResponse([]));
vi.mocked(api.listThreadStoragePaths).mockResolvedValue({
...makePathResponse([]),
@@ -162,7 +168,7 @@ afterEach(() => {
describe("NewTabPage", () => {
it("autofocuses search and selects a workspace result", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -192,23 +198,25 @@ describe("NewTabPage", () => {
});
it("lists apps and opens an app selection", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
const { onSelect } = renderNewTabPage({
currentThreadId: "thr-manager",
currentThreadType: "manager",
});
expect(await screen.findByText("Apps")).toBeTruthy();
- fireEvent.click(await screen.findByRole("option", { name: /Status/u }));
+ fireEvent.click(
+ await screen.findByRole("option", { name: /Review Board/u }),
+ );
expect(onSelect).toHaveBeenCalledWith({
source: "app",
- appId: "status",
+ applicationId: "status",
});
});
it("prefills the composer draft with the create-app prompt", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
renderNewTabPage({ projectId: "proj-1" });
fireEvent.click(screen.getByRole("option", { name: /Create App/u }));
@@ -233,7 +241,7 @@ describe("NewTabPage", () => {
});
it("leaves a non-empty composer draft unchanged when replacement is canceled", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.spyOn(window, "confirm").mockReturnValue(false);
setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
renderNewTabPage({ projectId: "proj-1" });
@@ -244,7 +252,7 @@ describe("NewTabPage", () => {
});
it("replaces non-empty composer text and attachments after confirmation", () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.spyOn(window, "confirm").mockReturnValue(true);
setStoredThreadDraft(DRAFT_WITH_ATTACHMENT);
renderNewTabPage({ projectId: "proj-1" });
@@ -258,13 +266,15 @@ describe("NewTabPage", () => {
});
it("arrows past the app rows onto the create-app entry as the last option", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
renderNewTabPage({ projectId: "proj-1" });
const input = screen.getByRole("textbox", {
name: "Search apps and files",
});
- const appOption = await screen.findByRole("option", { name: /Status/u });
+ const appOption = await screen.findByRole("option", {
+ name: /Review Board/u,
+ });
const createAppOption = screen.getByRole("option", {
name: /Create App/u,
});
@@ -286,13 +296,13 @@ describe("NewTabPage", () => {
});
it("prefills the composer draft when the create-app entry is activated by Enter", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
renderNewTabPage({ projectId: "proj-1" });
const input = screen.getByRole("textbox", {
name: "Search apps and files",
});
- await screen.findByRole("option", { name: /Status/u });
+ await screen.findByRole("option", { name: /Review Board/u });
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
@@ -304,7 +314,7 @@ describe("NewTabPage", () => {
});
it("selects a manager thread-storage result with the keyboard", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -351,7 +361,7 @@ describe("NewTabPage", () => {
screen.getByText("No searchable app or file source is available."),
).toBeTruthy();
expect(api.searchProjectPaths).not.toHaveBeenCalled();
- expect(api.listThreadApps).not.toHaveBeenCalled();
+ expect(api.listApps).not.toHaveBeenCalled();
expect(api.listThreadStoragePaths).not.toHaveBeenCalled();
});
});
@@ -467,7 +477,7 @@ describe("NewTabPage recent section", () => {
it("reaches a recent row via keyboard navigation", async () => {
mockEmptySearchSources();
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
const threadId = "thr-recent-keys";
seedRecentItems(threadId, [
{
@@ -488,7 +498,9 @@ describe("NewTabPage recent section", () => {
// Await the app row so the async sources have settled and the active-index
// reset no longer fires; the recent row trails Apps + Create App in one
// shared index space, so ArrowUp wraps onto it from the first entry.
- const appOption = await screen.findByRole("option", { name: /Status/u });
+ const appOption = await screen.findByRole("option", {
+ name: /Review Board/u,
+ });
const recentOption = screen.getByRole("option", {
name: /swap-model\.md/u,
});
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
index 64028215a..b51c3bfec 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx
@@ -189,13 +189,13 @@ afterEach(() => {
describe("ThreadSecondaryPanel", () => {
it.each([
{
- name: "status app iframe",
+ name: "app iframe",
activeTab: buildActiveFileTab({
- id: "app:status",
- filename: "Status",
+ id: "app:review-board",
+ filename: "Review Board",
isPinned: true,
}),
- iframeTitle: "Status app",
+ iframeTitle: "Review Board app",
closeButtonLabel: null,
},
{
@@ -275,9 +275,9 @@ describe("ThreadSecondaryPanel", () => {
])(
"keeps iframe previews interactable after secondary panel resize ends via $name",
async ({ finishDrag }) => {
- const activeStatusTab: SecondaryPanelFileTab = {
- id: "app:status",
- filename: "Status",
+ const activeAppTab: SecondaryPanelFileTab = {
+ id: "app:review-board",
+ filename: "Review Board",
isActive: true,
isPinned: true,
statusLabel: null,
@@ -286,8 +286,8 @@ describe("ThreadSecondaryPanel", () => {
};
renderPanel({
- fileTabs: [activeStatusTab],
- fileTabContent: ,
+ fileTabs: [activeAppTab],
+ fileTabContent: ,
renderAsDrawer: false,
});
@@ -317,7 +317,9 @@ describe("ThreadSecondaryPanel", () => {
// Regression guard: the drag must be intercepted by the overlay, never by
// disabling the iframe's pointer-events — that detaches the iframe's
// compositor scroll node in Chromium and kills wheel-scroll after resize.
- expect(aside?.className).not.toContain(IFRAME_POINTER_EVENTS_TOGGLE_CLASS);
+ expect(aside?.className).not.toContain(
+ IFRAME_POINTER_EVENTS_TOGGLE_CLASS,
+ );
finishDrag();
@@ -348,7 +350,9 @@ describe("ThreadSecondaryPanel", () => {
renderPanel({ reserveLeftForDesktopTrafficLights: false });
const topChrome = screen.getByTestId("thread-secondary-panel-top-chrome");
- expect(topChrome.className).not.toContain(MACOS_TRAFFIC_LIGHT_RESERVE_CLASS);
+ expect(topChrome.className).not.toContain(
+ MACOS_TRAFFIC_LIGHT_RESERVE_CLASS,
+ );
});
it("shows the resize seam hairline while the conversation is expanded", () => {
@@ -367,7 +371,11 @@ describe("ThreadSecondaryPanel", () => {
isBrowserTabActive: true,
browserDeck: browser deck
,
fileTabs: [
- buildActiveFileTab({ id: "browser:a", filename: "Example", isPinned: false }),
+ buildActiveFileTab({
+ id: "browser:a",
+ filename: "Example",
+ isPinned: false,
+ }),
],
fileTabContent: file tab content
,
});
@@ -382,7 +390,11 @@ describe("ThreadSecondaryPanel", () => {
isBrowserTabActive: false,
browserDeck: browser deck
,
fileTabs: [
- buildActiveFileTab({ id: "app:status", filename: "Status", isPinned: true }),
+ buildActiveFileTab({
+ id: "app:status",
+ filename: "Status",
+ isPinned: true,
+ }),
],
fileTabContent: file tab content
,
});
@@ -427,7 +439,7 @@ describe("ThreadSecondaryPanel", () => {
expect(
Boolean(
toggle.compareDocumentPosition(hideButton) &
- Node.DOCUMENT_POSITION_FOLLOWING,
+ Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
index 78499f26c..abe847731 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx
@@ -10,8 +10,8 @@ import type {
import { StoryCard, StoryRow } from "../../../.ladle/story-card";
import { createAppQueryClient } from "@/lib/query-client";
import {
+ appsQueryKey,
projectPathsQueryKey,
- threadAppsQueryKey,
threadStoragePathsQueryKey,
} from "@/hooks/queries/query-keys";
import { ThreadSecondaryPanel } from "./ThreadSecondaryPanel";
@@ -87,10 +87,10 @@ const THREAD_STORAGE_PATH_RESULTS: WorkspacePathEntry[] = [
},
];
-const THREAD_APPS_RESPONSE: AppSummary[] = [
+const APPS_RESPONSE: AppSummary[] = [
{
- id: "status",
- name: "Status",
+ applicationId: "story-review-board",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -99,21 +99,21 @@ const THREAD_APPS_RESPONSE: AppSummary[] = [
const APPS_ROW_APPS: AppSummary[] = [
{
- id: "status",
+ applicationId: "app_status",
name: "Status",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
},
{
- id: "workspace-map",
+ applicationId: "app_workspace_map",
name: "Workspace Map",
entry: { path: "index.html", kind: "html" },
capabilities: ["data"],
icon: { kind: "builtin", name: "GridView" },
},
{
- id: "release-notes",
+ applicationId: "app_release_notes",
name: "Release Notes",
entry: { path: "index.html", kind: "html" },
capabilities: ["message"],
@@ -259,9 +259,7 @@ function useStoryQueryClient({
),
makeWorkspacePathResponse(workspacePaths),
);
- if (currentThreadId.length > 0) {
- queryClient.setQueryData(threadAppsQueryKey(currentThreadId), apps);
- }
+ queryClient.setQueryData(appsQueryKey(), apps);
queryClient.setQueryData(
threadStoragePathsQueryKey(currentThreadId, {
limit: STORY_SOURCE_LIMIT,
@@ -333,11 +331,11 @@ function NewTabPanelStory({
{
id:
selection.source === "app"
- ? `app:${selection.appId}`
+ ? `app:${selection.applicationId}`
: `${selection.source}:${selection.path}`,
filename:
selection.source === "app"
- ? selection.appId
+ ? selection.applicationId
: (selection.path.split("/").at(-1) ?? selection.path),
isActive: true,
statusLabel: null,
@@ -370,7 +368,9 @@ function NewTabPanelStory({
: "thread storage file"}
- {selection.source === "app" ? selection.appId : selection.path}
+ {selection.source === "app"
+ ? selection.applicationId
+ : selection.path}
);
@@ -454,7 +454,7 @@ export function NewTab() {
hint="active New tab seeded with workspace and manager thread-storage matches"
>
{
+ resetBrowserViewPersistence();
+});
+
describe("browserViewVisibilityCoordinator", () => {
it("hides the previously-visible view before showing the next one", () => {
const { api, visibility } = createRecordingApi();
@@ -82,4 +100,68 @@ describe("browserViewVisibilityCoordinator", () => {
{ tabId: "b", visible: true },
]);
});
+
+ it("shares visibility ownership across browser decks in one renderer window", () => {
+ const { api, visibility } = createRecordingApi();
+ const firstDeckCoordinator = getBrowserViewVisibilityCoordinator(api);
+ const secondDeckCoordinator = getBrowserViewVisibilityCoordinator(api);
+
+ firstDeckCoordinator.show("thread-a-tab", () => {});
+ secondDeckCoordinator.show("thread-b-tab", () => {});
+
+ expect(secondDeckCoordinator).toBe(firstDeckCoordinator);
+ expect(visibility).toEqual([
+ { tabId: "thread-a-tab", visible: true },
+ { tabId: "thread-a-tab", visible: false },
+ { tabId: "thread-b-tab", visible: true },
+ ]);
+ });
+
+ it("destroys registered views for a deleted thread only", () => {
+ const { api, detachments, visibility } = createRecordingApi();
+ registerBrowserView({
+ environmentId: "environment-a",
+ tabId: "thread-a-tab",
+ threadId: "thread-a",
+ });
+ registerBrowserView({
+ environmentId: "environment-b",
+ tabId: "thread-b-tab",
+ threadId: "thread-b",
+ });
+
+ destroyPersistedBrowserViewsForThread({
+ desktopBrowser: api,
+ threadId: "thread-a",
+ });
+
+ expect(visibility).toEqual([
+ { tabId: "thread-a-tab", visible: false },
+ ]);
+ expect(detachments).toEqual(["thread-a-tab"]);
+ });
+
+ it("destroys registered views for a deleted environment only", () => {
+ const { api, detachments, visibility } = createRecordingApi();
+ registerBrowserView({
+ environmentId: "environment-a",
+ tabId: "thread-a-tab",
+ threadId: "thread-a",
+ });
+ registerBrowserView({
+ environmentId: "environment-b",
+ tabId: "thread-b-tab",
+ threadId: "thread-b",
+ });
+
+ destroyPersistedBrowserViewsForEnvironment({
+ desktopBrowser: api,
+ environmentId: "environment-b",
+ });
+
+ expect(visibility).toEqual([
+ { tabId: "thread-b-tab", visible: false },
+ ]);
+ expect(detachments).toEqual(["thread-b-tab"]);
+ });
});
diff --git a/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts b/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts
index 55024f637..26a8a451b 100644
--- a/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts
+++ b/apps/app/src/components/secondary-panel/browserViewVisibilityCoordinator.ts
@@ -30,6 +30,37 @@ export interface BrowserViewVisibilityCoordinator {
release(tabId: string): void;
}
+interface BrowserViewRecord {
+ environmentId: string | null;
+ tabId: string;
+ threadId: string;
+}
+
+interface RegisterBrowserViewArgs {
+ environmentId: string | null;
+ tabId: string;
+ threadId: string;
+}
+
+interface DestroyPersistedBrowserViewArgs {
+ desktopBrowser: BbDesktopBrowserApi;
+ tabId: string;
+}
+
+interface DestroyPersistedBrowserViewsForThreadArgs {
+ desktopBrowser: BbDesktopBrowserApi | null;
+ threadId: string;
+}
+
+interface DestroyPersistedBrowserViewsForEnvironmentArgs {
+ desktopBrowser: BbDesktopBrowserApi | null;
+ environmentId: string;
+}
+
+const browserViewRecords = new Map();
+let sharedDesktopBrowser: BbDesktopBrowserApi | null = null;
+let sharedCoordinator: BrowserViewVisibilityCoordinator | null = null;
+
export function createBrowserViewVisibilityCoordinator(
desktopBrowser: BbDesktopBrowserApi,
): BrowserViewVisibilityCoordinator {
@@ -57,3 +88,68 @@ export function createBrowserViewVisibilityCoordinator(
},
};
}
+
+export function getBrowserViewVisibilityCoordinator(
+ desktopBrowser: BbDesktopBrowserApi,
+): BrowserViewVisibilityCoordinator {
+ if (sharedDesktopBrowser !== desktopBrowser || sharedCoordinator === null) {
+ sharedDesktopBrowser = desktopBrowser;
+ sharedCoordinator = createBrowserViewVisibilityCoordinator(desktopBrowser);
+ }
+ return sharedCoordinator;
+}
+
+export function registerBrowserView({
+ environmentId,
+ tabId,
+ threadId,
+}: RegisterBrowserViewArgs): void {
+ browserViewRecords.set(tabId, { environmentId, tabId, threadId });
+}
+
+export function destroyPersistedBrowserView({
+ desktopBrowser,
+ tabId,
+}: DestroyPersistedBrowserViewArgs): void {
+ const coordinator = getBrowserViewVisibilityCoordinator(desktopBrowser);
+ coordinator.hide(tabId);
+ coordinator.release(tabId);
+ desktopBrowser.detach(tabId);
+ browserViewRecords.delete(tabId);
+}
+
+export function destroyPersistedBrowserViewsForThread({
+ desktopBrowser,
+ threadId,
+}: DestroyPersistedBrowserViewsForThreadArgs): void {
+ if (desktopBrowser === null) {
+ return;
+ }
+ const records = [...browserViewRecords.values()];
+ for (const record of records) {
+ if (record.threadId === threadId) {
+ destroyPersistedBrowserView({ desktopBrowser, tabId: record.tabId });
+ }
+ }
+}
+
+export function destroyPersistedBrowserViewsForEnvironment({
+ desktopBrowser,
+ environmentId,
+}: DestroyPersistedBrowserViewsForEnvironmentArgs): void {
+ if (desktopBrowser === null) {
+ return;
+ }
+ const records = [...browserViewRecords.values()];
+ for (const record of records) {
+ if (record.environmentId === environmentId) {
+ destroyPersistedBrowserView({ desktopBrowser, tabId: record.tabId });
+ }
+ }
+}
+
+export function resetBrowserViewPersistence(): void {
+ browserViewRecords.clear();
+ sharedDesktopBrowser = null;
+ sharedCoordinator = null;
+}
diff --git a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.test.ts b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.test.ts
index e643fafe7..eb84e4225 100644
--- a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.test.ts
+++ b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.test.ts
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
getThreadConversationCollapsedAtom,
getThreadConversationCollapsedStorageKey,
+ getThreadSecondaryPanelOpenAtom,
+ getThreadSecondaryPanelOpenStorageKey,
} from "./threadSecondaryPanelAtoms";
const THREAD_A = "thr_a";
@@ -15,6 +17,81 @@ afterEach(() => {
vi.resetModules();
});
+describe("getThreadSecondaryPanelOpenAtom", () => {
+ it("defaults to open=false and persists toggles to a per-thread key", () => {
+ const store = createStore();
+ const atomA = getThreadSecondaryPanelOpenAtom(THREAD_A);
+ expect(store.get(atomA)).toBe(false);
+
+ store.set(atomA, true);
+ expect(store.get(atomA)).toBe(true);
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: THREAD_A }),
+ ),
+ ).toBe("true");
+
+ store.set(atomA, false);
+ expect(store.get(atomA)).toBe(false);
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: THREAD_A }),
+ ),
+ ).toBe("false");
+ });
+
+ it("keeps each thread's open state isolated", () => {
+ const store = createStore();
+ const atomA = getThreadSecondaryPanelOpenAtom(THREAD_A);
+ const atomB = getThreadSecondaryPanelOpenAtom(THREAD_B);
+
+ store.set(atomA, true);
+
+ expect(store.get(atomA)).toBe(true);
+ expect(store.get(atomB)).toBe(false);
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: THREAD_B }),
+ ),
+ ).toBeNull();
+
+ store.set(atomB, false);
+ expect(store.get(atomA)).toBe(true);
+ });
+
+ it("returns a stable atom reference per thread id", () => {
+ expect(getThreadSecondaryPanelOpenAtom(THREAD_A)).toBe(
+ getThreadSecondaryPanelOpenAtom(THREAD_A),
+ );
+ expect(getThreadSecondaryPanelOpenAtom(THREAD_A)).not.toBe(
+ getThreadSecondaryPanelOpenAtom(THREAD_B),
+ );
+ });
+
+ it("falls back to a non-persisted disabled atom without a thread id", () => {
+ const store = createStore();
+ const disabled = getThreadSecondaryPanelOpenAtom(undefined);
+ expect(getThreadSecondaryPanelOpenAtom(null)).toBe(disabled);
+ expect(store.get(disabled)).toBe(false);
+
+ store.set(disabled, true);
+ expect(window.localStorage.length).toBe(0);
+ });
+
+ it("hydrates a thread's persisted open state when its atom initializes", async () => {
+ const { getThreadSecondaryPanelOpenStorageKey: seedKey } = await import(
+ "./threadSecondaryPanelAtoms"
+ );
+ window.localStorage.setItem(seedKey({ threadId: THREAD_A }), "true");
+ vi.resetModules();
+ const { getThreadSecondaryPanelOpenAtom: hydratedGetter } = await import(
+ "./threadSecondaryPanelAtoms"
+ );
+
+ expect(createStore().get(hydratedGetter(THREAD_A))).toBe(true);
+ });
+});
+
describe("getThreadConversationCollapsedAtom", () => {
it("defaults to collapsed=false and persists toggles to a per-thread key", () => {
const store = createStore();
diff --git a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts
index 56125159e..cfbc270bd 100644
--- a/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts
+++ b/apps/app/src/components/secondary-panel/threadSecondaryPanelAtoms.ts
@@ -5,6 +5,24 @@ import { createLocalStorageSyncStorage } from "@/lib/browser-storage";
export const threadSecondaryPanelResizingAtom = atom(false);
+type ResolvedThreadSecondaryPanelThreadId = string;
+type ThreadSecondaryPanelThreadId =
+ | ResolvedThreadSecondaryPanelThreadId
+ | null
+ | undefined;
+
+interface ThreadSecondaryPanelStorageKeyArgs {
+ prefix: string;
+ threadId: ResolvedThreadSecondaryPanelThreadId;
+}
+
+function getThreadSecondaryPanelStorageKey({
+ prefix,
+ threadId,
+}: ThreadSecondaryPanelStorageKeyArgs): string {
+ return `${prefix}-${encodeURIComponent(threadId)}`;
+}
+
/**
* User's preferred secondary panel width as a percentage of the surrounding
* PanelGroup. Persisted across reloads. The default (50) is used when the
@@ -28,6 +46,76 @@ export const secondaryPanelWidthPercentAtom = atomWithStorage(
{ getOnInit: true },
);
+const threadSecondaryPanelBooleanStorage =
+ createLocalStorageSyncStorage({
+ parse: (storedValue, initialValue) => {
+ if (storedValue === "true") return true;
+ if (storedValue === "false") return false;
+ return initialValue;
+ },
+ serialize: (value) => String(value),
+ });
+
+/**
+ * Whether a given thread's secondary panel is open on wide viewports. Stored
+ * separately from the tab list so the user's right-panel layout choice is
+ * restored per thread even as app/fullscreen and conversation-collapse flows
+ * mutate the selected tab. Defaults closed for threads with no stored value,
+ * matching the empty fixed-panel state.
+ */
+const THREAD_SECONDARY_PANEL_OPEN_STORAGE_PREFIX =
+ "bb.thread.secondaryPanel.open";
+
+interface ThreadSecondaryPanelOpenStorageKeyArgs {
+ threadId: ResolvedThreadSecondaryPanelThreadId;
+}
+
+export function getThreadSecondaryPanelOpenStorageKey({
+ threadId,
+}: ThreadSecondaryPanelOpenStorageKeyArgs): string {
+ return getThreadSecondaryPanelStorageKey({
+ prefix: THREAD_SECONDARY_PANEL_OPEN_STORAGE_PREFIX,
+ threadId,
+ });
+}
+
+const threadSecondaryPanelOpenAtomFamily = atomFamily(
+ (threadId: ResolvedThreadSecondaryPanelThreadId) =>
+ atomWithStorage(
+ getThreadSecondaryPanelOpenStorageKey({ threadId }),
+ false,
+ threadSecondaryPanelBooleanStorage,
+ { getOnInit: true },
+ ),
+);
+
+// Fallback for callers without a resolved thread id (e.g. before routing
+// settles). It stays false and any write lands on this throwaway atom, so no
+// real thread's panel-open state is affected.
+const disabledThreadSecondaryPanelOpenAtom = atom(false);
+
+function hasThreadId(
+ threadId: ThreadSecondaryPanelThreadId,
+): threadId is ResolvedThreadSecondaryPanelThreadId {
+ return threadId !== null && threadId !== undefined && threadId.length > 0;
+}
+
+/**
+ * The panel-open atom for a specific thread. `atomFamily` memoizes by threadId,
+ * so repeated calls with the same id return a stable atom reference safe to
+ * pass straight to `useAtom`/`useSetAtom`/`useAtomValue`.
+ */
+export function getThreadSecondaryPanelOpenAtom(
+ threadId: ThreadSecondaryPanelThreadId,
+) {
+ return hasThreadId(threadId)
+ ? threadSecondaryPanelOpenAtomFamily(threadId)
+ : disabledThreadSecondaryPanelOpenAtom;
+}
+
+const THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX =
+ "bb.thread.conversation.collapsed";
+
/**
* Whether a given thread's conversation/timeline pane is collapsed so the
* secondary panel fills the whole content area. Keyed per thread (like the
@@ -37,37 +125,29 @@ export const secondaryPanelWidthPercentAtom = atomWithStorage(
* Persisted per thread; only takes effect while the secondary panel is open on
* a wide viewport — see ThreadDetailSecondaryContent for the gating.
*/
-const THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX =
- "bb.thread.conversation.collapsed";
-
interface ThreadConversationCollapsedStorageKeyArgs {
- threadId: string;
+ threadId: ResolvedThreadSecondaryPanelThreadId;
}
export function getThreadConversationCollapsedStorageKey({
threadId,
}: ThreadConversationCollapsedStorageKeyArgs): string {
- return `${THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX}-${encodeURIComponent(
+ return getThreadSecondaryPanelStorageKey({
+ prefix: THREAD_CONVERSATION_COLLAPSED_STORAGE_PREFIX,
threadId,
- )}`;
+ });
}
-const conversationCollapsedStorage = createLocalStorageSyncStorage({
- parse: (storedValue, initialValue) => {
- if (storedValue === "true") return true;
- if (storedValue === "false") return false;
- return initialValue;
- },
- serialize: (value) => String(value),
-});
+const conversationCollapsedStorage = threadSecondaryPanelBooleanStorage;
-const threadConversationCollapsedAtomFamily = atomFamily((threadId: string) =>
- atomWithStorage(
- getThreadConversationCollapsedStorageKey({ threadId }),
- false,
- conversationCollapsedStorage,
- { getOnInit: true },
- ),
+const threadConversationCollapsedAtomFamily = atomFamily(
+ (threadId: ResolvedThreadSecondaryPanelThreadId) =>
+ atomWithStorage(
+ getThreadConversationCollapsedStorageKey({ threadId }),
+ false,
+ conversationCollapsedStorage,
+ { getOnInit: true },
+ ),
);
// Fallback for callers without a resolved thread id (e.g. before routing
@@ -75,17 +155,13 @@ const threadConversationCollapsedAtomFamily = atomFamily((threadId: string) =>
// real thread's collapse state is affected.
const disabledThreadConversationCollapsedAtom = atom(false);
-function hasThreadId(threadId: string | null | undefined): threadId is string {
- return threadId !== null && threadId !== undefined && threadId.length > 0;
-}
-
/**
* The conversation-collapsed atom for a specific thread. `atomFamily` memoizes
* by threadId, so repeated calls with the same id return a stable atom
* reference safe to pass straight to `useAtom`/`useSetAtom`/`useAtomValue`.
*/
export function getThreadConversationCollapsedAtom(
- threadId: string | null | undefined,
+ threadId: ThreadSecondaryPanelThreadId,
) {
return hasThreadId(threadId)
? threadConversationCollapsedAtomFamily(threadId)
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
index 4e95d2507..2a323e621 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.tsx
@@ -26,7 +26,7 @@ import {
type WorkspaceFilePreviewFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
-import { STATUS_APP_ID, useThreadFileTabs } from "./useThreadFileTabs";
+import { useThreadFileTabs } from "./useThreadFileTabs";
import { useThreadRecentItems } from "./threadRecentItems";
const NOW = 1_700_000_000_000;
@@ -44,7 +44,7 @@ interface TestWrapperProps {
}
interface HookProps {
- apps?: readonly { id: string }[];
+ apps?: readonly { applicationId: string }[];
environmentId: string | null | undefined;
storageFiles: readonly { path: string }[] | undefined;
threadId: string;
@@ -148,7 +148,7 @@ function storageFilePaths(
}
function appTabIds(tabs: readonly SecondaryFileFixedPanelTab[]): string[] {
- return tabs.filter(isAppTab).map((tab) => tab.appId);
+ return tabs.filter(isAppTab).map((tab) => tab.applicationId);
}
function tabIds(tabs: readonly SecondaryFileFixedPanelTab[]): string[] {
@@ -167,8 +167,8 @@ function hostFileTabId(path: string): string {
return `host-file-preview:${encodeURIComponent(path)}`;
}
-function appTabId(appId: string): string {
- return `app:${encodeURIComponent(appId)}`;
+function appTabId(applicationId: string): string {
+ return `app:${encodeURIComponent(applicationId)}`;
}
function newTabId(): string {
@@ -236,7 +236,7 @@ function getStoredStoragePaths(state: FixedPanelTabsState): string[] {
}
function getStoredAppIds(state: FixedPanelTabsState): string[] {
- return state.secondary.tabs.filter(isAppTab).map((tab) => tab.appId);
+ return state.secondary.tabs.filter(isAppTab).map((tab) => tab.applicationId);
}
afterEach(() => {
@@ -434,22 +434,17 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeHostFilePath).toBeNull();
});
- it("orders file tabs by open order with the pinned status app first", async () => {
+ it("orders file tabs by open order", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: STATUS_APP_ID }],
+ apps: [{ applicationId: "review" }],
environmentId: "env-one",
threadType: "manager",
storageFiles: [{ path: "notes.md" }],
threadId: "thr-manager-open-order",
});
- await waitFor(() => {
- expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
- ]);
- });
-
act(() => {
+ result.current.openApp("review");
result.current.openWorkspaceFile(
buildWorkspaceFileTab({ lineNumber: null, path: "src/app.ts" }),
);
@@ -458,7 +453,7 @@ describe("useThreadFileTabs", () => {
});
expect(tabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- appTabId(STATUS_APP_ID),
+ appTabId("review"),
workspaceFileTabId("src/app.ts"),
storageFileTabId("notes.md"),
hostFileTabId("/tmp/host.md"),
@@ -783,35 +778,35 @@ describe("useThreadFileTabs", () => {
expect(result.current.activeStorageFilePath).toBeNull();
});
- it("opens the status app tab for managers and keeps it pinned", async () => {
+ it("closes app tabs", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: STATUS_APP_ID }],
+ apps: [{ applicationId: "review" }],
environmentId: null,
threadType: "manager",
storageFiles: undefined,
- threadId: "thr-manager-status-app",
- });
-
- await waitFor(() => {
- expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
- ]);
+ threadId: "thr-manager-app",
});
- expect(result.current.activeAppId).toBe(STATUS_APP_ID);
act(() => {
- result.current.closeAppTab(STATUS_APP_ID);
+ result.current.openApp("review");
});
expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([
- STATUS_APP_ID,
+ "review",
]);
- expect(result.current.activeAppId).toBe(STATUS_APP_ID);
+ expect(result.current.activeAppId).toBe("review");
+
+ act(() => {
+ result.current.closeAppTab("review");
+ });
+
+ expect(appTabIds(result.current.orderedSecondaryFileTabs)).toEqual([]);
+ expect(result.current.activeAppId).toBeNull();
});
it("opens an app tab from launcher search selection", () => {
const { result } = renderThreadFileTabsHook({
- apps: [{ id: "demo" }],
+ apps: [{ applicationId: "demo" }],
environmentId: "env-one",
threadType: "standard",
storageFiles: undefined,
@@ -822,7 +817,7 @@ describe("useThreadFileTabs", () => {
result.current.openNewTab();
result.current.selectFileSearchResult({
source: "app",
- appId: "demo",
+ applicationId: "demo",
});
});
@@ -1012,9 +1007,9 @@ describe("useThreadFileTabs — browser tabs", () => {
});
expect(result.current.activeBrowserTab?.title).toBe("Example");
- const persisted = readStoredState(
- "thr-browser-update",
- ).secondary.tabs.find((entry) => entry.kind === "browser");
+ const persisted = readStoredState("thr-browser-update").secondary.tabs.find(
+ (entry) => entry.kind === "browser",
+ );
expect(persisted?.kind === "browser" ? persisted.url : null).toBe(
"https://example.com",
);
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
index c41dbe9c8..3d92648d2 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo } from "react";
+import { useSetAtom } from "jotai";
import type { ThreadType } from "@bb/domain";
import {
useFixedPanelTabsState,
@@ -27,16 +28,15 @@ import {
type HostFileTabState,
type WorkspaceFileTabState,
} from "@/lib/file-preview";
+import { getThreadSecondaryPanelOpenAtom } from "./threadSecondaryPanelAtoms";
import { useRecordThreadRecentItem } from "./threadRecentItems";
-export const STATUS_APP_ID = "status";
-
-interface ThreadAppTabDescriptor {
- id: string;
+interface AppTabDescriptor {
+ applicationId: string;
}
interface UseThreadFileTabsParams {
- apps?: readonly ThreadAppTabDescriptor[] | undefined;
+ apps?: readonly AppTabDescriptor[] | undefined;
threadId: string | null | undefined;
environmentId: string | null | undefined;
threadType: ThreadType | undefined;
@@ -67,7 +67,7 @@ export interface FileSearchThreadStorageSelection {
export interface FileSearchAppSelection {
source: "app";
- appId: string;
+ applicationId: string;
}
export type FileSearchSelection =
@@ -200,10 +200,11 @@ function pruneStorageTabs(
function pruneAppTabs(
tabs: readonly FixedPanelTab[],
- knownAppIds: ReadonlySet,
+ knownApplicationIds: ReadonlySet,
): readonly FixedPanelTab[] {
const nextTabs = tabs.filter(
- (tab) => !isAppTab(tab) || knownAppIds.has(tab.appId),
+ (tab) =>
+ !isAppTab(tab) || knownApplicationIds.has(tab.applicationId),
);
return nextTabs.length === tabs.length ? tabs : nextTabs;
}
@@ -222,8 +223,8 @@ function createStorageTab(path: string): ThreadStorageFilePreviewFixedPanelTab {
});
}
-function createAppTab(appId: string): AppFixedPanelTab {
- return createAppFixedPanelTab({ appId });
+function createAppTab(applicationId: string): AppFixedPanelTab {
+ return createAppFixedPanelTab({ applicationId });
}
function findWorkspaceTab(
@@ -264,10 +265,10 @@ function findStorageFileTab(
function findAppTab(
tabs: readonly FixedPanelTab[],
- appId: string,
+ applicationId: string,
): AppFixedPanelTab | null {
for (const tab of tabs) {
- if (isAppTab(tab) && tab.appId === appId) {
+ if (isAppTab(tab) && tab.applicationId === applicationId) {
return tab;
}
}
@@ -340,19 +341,10 @@ interface BuildOrderedSecondaryFileTabsArgs {
isManagerThread: boolean;
}
-function isPinnedAppTab(tab: SecondaryFileFixedPanelTab): boolean {
- return tab.kind === "app" && tab.appId === STATUS_APP_ID;
-}
-
-function isPinnedSecondaryFileTab(tab: SecondaryFileFixedPanelTab): boolean {
- return isPinnedAppTab(tab);
-}
-
/**
* Flattens the secondary panel's tabs into the closable file-tab strip, in the
- * order the user opened them. The pinned manager status app is floated to the
- * front; everything else (workspace, host, storage, new tab) keeps its
- * insertion order regardless of type.
+ * order the user opened them. Workspace, host, storage, app, browser, and new
+ * tabs keep their insertion order regardless of type.
*/
function buildOrderedSecondaryFileTabs({
tabs,
@@ -387,10 +379,7 @@ function buildOrderedSecondaryFileTabs({
break;
}
}
- return [
- ...displayable.filter(isPinnedSecondaryFileTab),
- ...displayable.filter((tab) => !isPinnedSecondaryFileTab(tab)),
- ];
+ return displayable;
}
/**
@@ -402,11 +391,15 @@ function buildOrderedSecondaryFileTabs({
*/
export function useOpenThreadAppTab(
threadId: string | null | undefined,
-): (appId: string) => void {
+): (applicationId: string) => void {
const updateFixedPanelTabsState = useUpdateFixedPanelTabsState(threadId);
+ const setThreadSecondaryPanelOpen = useSetAtom(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
return useCallback(
- (appId: string) => {
- const nextTab = createAppTab(appId);
+ (applicationId: string) => {
+ const nextTab = createAppTab(applicationId);
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab);
if (
@@ -424,7 +417,7 @@ export function useOpenThreadAppTab(
});
});
},
- [updateFixedPanelTabsState],
+ [setThreadSecondaryPanelOpen, updateFixedPanelTabsState],
);
}
@@ -437,14 +430,17 @@ export function useThreadFileTabs({
}: UseThreadFileTabsParams) {
const fixedPanelTabsState = useFixedPanelTabsState(threadId);
const updateFixedPanelTabsState = useUpdateFixedPanelTabsState(threadId);
+ const setThreadSecondaryPanelOpen = useSetAtom(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
const recordRecentItem = useRecordThreadRecentItem(threadId);
const isThreadResolved = threadType !== undefined;
const isManagerThread = threadType === "manager";
const resolvedEnvironmentId = isThreadResolved
? (environmentId ?? null)
: undefined;
- const appIds = useMemo(
- () => (apps ? new Set(apps.map((app) => app.id)) : null),
+ const applicationIds = useMemo(
+ () => (apps ? new Set(apps.map((app) => app.applicationId)) : null),
[apps],
);
@@ -519,9 +515,9 @@ export function useThreadFileTabs({
]);
useEffect(() => {
- if (!isThreadResolved || appIds === null) return;
+ if (!isThreadResolved || applicationIds === null) return;
updateFixedPanelTabsState((state) => {
- const tabs = pruneAppTabs(state.secondary.tabs, appIds);
+ const tabs = pruneAppTabs(state.secondary.tabs, applicationIds);
const activeTabId = isActiveTabStillOpen(
tabs,
state.secondary.activeTabId,
@@ -535,33 +531,12 @@ export function useThreadFileTabs({
tabs,
});
});
- }, [appIds, isThreadResolved, updateFixedPanelTabsState]);
-
- useEffect(() => {
- if (!isManagerThread || appIds === null || !appIds.has(STATUS_APP_ID)) {
- return;
- }
- updateFixedPanelTabsState((state) => {
- const statusAppTab = createAppTab(STATUS_APP_ID);
- const tabs = upsertSecondaryTab(state.secondary.tabs, statusAppTab);
- const activeTabId = isActiveTabStillOpen(
- tabs,
- state.secondary.activeTabId,
- )
- ? state.secondary.activeTabId
- : statusAppTab.id;
- return setSecondaryTabs({
- activeTabId,
- isOpen: state.secondary.isOpen,
- state,
- tabs,
- });
- });
- }, [appIds, isManagerThread, updateFixedPanelTabsState]);
+ }, [applicationIds, isThreadResolved, updateFixedPanelTabsState]);
const openWorkspaceFile = useCallback(
({ lineNumber, path, source, statusLabel }: WorkspaceFileTabState) => {
if (resolvedEnvironmentId === undefined) return;
+ setThreadSecondaryPanelOpen(true);
// Only working-tree opens are recorded as recent: a recent row reopens the
// live file, so diff-only previews (head/merge-base) would reopen to the
// wrong content.
@@ -599,7 +574,12 @@ export function useThreadFileTabs({
});
});
},
- [recordRecentItem, resolvedEnvironmentId, updateFixedPanelTabsState],
+ [
+ recordRecentItem,
+ resolvedEnvironmentId,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const closeWorkspaceFileTab = useCallback(
@@ -626,6 +606,10 @@ export function useThreadFileTabs({
const activateWorkspaceFileTab = useCallback(
(path: string) => {
+ if (findWorkspaceTab(fixedPanelTabsState.secondary.tabs, path) === null) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tab = findWorkspaceTab(state.secondary.tabs, path);
if (!tab) {
@@ -642,12 +626,17 @@ export function useThreadFileTabs({
});
});
},
- [updateFixedPanelTabsState],
+ [
+ fixedPanelTabsState.secondary.tabs,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const openStorageFile = useCallback(
(path: string) => {
if (!isManagerThread) return;
+ setThreadSecondaryPanelOpen(true);
recordRecentItem({ source: "thread-storage", path });
const nextTab = createStorageTab(path);
updateFixedPanelTabsState((state) => {
@@ -667,12 +656,18 @@ export function useThreadFileTabs({
});
});
},
- [isManagerThread, recordRecentItem, updateFixedPanelTabsState],
+ [
+ isManagerThread,
+ recordRecentItem,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const openHostFile = useCallback(
({ lineNumber, path }: HostFileTabState) => {
if (!threadId) return;
+ setThreadSecondaryPanelOpen(true);
const nextTab = createHostFilePreviewFixedPanelTab({
lineNumber,
path,
@@ -696,7 +691,7 @@ export function useThreadFileTabs({
});
});
},
- [threadId, updateFixedPanelTabsState],
+ [setThreadSecondaryPanelOpen, threadId, updateFixedPanelTabsState],
);
const closeHostFileTab = useCallback(
@@ -723,6 +718,10 @@ export function useThreadFileTabs({
const activateHostFileTab = useCallback(
(path: string) => {
+ if (findHostFileTab(fixedPanelTabsState.secondary.tabs, path) === null) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tab = findHostFileTab(state.secondary.tabs, path);
if (!tab) {
@@ -739,16 +738,19 @@ export function useThreadFileTabs({
});
});
},
- [updateFixedPanelTabsState],
+ [
+ fixedPanelTabsState.secondary.tabs,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const openApp = useOpenThreadAppTab(threadId);
const closeAppTab = useCallback(
- (appId: string) => {
- if (appId === STATUS_APP_ID) return;
+ (applicationId: string) => {
updateFixedPanelTabsState((state) => {
- const tab = findAppTab(state.secondary.tabs, appId);
+ const tab = findAppTab(state.secondary.tabs, applicationId);
if (!tab) {
return state;
}
@@ -768,9 +770,15 @@ export function useThreadFileTabs({
);
const activateAppTab = useCallback(
- (appId: string) => {
+ (applicationId: string) => {
+ if (
+ findAppTab(fixedPanelTabsState.secondary.tabs, applicationId) === null
+ ) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
- const tab = findAppTab(state.secondary.tabs, appId);
+ const tab = findAppTab(state.secondary.tabs, applicationId);
if (!tab) {
return state;
}
@@ -785,7 +793,11 @@ export function useThreadFileTabs({
});
});
},
- [updateFixedPanelTabsState],
+ [
+ fixedPanelTabsState.secondary.tabs,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
// Opening a browser tab swaps the transient new-tab in place (like selecting
@@ -795,13 +807,18 @@ export function useThreadFileTabs({
const openBrowserTab = useCallback(
(url?: string) => {
const nextTab = createBrowserFixedPanelTab({ url: url ?? "" });
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
},
- [updateFixedPanelTabsState],
+ [setThreadSecondaryPanelOpen, updateFixedPanelTabsState],
);
const activateBrowserTab = useCallback(
(tabId: string) => {
+ if (findBrowserTab(fixedPanelTabsState.secondary.tabs, tabId) === null) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tab = findBrowserTab(state.secondary.tabs, tabId);
if (!tab) {
@@ -818,7 +835,11 @@ export function useThreadFileTabs({
});
});
},
- [updateFixedPanelTabsState],
+ [
+ fixedPanelTabsState.secondary.tabs,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const closeBrowserTab = useCallback(
@@ -899,6 +920,10 @@ export function useThreadFileTabs({
const activateStorageFileTab = useCallback(
(path: string) => {
if (!isManagerThread) return;
+ if (findStorageFileTab(fixedPanelTabsState.secondary.tabs, path) === null) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tab = findStorageFileTab(state.secondary.tabs, path);
if (!tab) {
@@ -915,11 +940,17 @@ export function useThreadFileTabs({
});
});
},
- [isManagerThread, updateFixedPanelTabsState],
+ [
+ fixedPanelTabsState.secondary.tabs,
+ isManagerThread,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ],
);
const openNewTab = useCallback(() => {
const newTab = createNewTabFixedPanelTab();
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const tabs = upsertSecondaryTab(state.secondary.tabs, newTab);
if (
@@ -936,10 +967,14 @@ export function useThreadFileTabs({
tabs,
});
});
- }, [updateFixedPanelTabsState]);
+ }, [setThreadSecondaryPanelOpen, updateFixedPanelTabsState]);
const activateNewTab = useCallback(() => {
const newTab = createNewTabFixedPanelTab();
+ if (findNewTab(fixedPanelTabsState.secondary.tabs) === null) {
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => {
const existingTab = findNewTab(state.secondary.tabs);
if (!existingTab) {
@@ -955,7 +990,11 @@ export function useThreadFileTabs({
tabs: state.secondary.tabs,
});
});
- }, [updateFixedPanelTabsState]);
+ }, [
+ fixedPanelTabsState.secondary.tabs,
+ setThreadSecondaryPanelOpen,
+ updateFixedPanelTabsState,
+ ]);
const closeNewTab = useCallback(() => {
const newTab = createNewTabFixedPanelTab();
@@ -979,13 +1018,15 @@ export function useThreadFileTabs({
const selectFileSearchResult = useCallback(
(selection: FileSearchSelection) => {
if (selection.source === "app") {
- const nextTab = createAppTab(selection.appId);
+ const nextTab = createAppTab(selection.applicationId);
+ setThreadSecondaryPanelOpen(true);
updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
return;
}
if (selection.source === "workspace") {
if (resolvedEnvironmentId === undefined) return;
+ setThreadSecondaryPanelOpen(true);
recordRecentItem({ source: "workspace", path: selection.path });
const nextTab = createWorkspaceFilePreviewFixedPanelTab({
environmentId: resolvedEnvironmentId,
@@ -1001,6 +1042,7 @@ export function useThreadFileTabs({
}
if (!isManagerThread) return;
+ setThreadSecondaryPanelOpen(true);
recordRecentItem({ source: "thread-storage", path: selection.path });
const nextTab = createStorageTab(selection.path);
updateFixedPanelTabsState((state) => replaceNewTab({ nextTab, state }));
@@ -1009,6 +1051,7 @@ export function useThreadFileTabs({
isManagerThread,
recordRecentItem,
resolvedEnvironmentId,
+ setThreadSecondaryPanelOpen,
updateFixedPanelTabsState,
],
);
@@ -1072,7 +1115,7 @@ export function useThreadFileTabs({
activateHostFileTab,
activateStorageFileTab,
activateWorkspaceFileTab,
- activeAppId: activeAppTab?.appId ?? null,
+ activeAppId: activeAppTab?.applicationId ?? null,
activeBrowserTab,
activeHostFileLineNumber: activeHostFileTab?.lineNumber ?? null,
activeHostFilePath: activeHostFileTab?.path ?? null,
diff --git a/apps/app/src/components/sidebar/ProjectList.test.tsx b/apps/app/src/components/sidebar/ProjectList.test.tsx
index 9b6035e90..de2d5782d 100644
--- a/apps/app/src/components/sidebar/ProjectList.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.test.tsx
@@ -15,7 +15,6 @@ import {
waitFor,
} from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
-import { useAtom } from "jotai";
import type { QueryClient } from "@tanstack/react-query";
import { PERSONAL_PROJECT_ID } from "@bb/domain";
import type {
@@ -29,10 +28,12 @@ import {
resetFakeReconnectingWebSockets,
} from "@/test/fake-reconnecting-websocket";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
-import { installFetchRoutes, jsonResponse } from "@/test/http-test-utils";
+import {
+ installFetchRoutes,
+ jsonResponse,
+ type FetchRoute,
+} from "@/test/http-test-utils";
import { wsManager } from "@/lib/ws";
-import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import { useRootComposeReuseEnvironment } from "@/lib/root-compose-selection";
import { encodeReuseValue } from "@/components/pickers/environment-picker-value";
import {
@@ -72,7 +73,7 @@ type ProjectThreadListEntryOverrides = Partial;
type ProjectWithThreadsOverrides = Partial;
interface MakeAppArgs {
- id: string;
+ applicationId: string;
name: string;
icon: AppSummary["icon"];
}
@@ -137,9 +138,26 @@ function makeThreadListEntry(
};
}
-function makeApp({ id, name, icon }: MakeAppArgs): AppSummary {
+// The sidebar now lists global apps unconditionally, so every render hits
+// `GET /api/v1/apps`. Default it to an empty list when a test doesn't care about
+// apps, so those tests don't have to register the route by hand.
+function installProjectListFetchRoutes(routes: FetchRoute[]) {
+ const hasAppsRoute = routes.some(
+ (route) => route.pathname === "/api/v1/apps",
+ );
+ return installFetchRoutes(
+ hasAppsRoute
+ ? routes
+ : [
+ ...routes,
+ { pathname: "/api/v1/apps", handler: () => jsonResponse([]) },
+ ],
+ );
+}
+
+function makeApp({ applicationId, name, icon }: MakeAppArgs): AppSummary {
return {
- id,
+ applicationId,
name,
entry: { path: "index.html", kind: "html" },
capabilities: [],
@@ -147,20 +165,21 @@ function makeApp({ id, name, icon }: MakeAppArgs): AppSummary {
};
}
-const STATUS_APP = makeApp({
- id: "status",
- name: "Status",
+const REVIEW_BOARD_APP = makeApp({
+ applicationId: "review-board",
+ name: "Review Board",
icon: { kind: "builtin", name: "ListTodo" },
});
-// Manager app rows render after sidebar bootstrap plus a per-manager app query.
-const MANAGER_APP_ROW_TIMEOUT_MS = 5_000;
+// App rows render after the global app query resolves, which trails the sidebar
+// bootstrap, so allow a little extra time when finding one.
+const APP_ROW_TIMEOUT_MS = 5_000;
-function findStatusAppButton() {
+function findReviewBoardAppButton() {
return screen.findByRole(
"button",
- { name: "Open Status app" },
- { timeout: MANAGER_APP_ROW_TIMEOUT_MS },
+ { name: "Open Review Board app" },
+ { timeout: APP_ROW_TIMEOUT_MS },
);
}
@@ -174,10 +193,6 @@ interface RootComposeReuseProbeProps {
onValue: (value: string | null) => void;
}
-interface PanelStateProbeProps {
- threadId: string;
-}
-
function buildProjectListHandler(args: ProjectListHandlerArgs) {
return (request: Request) => {
const url = new URL(request.url);
@@ -247,44 +262,6 @@ function RootComposeReuseProbe({
return null;
}
-function PanelStateProbe({ threadId }: PanelStateProbeProps) {
- const state = useFixedPanelTabsState(threadId);
- return (
-
- {JSON.stringify({
- activeTabId: state.secondary.activeTabId,
- isOpen: state.secondary.isOpen,
- })}
-
- );
-}
-
-interface ConversationCollapseProbeProps {
- threadId: string;
-}
-
-// Mirrors one thread's conversation-collapse preference the thread detail view
-// reads/writes, and exposes a button that stands in for the collapsed rail's
-// expand control so a sidebar-only test can drive the same state. Keyed by
-// threadId so a test can probe several threads independently.
-function ConversationCollapseProbe({
- threadId,
-}: ConversationCollapseProbeProps) {
- const [collapsed, setCollapsed] = useAtom(
- getThreadConversationCollapsedAtom(threadId),
- );
- return (
-
-
- {String(collapsed)}
-
-
setCollapsed(false)}>
- {`expand-conversation:${threadId}`}
-
-
- );
-}
-
async function renderProjectList(
props: ProjectListRenderProps = {},
options: ProjectListRenderOptions = {},
@@ -346,7 +323,7 @@ describe("ProjectList", () => {
projects,
threadsByProjectId,
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => {
@@ -417,7 +394,7 @@ describe("ProjectList", () => {
});
it("does not show project error or empty states before the websocket connects", async () => {
- const fetchMock = installFetchRoutes([
+ const fetchMock = installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -469,7 +446,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -525,7 +502,7 @@ describe("ProjectList", () => {
expect(screen.getByText("Project Thread")).toBeTruthy();
});
- it("renders manager app rows directly under their owning manager", async () => {
+ it("lists global apps once in a top-level Apps section, not nested under managers", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -548,7 +525,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -563,8 +540,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -590,27 +567,34 @@ describe("ProjectList", () => {
await renderProjectList();
- const appRow = await findStatusAppButton();
+ const appRow = await findReviewBoardAppButton();
+ const appsLabel = screen.getByText("Apps");
const managerLabel = screen.getByText("Sidebar Manager");
- const workerLabel = screen.getByText("Worker Thread");
- expect(appRow.classList.contains("pl-14")).toBe(true);
- expect(appRow.parentElement?.className).toContain("before:left-10");
+ // One global app → exactly one app row, regardless of the manager present.
+ expect(
+ screen.getAllByRole("button", { name: "Open Review Board app" }),
+ ).toHaveLength(1);
+ // Top-level indent (pl-2), not the manager-nested indent (pl-14).
+ expect(appRow.classList.contains("pl-2")).toBe(true);
+ expect(appRow.classList.contains("pl-14")).toBe(false);
+ // The row lives in the standalone Apps section, after the manager that is
+ // listed under Projects — it is not a child of the manager group.
expect(
Boolean(
- managerLabel.compareDocumentPosition(appRow) &
+ managerLabel.compareDocumentPosition(appsLabel) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
expect(
Boolean(
- appRow.compareDocumentPosition(workerLabel) &
+ appsLabel.compareDocumentPosition(appRow) &
Node.DOCUMENT_POSITION_FOLLOWING,
),
).toBe(true);
});
- it("opens a manager app row in the owning thread secondary panel", async () => {
+ it("opens a global app on its standalone route", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -627,7 +611,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -640,8 +624,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -665,26 +649,87 @@ describe("ProjectList", () => {
},
]);
- await renderProjectList(
- {},
- { extraUi: },
+ // Apps open on their own route regardless of the thread in view.
+ window.history.pushState(
+ null,
+ "",
+ `/projects/${project.id}/threads/${managerThread.id}`,
);
- fireEvent.click(
- await findStatusAppButton(),
- );
+ await renderProjectList();
+
+ fireEvent.click(await findReviewBoardAppButton());
await waitFor(() => {
- const probe = screen.getByTestId("panel-state");
- expect(probe.textContent).toContain('"activeTabId":"app:status"');
- expect(probe.textContent).toContain('"isOpen":true');
+ expect(window.location.pathname).toBe("/apps/review-board");
+ });
+ });
+
+ it("keeps global app rows enabled and routable when no thread is selected", async () => {
+ const project = makeProjectResponse({
+ id: "project-1",
+ name: "Project One",
+ });
+ const personalProject = makeProjectWithThreadsResponse({
+ id: PERSONAL_PROJECT_ID,
+ kind: "personal",
+ name: "Personal",
+ threads: [],
+ });
+ installProjectListFetchRoutes([
+ {
+ pathname: "/api/v1/sidebar-bootstrap",
+ handler: () =>
+ jsonResponse(
+ buildSidebarNavigationResponse({
+ personalProject,
+ projects: [project],
+ }),
+ ),
+ },
+ {
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
+ },
+ {
+ pathname: "/api/v1/projects",
+ handler: () => jsonResponse([project]),
+ },
+ {
+ pathname: "/api/v1/threads",
+ handler: () => jsonResponse([]),
+ },
+ {
+ pathname: "/api/v1/system/config",
+ handler: () =>
+ jsonResponse({
+ hostDaemonPort: null,
+ voiceTranscriptionEnabled: false,
+ }),
+ },
+ {
+ pathname: "/api/v1/hosts",
+ handler: () => jsonResponse([]),
+ },
+ ]);
+
+ // Root view: no thread selected. The app still opens via its standalone
+ // route, so the row is interactive rather than disabled.
+ window.history.pushState(null, "", "/");
+
+ await renderProjectList();
+
+ const appRow = await findReviewBoardAppButton();
+ expect(appRow).toHaveProperty("disabled", false);
+
+ fireEvent.click(appRow);
+
+ await waitFor(() => {
+ expect(window.location.pathname).toBe("/apps/review-board");
});
- expect(window.location.pathname).toBe(
- `/projects/${project.id}/threads/${managerThread.id}`,
- );
});
- it("hides manager app rows when the owning manager is collapsed", async () => {
+ it("keeps the Apps section visible when a manager is collapsed", async () => {
const project = makeProjectResponse({
id: "project-1",
name: "Project One",
@@ -707,7 +752,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -722,8 +767,8 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
+ pathname: "/api/v1/apps",
+ handler: () => jsonResponse([REVIEW_BOARD_APP]),
},
{
pathname: "/api/v1/projects",
@@ -749,21 +794,21 @@ describe("ProjectList", () => {
await renderProjectList();
- expect(
- await findStatusAppButton(),
- ).toBeTruthy();
+ expect(await findReviewBoardAppButton()).toBeTruthy();
expect(screen.getByText("Worker Thread")).toBeTruthy();
+ // Apps are no longer nested under managers, so collapsing the manager hides
+ // its worker thread but leaves the global Apps section untouched.
fireEvent.click(
screen.getByRole("button", {
name: "Collapse Sidebar Manager threads",
}),
);
- expect(
- screen.queryByRole("button", { name: "Open Status app" }),
- ).toBeNull();
expect(screen.queryByText("Worker Thread")).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Open Review Board app" }),
+ ).toBeTruthy();
});
it("orders project hover actions as menu, new manager, then new thread", async () => {
@@ -780,7 +825,7 @@ describe("ProjectList", () => {
});
const reuseValues: (string | null)[] = [];
const staleReuseValue = encodeReuseValue("env-stale");
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -890,7 +935,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -967,7 +1012,7 @@ describe("ProjectList", () => {
});
it("shows projects unavailable when the project request fails after the websocket connects", async () => {
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -1001,7 +1046,7 @@ describe("ProjectList", () => {
it("shows threads unavailable when the sidebar navigation fails after the websocket connects", async () => {
const project = makeProjectResponse();
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () => new Response("starting", { status: 503 }),
@@ -1051,7 +1096,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1117,7 +1162,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1177,7 +1222,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1245,7 +1290,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1311,7 +1356,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessManager, projectlessChild, projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1323,7 +1368,7 @@ describe("ProjectList", () => {
),
},
{
- pathname: `/api/v1/threads/${projectlessManager.id}/apps`,
+ pathname: "/api/v1/apps",
handler: () => jsonResponse([]),
},
{
@@ -1378,7 +1423,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [projectlessThread],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1432,7 +1477,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1512,7 +1557,7 @@ describe("ProjectList", () => {
name: "Personal",
threads: [],
});
- installFetchRoutes([
+ installProjectListFetchRoutes([
{
pathname: "/api/v1/sidebar-bootstrap",
handler: () =>
@@ -1556,327 +1601,4 @@ describe("ProjectList", () => {
),
).toBe(true);
});
-
- it("collapses the conversation and moves the single selection to the opened app row", async () => {
- const project = makeProjectResponse({
- id: "project-1",
- name: "Project One",
- });
- const managerThread = makeThreadListEntry(project.id, 10, {
- id: "thread-manager-app-select",
- title: "Sidebar Manager",
- titleFallback: "Sidebar Manager",
- type: "manager",
- });
- const personalProject = makeProjectWithThreadsResponse({
- id: PERSONAL_PROJECT_ID,
- kind: "personal",
- name: "Personal",
- threads: [],
- });
- installFetchRoutes([
- {
- pathname: "/api/v1/sidebar-bootstrap",
- handler: () =>
- jsonResponse(
- buildSidebarNavigationResponse({
- personalProject,
- projects: [project],
- threadsByProjectId: new Map([[project.id, [managerThread]]]),
- }),
- ),
- },
- {
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
- },
- {
- pathname: "/api/v1/projects",
- handler: () => jsonResponse([project]),
- },
- {
- pathname: "/api/v1/threads",
- handler: () => jsonResponse([]),
- },
- {
- pathname: "/api/v1/system/config",
- handler: () =>
- jsonResponse({
- hostDaemonPort: null,
- voiceTranscriptionEnabled: false,
- }),
- },
- {
- pathname: "/api/v1/hosts",
- handler: () => jsonResponse([]),
- },
- ]);
-
- // Start on the manager thread route so its row is the selected surface.
- window.history.pushState(
- null,
- "",
- `/projects/${project.id}/threads/${managerThread.id}`,
- );
-
- await renderProjectList(
- {},
- { extraUi: },
- );
-
- const appRow = await findStatusAppButton();
- const findManagerRow = () =>
- screen
- .getByText("Sidebar Manager")
- .closest("[data-sidebar-sticky-tier='manager']");
-
- // Conversation active: the manager row owns the single selected highlight
- // and no app row is highlighted.
- expect(findManagerRow()?.className).toContain("bg-sidebar-border");
- expect(appRow.className).not.toContain("bg-sidebar-border");
- expect(
- screen.getByTestId(`conversation-collapsed:${managerThread.id}`)
- .textContent,
- ).toBe("false");
-
- fireEvent.click(appRow);
-
- // App opened: the conversation collapses and the app row becomes the single
- // selected row; the manager row drops its selected background.
- await waitFor(() => {
- expect(
- screen.getByTestId(`conversation-collapsed:${managerThread.id}`)
- .textContent,
- ).toBe("true");
- });
- expect(
- screen.getByRole("button", { name: "Open Status app" }).className,
- ).toContain("bg-sidebar-border");
- expect(findManagerRow()?.className).not.toContain("bg-sidebar-border");
-
- // Expanding the conversation flips the selection back to the manager row.
- fireEvent.click(
- screen.getByRole("button", {
- name: `expand-conversation:${managerThread.id}`,
- }),
- );
-
- await waitFor(() => {
- expect(findManagerRow()?.className).toContain("bg-sidebar-border");
- });
- expect(
- screen.getByRole("button", { name: "Open Status app" }).className,
- ).not.toContain("bg-sidebar-border");
- });
-
- it("restores the conversation when its thread row is selected while collapsed", async () => {
- const project = makeProjectResponse({
- id: "project-1",
- name: "Project One",
- });
- const managerThread = makeThreadListEntry(project.id, 10, {
- id: "thread-manager-row-restore",
- title: "Sidebar Manager",
- titleFallback: "Sidebar Manager",
- type: "manager",
- });
- const personalProject = makeProjectWithThreadsResponse({
- id: PERSONAL_PROJECT_ID,
- kind: "personal",
- name: "Personal",
- threads: [],
- });
- installFetchRoutes([
- {
- pathname: "/api/v1/sidebar-bootstrap",
- handler: () =>
- jsonResponse(
- buildSidebarNavigationResponse({
- personalProject,
- projects: [project],
- threadsByProjectId: new Map([[project.id, [managerThread]]]),
- }),
- ),
- },
- {
- pathname: `/api/v1/threads/${managerThread.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
- },
- {
- pathname: "/api/v1/projects",
- handler: () => jsonResponse([project]),
- },
- {
- pathname: "/api/v1/threads",
- handler: () => jsonResponse([]),
- },
- {
- pathname: "/api/v1/system/config",
- handler: () =>
- jsonResponse({
- hostDaemonPort: null,
- voiceTranscriptionEnabled: false,
- }),
- },
- {
- pathname: "/api/v1/hosts",
- handler: () => jsonResponse([]),
- },
- ]);
-
- // Start on the manager thread route so its row is the selected surface.
- window.history.pushState(
- null,
- "",
- `/projects/${project.id}/threads/${managerThread.id}`,
- );
-
- await renderProjectList(
- {},
- { extraUi: },
- );
-
- // Opening the app collapses the conversation so the app fills the view.
- fireEvent.click(
- await findStatusAppButton(),
- );
- await waitFor(() => {
- expect(
- screen.getByTestId(`conversation-collapsed:${managerThread.id}`)
- .textContent,
- ).toBe("true");
- });
-
- // Selecting the agent/thread row is the inverse: it restores the
- // conversation by clearing this thread's own collapse flag.
- fireEvent.click(screen.getByRole("link", { name: "Open Sidebar Manager" }));
-
- await waitFor(() => {
- expect(
- screen.getByTestId(`conversation-collapsed:${managerThread.id}`)
- .textContent,
- ).toBe("false");
- });
- });
-
- it("keeps each thread's collapse state isolated when selecting another thread", async () => {
- const project = makeProjectResponse({
- id: "project-1",
- name: "Project One",
- });
- const managerA = makeThreadListEntry(project.id, 10, {
- id: "thread-manager-a",
- title: "Manager A",
- titleFallback: "Manager A",
- type: "manager",
- });
- const managerB = makeThreadListEntry(project.id, 9, {
- id: "thread-manager-b",
- title: "Manager B",
- titleFallback: "Manager B",
- type: "manager",
- });
- const personalProject = makeProjectWithThreadsResponse({
- id: PERSONAL_PROJECT_ID,
- kind: "personal",
- name: "Personal",
- threads: [],
- });
- installFetchRoutes([
- {
- pathname: "/api/v1/sidebar-bootstrap",
- handler: () =>
- jsonResponse(
- buildSidebarNavigationResponse({
- personalProject,
- projects: [project],
- threadsByProjectId: new Map([[project.id, [managerA, managerB]]]),
- }),
- ),
- },
- {
- pathname: `/api/v1/threads/${managerA.id}/apps`,
- handler: () => jsonResponse([STATUS_APP]),
- },
- {
- pathname: `/api/v1/threads/${managerB.id}/apps`,
- handler: () => jsonResponse([]),
- },
- {
- pathname: "/api/v1/projects",
- handler: () => jsonResponse([project]),
- },
- {
- pathname: "/api/v1/threads",
- handler: () => jsonResponse([]),
- },
- {
- pathname: "/api/v1/system/config",
- handler: () =>
- jsonResponse({
- hostDaemonPort: null,
- voiceTranscriptionEnabled: false,
- }),
- },
- {
- pathname: "/api/v1/hosts",
- handler: () => jsonResponse([]),
- },
- ]);
-
- // Start on Manager A so opening its app targets A's thread.
- window.history.pushState(
- null,
- "",
- `/projects/${project.id}/threads/${managerA.id}`,
- );
-
- await renderProjectList(
- {},
- {
- extraUi: (
- <>
-
-
- >
- ),
- },
- );
-
- // Collapse A's conversation by opening its app full-screen.
- fireEvent.click(
- await findStatusAppButton(),
- );
- await waitFor(() => {
- expect(
- screen.getByTestId(`conversation-collapsed:${managerA.id}`).textContent,
- ).toBe("true");
- });
- // B was never collapsed.
- expect(
- screen.getByTestId(`conversation-collapsed:${managerB.id}`).textContent,
- ).toBe("false");
-
- // Selecting B's row restores only B and leaves A collapsed (full-screen app).
- fireEvent.click(screen.getByRole("link", { name: "Open Manager B" }));
- await waitFor(() => {
- expect(window.location.pathname).toBe(
- `/projects/${project.id}/threads/${managerB.id}`,
- );
- });
- expect(
- screen.getByTestId(`conversation-collapsed:${managerB.id}`).textContent,
- ).toBe("false");
- expect(
- screen.getByTestId(`conversation-collapsed:${managerA.id}`).textContent,
- ).toBe("true");
-
- // Reselecting A restores only A.
- fireEvent.click(screen.getByRole("link", { name: "Open Manager A" }));
- await waitFor(() => {
- expect(
- screen.getByTestId(`conversation-collapsed:${managerA.id}`).textContent,
- ).toBe("false");
- });
- });
});
diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx
index bbd99ffb4..5a6bbb0f2 100644
--- a/apps/app/src/components/sidebar/ProjectList.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.tsx
@@ -33,13 +33,14 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import type { ProjectResponse } from "@bb/server-contract";
+import type { AppSummary, ProjectResponse } from "@bb/server-contract";
import {
findLocalPathProjectSourceForHost,
PERSONAL_PROJECT_ID,
type ThreadListEntry,
} from "@bb/domain";
import { useAppRoute } from "@/hooks/useAppRoute";
+import { useApps } from "@/hooks/queries/thread-queries";
import {
useConnectionAwareQueryState,
type ConnectionAwareQueryStatus,
@@ -86,6 +87,7 @@ import {
COARSE_POINTER_ROW_HEIGHT_CLASS,
} from "@/components/ui/coarse-pointer-sizing.js";
import { ProjectRow, ProjectThreadTree } from "./ProjectRow";
+import { SidebarAppsSection } from "./SidebarAppsSection";
import type {
ProjectRowDragBindings,
ProjectRowProps,
@@ -244,7 +246,12 @@ function hasSameSidebarSectionOrder(
}
function isSidebarSectionId(value: string): value is SidebarSectionId {
- return value === "pinned" || value === "projects" || value === "threads";
+ return (
+ value === "pinned" ||
+ value === "projects" ||
+ value === "threads" ||
+ value === "apps"
+ );
}
function normalizeSidebarSectionOrder(
@@ -276,6 +283,8 @@ const EMPTY_PROJECT_THREAD_LIST_STATE: ProjectThreadListState = {
status: "loading",
};
+const EMPTY_APPS: readonly AppSummary[] = [];
+
function getProjectThreadListState({
status,
threads,
@@ -618,6 +627,8 @@ function ProjectListComponent({
const setRootComposeMode = useSetRootComposeMode();
const sidebarNavigationQuery = useSidebarNavigation();
const sidebarNavigation = sidebarNavigationQuery.data;
+ const appsQuery = useApps();
+ const apps = appsQuery.data ?? EMPTY_APPS;
const projects = useMemo(
() => sidebarNavigation?.projects.map(stripProjectThreads),
[sidebarNavigation],
@@ -894,12 +905,17 @@ function ProjectListComponent({
[threads],
);
const hasPinnedSection = pinnedSidebarState.rootItems.length > 0;
+ // No apps → no section: the empty Apps list adds nothing, so it stays hidden
+ // (like the Pinned section) until at least one global app exists.
+ const hasAppsSection = apps.length > 0;
const visibleSidebarSectionOrder = useMemo(
() =>
- sidebarSectionOrder.filter(
- (sectionId) => sectionId !== "pinned" || hasPinnedSection,
- ),
- [hasPinnedSection, sidebarSectionOrder],
+ sidebarSectionOrder.filter((sectionId) => {
+ if (sectionId === "pinned") return hasPinnedSection;
+ if (sectionId === "apps") return hasAppsSection;
+ return true;
+ }),
+ [hasAppsSection, hasPinnedSection, sidebarSectionOrder],
);
const threadsByProject = useMemo(() => {
const grouped = new Map();
@@ -1159,6 +1175,7 @@ function ProjectListComponent({
onReorderManager={handleReorderManager}
/>
);
+ const appsSectionContent = ;
const projectsSectionActions = onNewProject ? (
{projectsSectionContent}
- ) : (
+ ) : sectionId === "threads" ? (
{threadsSectionContent}
+ ) : (
+
+ {appsSectionContent}
+
),
)}
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx
index d51e51ed7..789c86e18 100644
--- a/apps/app/src/components/sidebar/ProjectRow.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.tsx
@@ -9,7 +9,6 @@ import {
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
-import { useAtomValue } from "jotai";
import {
closestCenter,
DndContext,
@@ -31,11 +30,10 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ThreadListEntry } from "@bb/domain";
-import type { AppSummary, ProjectResponse } from "@bb/server-contract";
+import type { ProjectResponse } from "@bb/server-contract";
import { NavLink, useNavigate } from "react-router-dom";
import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree";
import { useArchiveEnvironmentThreads } from "@/hooks/mutations/environment-mutations";
-import { useThreadApps } from "@/hooks/queries/thread-queries";
import { Button } from "@/components/ui/button.js";
import {
DropdownMenu,
@@ -75,9 +73,6 @@ import {
import { cn } from "@/lib/utils";
import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display";
import { getProjectSettingsRoutePath } from "@/lib/app-route-paths";
-import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
-import type { FixedPanelTabsState } from "@/lib/fixed-panel-tabs-state";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import {
applyNeighborReorder,
buildNeighborReorderRequest,
@@ -90,7 +85,6 @@ import {
type ThreadRowDragBindings,
type ThreadRowOptions,
} from "./ThreadRow";
-import { ThreadAppRow } from "./ThreadAppRow";
import {
buildProjectThreadGroups,
type EnvironmentThreadGroup,
@@ -109,7 +103,6 @@ import {
SIDEBAR_SECTION_GROUP_LINE_CLASS,
SIDEBAR_SECTION_LINE_CONTINUATION_CLASS,
SIDEBAR_STANDARD_ROW_PADDING_CLASS,
- type SidebarThreadRowIndent,
} from "./sidebarRowClasses";
import { SIDEBAR_SORTABLE_TRANSITION } from "./sortableMotion";
import {
@@ -230,7 +223,6 @@ type ProjectItemClickCaptureHandler = MouseEventHandler;
type ProjectThreadListClickCaptureHandler = MouseEventHandler;
const EMPTY_PROJECT_THREADS: ThreadListEntry[] = [];
-const EMPTY_THREAD_APPS: readonly AppSummary[] = [];
const PROJECT_ROW_LEADING_SLOT_CLASS =
"h-7 w-8 max-md:pointer-coarse:h-10 max-md:pointer-coarse:w-10";
@@ -244,13 +236,6 @@ interface ManagerThreadOrderEntry {
id: string;
}
-interface ActiveThreadAppIdArgs {
- fixedPanelTabsState: FixedPanelTabsState;
- isConversationCollapsed: boolean;
- selectedThreadId?: string;
- threadId: string;
-}
-
function getManagerThreadGroupId(
managerThreadGroup: ManagerThreadGroup,
): string {
@@ -334,12 +319,6 @@ function getProjectThreadTreeManagedChildOptions(
: THREAD_ROW_PROJECT_MANAGED_CHILD_OPTIONS;
}
-function getProjectThreadTreeManagedAppIndent(
- variant: ProjectThreadTreeVariant,
-): SidebarThreadRowIndent {
- return variant === "section" ? "project-child" : "nested-child";
-}
-
function getProjectThreadTreeEnvGroupedChildOptions(
variant: ProjectThreadTreeVariant,
): ThreadRowOptions {
@@ -388,29 +367,6 @@ function getProjectThreadTreeManagerLineContinuationClassName(
: SIDEBAR_MANAGER_LINE_CONTINUATION_CLASS;
}
-function getActiveThreadAppId({
- fixedPanelTabsState,
- isConversationCollapsed,
- selectedThreadId,
- threadId,
-}: ActiveThreadAppIdArgs): string | null {
- // An app is the active surface only for the open thread, and only while the
- // conversation is tucked into the collapsed rail with the panel showing the
- // app full. With the conversation visible it stays the selected surface, so
- // no app row is highlighted and the manager row reads as selected instead.
- if (selectedThreadId !== threadId || !isConversationCollapsed) {
- return null;
- }
-
- const { activeTabId, isOpen, tabs } = fixedPanelTabsState.secondary;
- if (!isOpen || activeTabId === null) {
- return null;
- }
-
- const activeTab = tabs.find((tab) => tab.id === activeTabId);
- return activeTab?.kind === "app" ? activeTab.appId : null;
-}
-
function ProjectThreadTreeGroup({
children,
variant,
@@ -894,20 +850,7 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
sortableStyle,
}: ManagerThreadGroupRowProps) {
const { managerThread, managedItems, stats } = managerThreadGroup;
- const managerAppsQuery = useThreadApps(managerThread.id);
- const managerApps = managerAppsQuery.data ?? EMPTY_THREAD_APPS;
- const fixedPanelTabsState = useFixedPanelTabsState(managerThread.id);
- const isConversationCollapsed = useAtomValue(
- getThreadConversationCollapsedAtom(managerThread.id),
- );
- const activeAppId = getActiveThreadAppId({
- fixedPanelTabsState,
- isConversationCollapsed,
- selectedThreadId,
- threadId: managerThread.id,
- });
- const nestedChildCount = stats.managedChildCount + managerApps.length;
- const appIndent = getProjectThreadTreeManagedAppIndent(variant);
+ const nestedChildCount = stats.managedChildCount;
const managerOptions = useMemo(
() => ({
kind: "manager",
@@ -939,10 +882,10 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
@@ -953,16 +896,6 @@ export const ManagerThreadGroupRow = memo(function ManagerThreadGroupRow({
getProjectThreadTreeChildGroupLineClassName(variant),
)}
>
- {managerApps.map((app) => (
-
- ))}
{managedItems.map((item) =>
item.kind === "thread" ? (
{location.pathname};
+}
+
+function renderSection(initialEntry: string) {
+ return render(
+
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("SidebarAppsSection", () => {
+ it("navigates to the standalone app route and stays enabled without a thread", () => {
+ // Root compose screen — no thread/project selected.
+ renderSection("/");
+
+ const row = screen.getByRole("button", { name: "Open Alpha app" });
+ // The row is no longer gated on a selected thread.
+ expect((row as HTMLButtonElement).disabled).toBe(false);
+
+ fireEvent.click(row);
+
+ expect(screen.getByTestId("location").textContent).toBe("/apps/alpha");
+ });
+
+ it("marks the row for the active app route as current", () => {
+ renderSection("/apps/beta");
+
+ const activeRow = screen.getByRole("button", { name: "Open Beta app" });
+ const inactiveRow = screen.getByRole("button", { name: "Open Alpha app" });
+
+ expect(activeRow.getAttribute("aria-current")).toBe("page");
+ expect(inactiveRow.getAttribute("aria-current")).toBeNull();
+ });
+});
diff --git a/apps/app/src/components/sidebar/ThreadAppRow.tsx b/apps/app/src/components/sidebar/SidebarAppsSection.tsx
similarity index 50%
rename from apps/app/src/components/sidebar/ThreadAppRow.tsx
rename to apps/app/src/components/sidebar/SidebarAppsSection.tsx
index 4cb9ed985..4121b987c 100644
--- a/apps/app/src/components/sidebar/ThreadAppRow.tsx
+++ b/apps/app/src/components/sidebar/SidebarAppsSection.tsx
@@ -1,71 +1,54 @@
import { memo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
-import { useSetAtom } from "jotai";
import type { AppSummary } from "@bb/server-contract";
import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon";
-import { useOpenThreadAppTab } from "@/components/secondary-panel/useThreadFileTabs";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import {
COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS,
COARSE_POINTER_GLYPH_BOX_CLASS,
COARSE_POINTER_ICON_SIZE_CLASS,
} from "@/components/ui/coarse-pointer-sizing.js";
-import { getThreadRoutePath } from "@/lib/app-route-paths";
+import { useAppRoute } from "@/hooks/useAppRoute";
+import { getStandaloneAppRoutePath } from "@/lib/app-route-paths";
import { cn } from "@/lib/utils";
import {
SIDEBAR_ROW_BASE_CLASS,
SIDEBAR_ROW_GLYPH_SLOT_CLASS,
SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
- getSidebarThreadRowPaddingClass,
- type SidebarThreadRowIndent,
+ SIDEBAR_STANDARD_ROW_PADDING_CLASS,
} from "./sidebarRowClasses";
-interface ThreadAppRowProps {
+interface SidebarAppsSectionProps {
+ apps: readonly AppSummary[];
+}
+
+interface SidebarAppRowProps {
app: AppSummary;
- indent: SidebarThreadRowIndent;
isActive: boolean;
- projectId: string;
- threadId: string;
}
-function ThreadAppRowComponent({
+const SidebarAppRow = memo(function SidebarAppRow({
app,
- indent,
isActive,
- projectId,
- threadId,
-}: ThreadAppRowProps) {
+}: SidebarAppRowProps) {
const navigate = useNavigate();
- const openThreadAppTab = useOpenThreadAppTab(threadId);
- const setConversationCollapsed = useSetAtom(
- getThreadConversationCollapsedAtom(threadId),
- );
+ // Global apps open on their own thread-independent route, so the row simply
+ // navigates there — no thread required, and the active highlight follows the
+ // current `/apps/:applicationId` route.
const openApp = useCallback(() => {
- // Opening an app makes it the active surface: reveal its tab in the
- // secondary panel (openThreadAppTab also opens the panel) and tuck the
- // conversation into the collapsed rail so the app fills the view.
- openThreadAppTab(app.id);
- setConversationCollapsed(true);
- navigate(getThreadRoutePath({ projectId, threadId }));
- }, [
- app.id,
- navigate,
- openThreadAppTab,
- projectId,
- setConversationCollapsed,
- threadId,
- ]);
+ navigate(getStandaloneAppRoutePath(app.applicationId));
+ }, [app.applicationId, navigate]);
return (
{app.name}
);
-}
+});
-export const ThreadAppRow = memo(ThreadAppRowComponent);
+/**
+ * Top-level sidebar list of the global apps. Apps are not nested under any
+ * project or manager: there is one canonical list, sourced from `useApps()` by
+ * the caller. Opening an app routes to its standalone surface
+ * (`/apps/:applicationId`), which is thread-independent, so rows stay active
+ * even with no thread selected. The highlight follows the current app route.
+ */
+export const SidebarAppsSection = memo(function SidebarAppsSection({
+ apps,
+}: SidebarAppsSectionProps) {
+ const { applicationId: activeApplicationId } = useAppRoute();
+
+ return (
+
+ {apps.map((app) => (
+
+ ))}
+
+ );
+});
diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx
index e11ccdbb9..7ee6d9b6e 100644
--- a/apps/app/src/components/sidebar/ThreadRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.tsx
@@ -1,11 +1,16 @@
import { memo, useCallback, useState, type MouseEventHandler } from "react";
-import { useSetAtom } from "jotai";
+import { useAtomValue, useSetAtom } from "jotai";
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import type { ThreadListEntry } from "@bb/domain";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
+import {
+ getThreadConversationCollapsedAtom,
+ getThreadSecondaryPanelOpenAtom,
+} from "@/components/secondary-panel/threadSecondaryPanelAtoms";
+import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs";
+import { getActiveSecondaryAppId } from "@/lib/fixed-panel-tabs-state";
import { Icon, type IconName } from "@/components/ui/icon.js";
import { SidebarStickyTier } from "@/components/ui/sidebar.js";
import { NavLink } from "react-router-dom";
@@ -300,6 +305,24 @@ function ThreadRowComponent({
const setConversationCollapsed = useSetAtom(
getThreadConversationCollapsedAtom(thread.id),
);
+ // When this thread tucks its conversation into the collapsed rail to show an
+ // app full-screen, the app's sidebar row owns the single selected highlight,
+ // so this row drops its own selected background even though it is the route's
+ // selected thread. Keeps exactly one row highlighted across the sidebar.
+ const isConversationCollapsed = useAtomValue(
+ getThreadConversationCollapsedAtom(thread.id),
+ );
+ const isSecondaryPanelOpen = useAtomValue(
+ getThreadSecondaryPanelOpenAtom(thread.id),
+ );
+ const fixedPanelTabsState = useFixedPanelTabsState(thread.id);
+ const appOwnsSurface =
+ isConversationCollapsed &&
+ getActiveSecondaryAppId({
+ isSecondaryPanelOpen,
+ state: fixedPanelTabsState,
+ }) !== null;
+ const showActive = isActive && !appOwnsSurface;
const hasPendingInteraction = thread.hasPendingInteraction;
const threadIsBusy = isBusyThread(thread) && !hasPendingInteraction;
const showUnreadBadge = !hasPendingInteraction && isUnreadDoneThread(thread);
@@ -353,7 +376,7 @@ function ThreadRowComponent({
? COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS
: COARSE_POINTER_ROW_HEIGHT_CLASS,
getSidebarThreadRowPaddingClass(options.indent),
- isActive
+ showActive
? "bg-sidebar-border text-sidebar-foreground"
: SIDEBAR_ROW_INTERACTIVE_STATE_CLASS,
);
@@ -377,8 +400,8 @@ function ThreadRowComponent({
onClick={() => {
// Selecting a thread/agent row restores its conversation: the inverse
// of opening an app row, which tucks the conversation into the
- // collapsed rail so the app fills the view (see ThreadAppRow). Both
- // write this thread's own collapse flag, so selecting one thread
+ // collapsed rail so the app fills the view (see SidebarAppsSection).
+ // Both write this thread's own collapse flag, so selecting one thread
// never disturbs another's full-screen-app state.
setConversationCollapsed(false);
onProjectSelect?.();
diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
index 4e3b90ca9..df0bec3ad 100644
--- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
+++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts
@@ -6,12 +6,13 @@ const COLLAPSED_MANAGERS_STORAGE_KEY = "bb.sidebar.collapsedManagers";
const COLLAPSED_ENVIRONMENTS_STORAGE_KEY = "bb.sidebar.collapsedEnvironments";
const SIDEBAR_SECTION_ORDER_STORAGE_KEY = "bb.sidebar.sectionOrder";
-export type SidebarSectionId = "pinned" | "projects" | "threads";
+export type SidebarSectionId = "pinned" | "projects" | "threads" | "apps";
export const DEFAULT_SIDEBAR_SECTION_ORDER: readonly SidebarSectionId[] = [
"pinned",
"projects",
"threads",
+ "apps",
];
export const collapsedProjectIdsAtom = atomWithStorage(
diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx
index e4d0b71c6..ba731cf3f 100644
--- a/apps/app/src/components/thread/ThreadActionsProvider.tsx
+++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx
@@ -37,8 +37,10 @@ import {
ThreadDeleteDialog,
type ThreadDeleteDialogTarget,
} from "@/components/dialogs/ThreadDeleteDialog";
+import { destroyPersistedBrowserViewsForThread } from "@/components/secondary-panel/browserViewVisibilityCoordinator";
import { getThreadReadToggleAction } from "@/components/sidebar/threadReadState";
import { getRootComposeRoutePath } from "@/lib/app-route-paths";
+import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
export interface ThreadActionsContextValue {
@@ -232,6 +234,10 @@ export function ThreadActionsProvider({
{ id: thread.id, managerChildThreadsConfirmed },
{
onSuccess: () => {
+ destroyPersistedBrowserViewsForThread({
+ desktopBrowser: getDesktopBrowserApi(),
+ threadId: thread.id,
+ });
closeDialog();
navigateAwayIfViewing(thread);
},
diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
index 5086bf6e7..11039ecdb 100644
--- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
+++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
@@ -130,6 +130,9 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
],
"hooks/cache-owners/realtime-cache-registry.ts": [
"allHostQueryKeyPrefix",
+ "allAppMarkdownPreviewQueryKeyPrefix",
+ "allAppQueryKeyPrefix",
+ "allAppsQueryKeyPrefix",
"allSystemExecutionOptionsQueryKeyPrefix",
"allThreadQueryKeyPrefix",
"allThreadTerminalsQueryKeyPrefix",
@@ -139,9 +142,6 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"hostsQueryKey",
"sidebarNavigationQueryKey",
"systemProvidersQueryKey",
- "threadAppMarkdownPreviewQueryKeyPrefix",
- "threadAppQueryKeyPrefix",
- "threadAppsQueryKey",
"threadListQueryKey",
"threadQueryKey",
"threadStorageFilePreviewQueryKeyPrefix",
@@ -157,11 +157,11 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"allEnvironmentQueryKeyPrefix",
"allEnvironmentWorkStatusQueryKeyPrefix",
"allHostQueryKeyPrefix",
+ "allAppMarkdownPreviewQueryKeyPrefix",
+ "allAppQueryKeyPrefix",
+ "allAppsQueryKeyPrefix",
"allProjectPathsQueryKeyPrefix",
"allSystemExecutionOptionsQueryKeyPrefix",
- "allThreadAppMarkdownPreviewQueryKeyPrefix",
- "allThreadAppQueryKeyPrefix",
- "allThreadAppsQueryKeyPrefix",
"allThreadDefaultExecutionOptionsQueryKeyPrefix",
"allThreadPendingInteractionsQueryKeyPrefix",
"allThreadQueryKeyPrefix",
diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
index 6fa2ab45e..539986c36 100644
--- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
@@ -22,6 +22,9 @@ import {
} from "./thread-list-cache-data";
import {
allHostQueryKeyPrefix,
+ allAppMarkdownPreviewQueryKeyPrefix,
+ allAppQueryKeyPrefix,
+ allAppsQueryKeyPrefix,
allSystemExecutionOptionsQueryKeyPrefix,
allThreadQueryKeyPrefix,
allThreadTerminalsQueryKeyPrefix,
@@ -31,9 +34,6 @@ import {
hostsQueryKey,
sidebarNavigationQueryKey,
systemProvidersQueryKey,
- threadAppMarkdownPreviewQueryKeyPrefix,
- threadAppQueryKeyPrefix,
- threadAppsQueryKey,
threadListQueryKey,
threadQueryKey,
threadTerminalsQueryKey,
@@ -273,6 +273,9 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = {
"config-changed": {
dirty: [dirtySystemProviderQueries, dirtySystemExecutionOptionQueries],
},
+ "apps-changed": {
+ dirty: [dirtyAppListQueries],
+ },
} satisfies SystemChangeRegistry;
export type ThreadChangeFlushPriority = "debounced" | "immediate";
@@ -577,9 +580,6 @@ function dirtyThreadStorageQueriesForEnvironment({
queryKeys.push(threadStorageFilesForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStoragePathsForThreadQueryKeyPrefix(threadId));
queryKeys.push(threadStorageFilePreviewQueryKeyPrefix(threadId));
- queryKeys.push(threadAppsQueryKey(threadId));
- queryKeys.push(threadAppQueryKeyPrefix(threadId));
- queryKeys.push(threadAppMarkdownPreviewQueryKeyPrefix(threadId));
}
return queryKeys;
}
@@ -605,3 +605,11 @@ function dirtySystemProviderQueries(): QueryKey[] {
function dirtySystemExecutionOptionQueries(): QueryKey[] {
return [allSystemExecutionOptionsQueryKeyPrefix()];
}
+
+function dirtyAppListQueries(): QueryKey[] {
+ return [
+ allAppsQueryKeyPrefix(),
+ allAppQueryKeyPrefix(),
+ allAppMarkdownPreviewQueryKeyPrefix(),
+ ];
+}
diff --git a/apps/app/src/hooks/cache-owners/resource-route-owner.test.tsx b/apps/app/src/hooks/cache-owners/resource-route-owner.test.tsx
index f3a3c5d7d..69683f438 100644
--- a/apps/app/src/hooks/cache-owners/resource-route-owner.test.tsx
+++ b/apps/app/src/hooks/cache-owners/resource-route-owner.test.tsx
@@ -6,6 +6,16 @@ import { QueryClientProvider } from "@tanstack/react-query";
import type { ChangedMessage, ThreadWithRuntime } from "@bb/domain";
import { afterEach, describe, expect, it } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
+import type {
+ BbDesktopApi,
+ BbDesktopBrowserApi,
+ BbDesktopInfo,
+ BbDesktopInfoChangeHandler,
+} from "@bb/server-contract";
+import {
+ registerBrowserView,
+ resetBrowserViewPersistence,
+} from "@/components/secondary-panel/browserViewVisibilityCoordinator";
import { collapsedProjectIdsAtom } from "@/components/sidebar/sidebarCollapsedAtoms";
import { createAppQueryClient } from "@/lib/query-client";
import {
@@ -13,6 +23,7 @@ import {
getThreadRoutePath,
} from "@/lib/app-route-paths";
import { useRootComposeProjectId } from "@/lib/root-compose-selection";
+import { createNoopDesktopBrowserApi } from "@/test/bb-desktop-test-utils";
import { threadQueryKey } from "../queries/query-keys";
import {
useDeletedResourceRouteOwner,
@@ -29,6 +40,22 @@ interface RenderRouteOwnerResult {
queryClient: ReturnType;
}
+interface RecordedBrowserCall {
+ method: "detach" | "setVisible";
+ tabId: string;
+ visible: boolean | null;
+}
+
+const DESKTOP_INFO: BbDesktopInfo = {
+ lastCheckedAt: null,
+ latestVersion: null,
+ pendingVersion: null,
+ platform: "macos",
+ updateAvailable: false,
+ updateDownloaded: false,
+ version: "0.0.1",
+};
+
function makeThread(
overrides: Partial = {},
): ThreadWithRuntime {
@@ -59,6 +86,42 @@ function makeThread(
};
}
+function installRecordingDesktopBrowser(): RecordedBrowserCall[] {
+ const calls: RecordedBrowserCall[] = [];
+ const browser: BbDesktopBrowserApi = {
+ ...createNoopDesktopBrowserApi(),
+ detach(tabId) {
+ calls.push({ method: "detach", tabId, visible: null });
+ },
+ setVisible(request) {
+ calls.push({
+ method: "setVisible",
+ tabId: request.tabId,
+ visible: request.visible,
+ });
+ },
+ };
+ const desktop: BbDesktopApi = {
+ ...DESKTOP_INFO,
+ browser,
+ async checkForUpdates() {
+ return DESKTOP_INFO;
+ },
+ async getInfo() {
+ return DESKTOP_INFO;
+ },
+ async installUpdate() {
+ return undefined;
+ },
+ onChange(_listener: BbDesktopInfoChangeHandler) {
+ return () => undefined;
+ },
+ setTheme() {},
+ };
+ window.bbDesktop = desktop;
+ return calls;
+}
+
function RouteProbe() {
const location = useLocation();
const [rootComposeProjectId] = useRootComposeProjectId();
@@ -117,6 +180,8 @@ function renderRouteOwner({
afterEach(() => {
cleanup();
+ delete window.bbDesktop;
+ resetBrowserViewPersistence();
});
describe("useDeletedResourceRouteOwner", () => {
@@ -231,4 +296,48 @@ describe("useDeletedResourceRouteOwner", () => {
);
expect(jotaiStore.get(collapsedProjectIdsAtom)).toEqual([activeProjectId]);
});
+
+ it("releases retained browser views when an environment is deleted remotely", () => {
+ const calls = installRecordingDesktopBrowser();
+ registerBrowserView({
+ environmentId: "env_deleted",
+ tabId: "browser:deleted-environment",
+ threadId: "thr_deleted_environment",
+ });
+ registerBrowserView({
+ environmentId: "env_other",
+ tabId: "browser:other-environment",
+ threadId: "thr_other_environment",
+ });
+ const activeProjectRoute = getLegacyProjectComposeRoutePath("proj_1");
+ const { handleChanged } = renderRouteOwner({
+ initialEntry: activeProjectRoute,
+ });
+ const message: ChangedMessage = {
+ type: "changed",
+ entity: "environment",
+ id: "env_deleted",
+ changes: ["environment-deleted"],
+ };
+
+ act(() => {
+ handleChanged()(message);
+ });
+
+ expect(calls).toEqual([
+ {
+ method: "setVisible",
+ tabId: "browser:deleted-environment",
+ visible: false,
+ },
+ {
+ method: "detach",
+ tabId: "browser:deleted-environment",
+ visible: null,
+ },
+ ]);
+ expect(screen.getByTestId("location").textContent).toBe(
+ activeProjectRoute,
+ );
+ });
});
diff --git a/apps/app/src/hooks/cache-owners/resource-route-owner.ts b/apps/app/src/hooks/cache-owners/resource-route-owner.ts
index 8cb583c31..bedb32713 100644
--- a/apps/app/src/hooks/cache-owners/resource-route-owner.ts
+++ b/apps/app/src/hooks/cache-owners/resource-route-owner.ts
@@ -4,11 +4,17 @@ import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import type {
ChangedMessage,
+ EnvironmentChangedMessage,
ProjectChangedMessage,
ThreadChangedMessage,
} from "@bb/domain";
+import {
+ destroyPersistedBrowserViewsForEnvironment,
+ destroyPersistedBrowserViewsForThread,
+} from "@/components/secondary-panel/browserViewVisibilityCoordinator";
import { collapsedProjectIdsAtom } from "@/components/sidebar/sidebarCollapsedAtoms";
import { getRootComposeRoutePath } from "@/lib/app-route-paths";
+import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { useAppRoute } from "../useAppRoute";
import { getCachedThreadProjectId } from "./thread-detail-cache-owner";
@@ -37,6 +43,16 @@ function isDeletedThreadMessage(
);
}
+function isDeletedEnvironmentMessage(
+ message: ChangedMessage,
+): message is EnvironmentChangedMessage & { id: string } {
+ return (
+ message.entity === "environment" &&
+ message.id !== undefined &&
+ message.changes.includes("environment-deleted")
+ );
+}
+
export function useDeletedResourceRouteOwner(): DeletedResourceRouteChangeHandler {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -58,9 +74,19 @@ export function useDeletedResourceRouteOwner(): DeletedResourceRouteChangeHandle
}
if (!isDeletedThreadMessage(message)) {
+ if (isDeletedEnvironmentMessage(message)) {
+ destroyPersistedBrowserViewsForEnvironment({
+ desktopBrowser: getDesktopBrowserApi(),
+ environmentId: message.id,
+ });
+ }
return;
}
const deletedThreadId = message.id;
+ destroyPersistedBrowserViewsForThread({
+ desktopBrowser: getDesktopBrowserApi(),
+ threadId: deletedThreadId,
+ });
if (routeThreadId !== deletedThreadId) {
return;
}
diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts
index 63b253360..08656253a 100644
--- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts
+++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts
@@ -10,9 +10,9 @@ import {
allProjectPathsQueryKeyPrefix,
allSystemExecutionOptionsQueryKeyPrefix,
allThreadDefaultExecutionOptionsQueryKeyPrefix,
- allThreadAppMarkdownPreviewQueryKeyPrefix,
- allThreadAppQueryKeyPrefix,
- allThreadAppsQueryKeyPrefix,
+ allAppMarkdownPreviewQueryKeyPrefix,
+ allAppQueryKeyPrefix,
+ allAppsQueryKeyPrefix,
allThreadQueuedMessagesQueryKeyPrefix,
allThreadPendingInteractionsQueryKeyPrefix,
allThreadQueryKeyPrefix,
@@ -121,9 +121,9 @@ function getServerReconnectInvalidationQueryKeys(): QueryKey[] {
threadPromptHistoryQueryKeyPrefix(),
allThreadPendingInteractionsQueryKeyPrefix(),
allThreadDefaultExecutionOptionsQueryKeyPrefix(),
- allThreadAppsQueryKeyPrefix(),
- allThreadAppQueryKeyPrefix(),
- allThreadAppMarkdownPreviewQueryKeyPrefix(),
+ allAppsQueryKeyPrefix(),
+ allAppQueryKeyPrefix(),
+ allAppMarkdownPreviewQueryKeyPrefix(),
allThreadStorageFilesQueryKeyPrefix(),
allThreadStoragePathsQueryKeyPrefix(),
allThreadStorageFilePreviewQueryKeyPrefix(),
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index 3a1dc1159..a186e7a94 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -36,9 +36,9 @@ export const THREAD_TERMINALS_QUERY_KEY = "threadTerminals";
export const THREAD_STORAGE_FILES_QUERY_KEY = "threadStorageFiles";
export const THREAD_STORAGE_PATHS_QUERY_KEY = "threadStoragePaths";
export const THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY = "threadStorageFilePreview";
-export const THREAD_APPS_QUERY_KEY = "threadApps";
-export const THREAD_APP_QUERY_KEY = "threadApp";
-export const THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY = "threadAppMarkdownPreview";
+export const APPS_QUERY_KEY = "apps";
+export const APP_QUERY_KEY = "app";
+export const APP_MARKDOWN_PREVIEW_QUERY_KEY = "appMarkdownPreview";
export const THREAD_HOST_FILE_PREVIEW_QUERY_KEY = "threadHostFilePreview";
export const ENVIRONMENT_QUERY_KEY = "environment";
export const ENVIRONMENT_WORK_STATUS_QUERY_KEY = "environmentWorkStatus";
@@ -238,35 +238,21 @@ export type ThreadStorageFilePreviewQueryKeyPrefix = readonly [
typeof THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY,
string,
];
-export type AllThreadAppsQueryKeyPrefix = readonly [
- typeof THREAD_APPS_QUERY_KEY,
+export type AllAppsQueryKeyPrefix = readonly [typeof APPS_QUERY_KEY];
+export type AppsQueryKey = readonly [typeof APPS_QUERY_KEY];
+export type AllAppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY];
+export type AppQueryKey = readonly [typeof APP_QUERY_KEY, string];
+export type AppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY];
+export type AllAppMarkdownPreviewQueryKeyPrefix = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
];
-export type ThreadAppsQueryKey = readonly [
- typeof THREAD_APPS_QUERY_KEY,
- string,
-];
-export type AllThreadAppQueryKeyPrefix = readonly [typeof THREAD_APP_QUERY_KEY];
-export type ThreadAppQueryKey = readonly [
- typeof THREAD_APP_QUERY_KEY,
- string,
- string,
-];
-export type ThreadAppQueryKeyPrefix = readonly [
- typeof THREAD_APP_QUERY_KEY,
- string,
-];
-export type AllThreadAppMarkdownPreviewQueryKeyPrefix = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
-];
-export type ThreadAppMarkdownPreviewQueryKey = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
- string,
+export type AppMarkdownPreviewQueryKey = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
string,
string | null | undefined,
];
-export type ThreadAppMarkdownPreviewQueryKeyPrefix = readonly [
- typeof THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY,
- string,
+export type AppMarkdownPreviewQueryKeyPrefix = readonly [
+ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY,
];
export type ThreadHostFilePreviewQueryKey = readonly [
typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY,
@@ -670,47 +656,39 @@ export function threadStorageFilePreviewQueryKeyPrefix(
return [THREAD_STORAGE_FILE_PREVIEW_QUERY_KEY, threadId];
}
-export function allThreadAppsQueryKeyPrefix(): AllThreadAppsQueryKeyPrefix {
- return [THREAD_APPS_QUERY_KEY];
+export function allAppsQueryKeyPrefix(): AllAppsQueryKeyPrefix {
+ return [APPS_QUERY_KEY];
}
-export function threadAppsQueryKey(threadId: string): ThreadAppsQueryKey {
- return [THREAD_APPS_QUERY_KEY, threadId];
+export function appsQueryKey(): AppsQueryKey {
+ return [APPS_QUERY_KEY];
}
-export function allThreadAppQueryKeyPrefix(): AllThreadAppQueryKeyPrefix {
- return [THREAD_APP_QUERY_KEY];
+export function allAppQueryKeyPrefix(): AllAppQueryKeyPrefix {
+ return [APP_QUERY_KEY];
}
-export function threadAppQueryKey(
- threadId: string,
- appId: string,
-): ThreadAppQueryKey {
- return [THREAD_APP_QUERY_KEY, threadId, appId];
+export function appQueryKey(applicationId: string): AppQueryKey {
+ return [APP_QUERY_KEY, applicationId];
}
-export function threadAppQueryKeyPrefix(
- threadId: string,
-): ThreadAppQueryKeyPrefix {
- return [THREAD_APP_QUERY_KEY, threadId];
+export function appQueryKeyPrefix(): AppQueryKeyPrefix {
+ return [APP_QUERY_KEY];
}
-export function allThreadAppMarkdownPreviewQueryKeyPrefix(): AllThreadAppMarkdownPreviewQueryKeyPrefix {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY];
+export function allAppMarkdownPreviewQueryKeyPrefix(): AllAppMarkdownPreviewQueryKeyPrefix {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY];
}
-export function threadAppMarkdownPreviewQueryKey(
- threadId: string,
- appId: string,
+export function appMarkdownPreviewQueryKey(
+ applicationId: string,
entryPath: string | null | undefined,
-): ThreadAppMarkdownPreviewQueryKey {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY, threadId, appId, entryPath];
+): AppMarkdownPreviewQueryKey {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY, applicationId, entryPath];
}
-export function threadAppMarkdownPreviewQueryKeyPrefix(
- threadId: string,
-): ThreadAppMarkdownPreviewQueryKeyPrefix {
- return [THREAD_APP_MARKDOWN_PREVIEW_QUERY_KEY, threadId];
+export function appMarkdownPreviewQueryKeyPrefix(): AppMarkdownPreviewQueryKeyPrefix {
+ return [APP_MARKDOWN_PREVIEW_QUERY_KEY];
}
export function threadHostFilePreviewQueryKey(
diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts
index 8dc4204b9..b3fa366c2 100644
--- a/apps/app/src/hooks/queries/thread-queries.ts
+++ b/apps/app/src/hooks/queries/thread-queries.ts
@@ -49,9 +49,9 @@ import {
threadStorageFilesQueryKey,
threadStoragePathsQueryKey,
threadStorageFilePreviewQueryKey,
- threadAppMarkdownPreviewQueryKey,
- threadAppQueryKey,
- threadAppsQueryKey,
+ appMarkdownPreviewQueryKey,
+ appQueryKey,
+ appsQueryKey,
threadHostFilePreviewQueryKey,
threadTimelineQueryKey,
threadTimelineTurnSummaryDetailsQueryKey,
@@ -528,70 +528,51 @@ export function useThreadStorageFilePreview(
}
/**
- * Thread apps rarely change within a session and are read from both the sidebar
- * (a query per manager row) and the thread detail view. A shared default stale
- * window lets navigation reuse a recent sidebar fetch instead of refetching on
- * detail mount; callers can still override `staleTime` explicitly.
+ * Apps rarely change within a session and are read from both the sidebar and
+ * thread detail view. A shared default stale window lets navigation reuse a
+ * recent fetch instead of refetching on detail mount; callers can still
+ * override `staleTime` explicitly.
*/
-const THREAD_APPS_STALE_TIME_MS = 30_000;
+const APPS_STALE_TIME_MS = 30_000;
-export function useThreadApps(id: string, options?: QueryOptions) {
+export function useApps(options?: QueryOptions) {
return useQuery({
- queryKey: threadAppsQueryKey(id),
- queryFn: ({ signal }) =>
- api.listThreadApps(requireThreadId(id, "useThreadApps"), signal),
- enabled: (options?.enabled ?? true) && Boolean(id),
+ queryKey: appsQueryKey(),
+ queryFn: ({ signal }) => api.listApps(signal),
+ enabled: options?.enabled ?? true,
refetchOnMount: options?.refetchOnMount ?? true,
refetchOnWindowFocus: false,
- staleTime: options?.staleTime ?? THREAD_APPS_STALE_TIME_MS,
+ staleTime: options?.staleTime ?? APPS_STALE_TIME_MS,
});
}
-export function useThreadApp(
- id: string,
- appId: string | null | undefined,
+export function useApp(
+ applicationId: string | null | undefined,
options?: QueryOptions,
) {
- const queryClient = useQueryClient();
-
return useQuery({
- queryKey: threadAppQueryKey(id, appId ?? ""),
+ queryKey: appQueryKey(applicationId ?? ""),
queryFn: ({ signal }) =>
- api.getThreadApp(
- requireThreadId(id, "useThreadApp"),
- appId ?? "",
- signal,
- ),
- enabled: (options?.enabled ?? true) && Boolean(id) && Boolean(appId),
+ api.getApp(applicationId ?? "", signal),
+ enabled: (options?.enabled ?? true) && Boolean(applicationId),
refetchOnMount: options?.refetchOnMount ?? true,
refetchOnWindowFocus: false,
- placeholderData: () =>
- queryClient
- .getQueryData(threadAppsQueryKey(id))
- ?.find((app) => app.id === appId),
staleTime: options?.staleTime,
});
}
-export function useThreadAppMarkdownPreview(
- id: string,
- appId: string | null | undefined,
+export function useAppMarkdownPreview(
+ applicationId: string | null | undefined,
entryPath: string | null | undefined,
options?: QueryOptions,
) {
return useQuery({
- queryKey: threadAppMarkdownPreviewQueryKey(id, appId ?? "", entryPath),
+ queryKey: appMarkdownPreviewQueryKey(applicationId ?? "", entryPath),
queryFn: ({ signal }) =>
- api.getThreadAppMarkdownPreview(
- requireThreadId(id, "useThreadAppMarkdownPreview"),
- appId ?? "",
- entryPath ?? "",
- signal,
- ),
+ api.getAppMarkdownPreview(applicationId ?? "", entryPath ?? "", signal),
enabled:
(options?.enabled ?? true) &&
- Boolean(id) &&
- Boolean(appId) &&
+ Boolean(applicationId) &&
Boolean(entryPath),
refetchOnWindowFocus: false,
staleTime: options?.staleTime,
diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts
index 3c4ceacde..c9aea770b 100644
--- a/apps/app/src/hooks/realtime-cache-effects.test.ts
+++ b/apps/app/src/hooks/realtime-cache-effects.test.ts
@@ -4,11 +4,13 @@ import {
ENVIRONMENT_CHANGE_KINDS,
HOST_CHANGE_KINDS,
PROJECT_CHANGE_KINDS,
+ SYSTEM_CHANGE_KINDS,
THREAD_CHANGE_KINDS,
} from "@bb/domain";
import { createAppQueryClient } from "@/lib/query-client";
import {
archivedThreadsListQueryKey,
+ appsQueryKey,
environmentGitDiffQueryKey,
environmentWorkStatusQueryKey,
localPathExistenceQueryKey,
@@ -17,9 +19,6 @@ import {
projectSourceBranchesQueryKey,
projectsQueryKey,
sidebarNavigationQueryKey,
- threadAppMarkdownPreviewQueryKey,
- threadAppQueryKey,
- threadAppsQueryKey,
threadQueuedMessagesQueryKey,
threadListQueryKey,
threadPromptHistoryQueryKey,
@@ -33,6 +32,7 @@ import {
REALTIME_ENVIRONMENT_CHANGE_REGISTRY,
REALTIME_HOST_CHANGE_REGISTRY,
REALTIME_PROJECT_CHANGE_REGISTRY,
+ REALTIME_SYSTEM_CHANGE_REGISTRY,
REALTIME_THREAD_CHANGE_REGISTRY,
} from "./cache-owners/realtime-cache-registry";
@@ -127,6 +127,14 @@ describe("createRealtimeCacheEffects", () => {
}
});
+ it("maps every realtime system change to at least one dirty handler", () => {
+ for (const changeKind of SYSTEM_CHANGE_KINDS) {
+ expect(
+ REALTIME_SYSTEM_CHANGE_REGISTRY[changeKind].dirty.length,
+ ).toBeGreaterThan(0);
+ }
+ });
+
it.each(PROJECT_PROMPT_HISTORY_THREAD_CHANGES)(
"invalidates all cached project prompt histories for %s thread events",
(change) => {
@@ -583,89 +591,6 @@ describe("createRealtimeCacheEffects", () => {
effects.dispose();
});
- it("refetches active thread app queries for thread storage changes", async () => {
- vi.useFakeTimers();
- const { effects, queryClient } = createRealtimeEffectsTestContext();
- const threadKey = threadQueryKey("thr_1");
- const appListKey = threadAppsQueryKey("thr_1");
- const appDetailKey = threadAppQueryKey("thr_1", "status");
- const markdownPreviewKey = threadAppMarkdownPreviewQueryKey(
- "thr_1",
- "status",
- "index.md",
- );
- const nextAppDetail = {
- id: "status",
- name: "Status",
- entry: { path: "index.html", kind: "html" },
- capabilities: ["data"],
- icon: { kind: "builtin", name: "ListTodo" },
- };
- const nextMarkdownPreview = {
- kind: "text",
- content: "new",
- mimeType: "text/markdown",
- path: "index.md",
- url: "/new",
- };
- queryClient.setQueryData(threadKey, {
- id: "thr_1",
- environmentId: "env-1",
- });
- queryClient.setQueryData(appListKey, []);
- queryClient.setQueryData(appDetailKey, {
- ...nextAppDetail,
- name: "Old Status",
- });
- queryClient.setQueryData(markdownPreviewKey, {
- ...nextMarkdownPreview,
- content: "old",
- });
- const appListQueryFn = vi.fn(async () => [nextAppDetail]);
- const appDetailQueryFn = vi.fn(async () => nextAppDetail);
- const markdownPreviewQueryFn = vi.fn(async () => nextMarkdownPreview);
- const appListObserver = new QueryObserver(queryClient, {
- queryKey: appListKey,
- queryFn: appListQueryFn,
- staleTime: Infinity,
- });
- const appDetailObserver = new QueryObserver(queryClient, {
- queryKey: appDetailKey,
- queryFn: appDetailQueryFn,
- staleTime: Infinity,
- });
- const markdownPreviewObserver = new QueryObserver(queryClient, {
- queryKey: markdownPreviewKey,
- queryFn: markdownPreviewQueryFn,
- staleTime: Infinity,
- });
- const unsubscribeAppList = appListObserver.subscribe(() => {});
- const unsubscribeAppDetail = appDetailObserver.subscribe(() => {});
- const unsubscribeMarkdownPreview = markdownPreviewObserver.subscribe(
- () => {},
- );
- appListQueryFn.mockClear();
- appDetailQueryFn.mockClear();
- markdownPreviewQueryFn.mockClear();
-
- effects.handleChanged({
- type: "changed",
- entity: "environment",
- id: "env-1",
- changes: ["thread-storage-changed"],
- });
- await vi.advanceTimersByTimeAsync(250);
-
- expect(appListQueryFn).toHaveBeenCalledTimes(1);
- expect(appDetailQueryFn).toHaveBeenCalledTimes(1);
- expect(markdownPreviewQueryFn).toHaveBeenCalledTimes(1);
-
- unsubscribeAppList();
- unsubscribeAppDetail();
- unsubscribeMarkdownPreview();
- effects.dispose();
- });
-
it("does not invalidate timeline queries for status-only thread changes", () => {
const { effects, queryClient } = createRealtimeEffectsTestContext();
const timelineKey = threadTimelineQueryKey("thr_1", undefined);
@@ -1069,6 +994,31 @@ describe("createRealtimeCacheEffects", () => {
effects.dispose();
});
+ it("refetches active app list queries for app list changes without reconnect", async () => {
+ const { effects, queryClient } = createRealtimeEffectsTestContext();
+ const appsKey = appsQueryKey();
+ queryClient.setQueryData(appsKey, []);
+ const appsQueryFn = vi.fn(async () => []);
+ const appsObserver = new QueryObserver(queryClient, {
+ queryKey: appsKey,
+ queryFn: appsQueryFn,
+ staleTime: Infinity,
+ });
+ const unsubscribeApps = appsObserver.subscribe(() => {});
+ appsQueryFn.mockClear();
+
+ effects.handleChanged({
+ type: "changed",
+ entity: "system",
+ changes: ["apps-changed"],
+ });
+
+ await vi.waitFor(() => expect(appsQueryFn).toHaveBeenCalledTimes(1));
+
+ unsubscribeApps();
+ effects.dispose();
+ });
+
it("invalidates cached thread terminals for terminal changes", () => {
vi.useFakeTimers();
const { effects, queryClient, terminalKey } =
diff --git a/apps/app/src/hooks/useAppRoute.test.tsx b/apps/app/src/hooks/useAppRoute.test.tsx
index d9a6ca76c..506287b2a 100644
--- a/apps/app/src/hooks/useAppRoute.test.tsx
+++ b/apps/app/src/hooks/useAppRoute.test.tsx
@@ -85,6 +85,16 @@ describe("useAppRoute", () => {
expect(route.isProjectlessView).toBe(false);
});
+ it("recognizes the standalone app route", () => {
+ const route = renderRouteCapture({ initialEntry: "/apps/alpha" });
+
+ expect(route.applicationId).toBe("alpha");
+ expect(route.isAppView).toBe(true);
+ expect(route.threadId).toBeUndefined();
+ expect(route.projectId).toBeUndefined();
+ expect(route.isThreadView).toBe(false);
+ });
+
it("does not accept personal project thread routes", () => {
const route = renderRouteCapture({
initialEntry: `/projects/${PERSONAL_PROJECT_ID}/threads/thr_personal`,
diff --git a/apps/app/src/hooks/useAppRoute.ts b/apps/app/src/hooks/useAppRoute.ts
index 66c420b45..707578af4 100644
--- a/apps/app/src/hooks/useAppRoute.ts
+++ b/apps/app/src/hooks/useAppRoute.ts
@@ -6,6 +6,10 @@ export interface AppRouteState {
projectId: string | undefined;
/** ID of the thread in view (thread detail only), else undefined. */
threadId: string | undefined;
+ /** ID of the global app in view (standalone app route only), else undefined. */
+ applicationId: string | undefined;
+ /** On the standalone app surface (`/apps/:applicationId`). */
+ isAppView: boolean;
/** On a thread detail URL. */
isThreadView: boolean;
/** On the project's archived threads list. */
@@ -35,6 +39,7 @@ export function useAppRoute(): AppRouteState {
const projectlessThreadMatch = useMatch("/threads/:threadId/*");
const projectArchivedMatch = useMatch("/projects/:projectId/archived");
const projectSettingsMatch = useMatch("/projects/:projectId/settings");
+ const appMatch = useMatch("/apps/:applicationId");
const isRootView = location.pathname === "/";
const isUnsupportedPersonalProjectThread =
projectThreadMatch?.params.projectId === PERSONAL_PROJECT_ID;
@@ -55,6 +60,8 @@ export function useAppRoute(): AppRouteState {
return {
projectId,
threadId,
+ applicationId: appMatch?.params.applicationId,
+ isAppView: Boolean(appMatch),
isThreadView:
Boolean(projectlessThreadMatch) ||
(Boolean(projectThreadMatch) && !isUnsupportedPersonalProjectThread),
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
index c2937f257..a3253110e 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
+++ b/apps/app/src/hooks/useFileSearchSuggestions.test.tsx
@@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
return {
...actual,
searchProjectPaths: vi.fn(),
- listThreadApps: vi.fn(),
+ listApps: vi.fn(),
listThreadStoragePaths: vi.fn(),
};
});
@@ -63,9 +63,9 @@ function isFilePathSearchSuggestion(
return suggestion.entryKind === "file";
}
-const STATUS_APP: AppSummary = {
- id: "status",
- name: "Status",
+const APP: AppSummary = {
+ applicationId: "status",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
@@ -78,7 +78,7 @@ afterEach(() => {
describe("useFileSearchSuggestions", () => {
it("merges workspace and manager thread-storage file results", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([]);
+ vi.mocked(api.listApps).mockResolvedValue([]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -145,7 +145,7 @@ describe("useFileSearchSuggestions", () => {
});
it("returns matching apps before files", async () => {
- vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]);
+ vi.mocked(api.listApps).mockResolvedValue([APP]);
vi.mocked(api.searchProjectPaths).mockResolvedValue(
makePathResponse([
{
@@ -180,8 +180,8 @@ describe("useFileSearchSuggestions", () => {
expect(result.current.suggestions[0]).toMatchObject({
source: "app",
entryKind: "app",
- appId: "status",
- name: "Status",
+ applicationId: "status",
+ name: "Review Board",
});
expect(result.current.suggestions[1]).toMatchObject({
source: "workspace",
diff --git a/apps/app/src/hooks/useFileSearchSuggestions.ts b/apps/app/src/hooks/useFileSearchSuggestions.ts
index af7c4e4b6..2317d5640 100644
--- a/apps/app/src/hooks/useFileSearchSuggestions.ts
+++ b/apps/app/src/hooks/useFileSearchSuggestions.ts
@@ -6,7 +6,7 @@ import {
type PathSuggestion,
type PathSuggestionSource,
} from "./usePathSuggestions";
-import { useThreadApps } from "./queries/thread-queries";
+import { useApps } from "./queries/thread-queries";
const DEFAULT_FILE_SEARCH_SUGGESTION_LIMIT = 8;
@@ -14,7 +14,7 @@ export interface AppSearchSuggestion {
source: "app";
entryKind: "app";
app: AppSummary;
- appId: string;
+ applicationId: string;
name: string;
score: number;
}
@@ -84,7 +84,7 @@ function scoreAppSearchMatch(app: AppSummary, normalizedQuery: string): number {
}
const normalizedName = app.name.toLowerCase();
- const normalizedId = app.id.toLowerCase();
+ const normalizedId = app.applicationId.toLowerCase();
if (normalizedName === normalizedQuery || normalizedId === normalizedQuery) {
return 100;
}
@@ -119,7 +119,7 @@ function buildAppSearchSuggestions({
source: "app",
entryKind: "app",
app,
- appId: app.id,
+ applicationId: app.applicationId,
name: app.name,
score,
});
@@ -149,17 +149,17 @@ export function useFileSearchSuggestions(
includeDirectories: false,
});
const canSearchApps = Boolean(args.currentThreadId);
- const threadApps = useThreadApps(args.currentThreadId ?? "", {
+ const apps = useApps({
enabled: canSearchApps,
});
const appSuggestions = useMemo(
() =>
buildAppSearchSuggestions({
- apps: threadApps.data ?? [],
+ apps: apps.data ?? [],
limit,
query: args.query ?? "",
}),
- [args.query, limit, threadApps.data],
+ [args.query, apps.data, limit],
);
const fileSuggestions = useMemo(
() =>
@@ -180,8 +180,8 @@ export function useFileSearchSuggestions(
suggestions,
isLoading:
suggestions.length === 0 &&
- (pathSuggestions.isLoading || (canSearchApps && threadApps.isLoading)),
- isError: pathSuggestions.isError || (canSearchApps && threadApps.isError),
+ (pathSuggestions.isLoading || (canSearchApps && apps.isLoading)),
+ isError: pathSuggestions.isError || (canSearchApps && apps.isError),
isDebouncing: pathSuggestions.isDebouncing,
isUnavailable: !canSearchApps && !canSearchWorkspace && !canSearchThreadStorage,
};
diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts
index f64088b1e..063ff3cf1 100644
--- a/apps/app/src/lib/api.ts
+++ b/apps/app/src/lib/api.ts
@@ -92,7 +92,7 @@ import {
type FilePreviewTarget,
} from "./file-preview";
import {
- buildThreadAppAssetUrl,
+ buildAppPublicFileUrl,
buildThreadHostFileContentUrl,
buildThreadStorageContentUrl,
} from "./file-content-urls";
@@ -886,34 +886,28 @@ export async function getThreadStorageFilePreview(
);
}
-export async function listThreadApps(
- id: string,
+export async function listApps(
signal?: AbortSignal,
): Promise {
return request(
- apiClient.threads[":id"].apps.$get(
- { param: { id } },
- requestOptions(signal),
- ),
+ apiClient.apps.$get({}, requestOptions(signal)),
);
}
-export async function getThreadApp(
- id: string,
- appId: string,
+export async function getApp(
+ applicationId: string,
signal?: AbortSignal,
): Promise {
return request(
- apiClient.threads[":id"].apps[":appId"].$get(
- { param: { id, appId } },
+ apiClient.apps[":applicationId"].$get(
+ { param: { applicationId } },
requestOptions(signal),
),
);
}
-export async function getThreadAppMarkdownPreview(
- id: string,
- appId: string,
+export async function getAppMarkdownPreview(
+ applicationId: string,
path: string,
signal?: AbortSignal,
): Promise {
@@ -921,7 +915,7 @@ export async function getThreadAppMarkdownPreview(
{
name: path.split("/").at(-1),
path,
- url: buildThreadAppAssetUrl(id, appId, path),
+ url: buildAppPublicFileUrl(applicationId, path),
},
signal,
);
diff --git a/apps/app/src/lib/app-route-paths.ts b/apps/app/src/lib/app-route-paths.ts
index a3c098e29..edeab3972 100644
--- a/apps/app/src/lib/app-route-paths.ts
+++ b/apps/app/src/lib/app-route-paths.ts
@@ -5,6 +5,7 @@ export const AUTH_CALLBACK_ROUTE_PATH = "/auth/callback";
export const APP_SETTINGS_ROUTE_PATH = "/settings";
export const DEVELOPMENT_REPLAY_ROUTE_PATH = "/development-only/replay";
export const ROOT_COMPOSE_ROUTE_PATH = APP_ROOT_ROUTE_PATH;
+export const STANDALONE_APP_ROUTE_PATH = "/apps/:applicationId";
export const LEGACY_PROJECT_COMPOSE_ROUTE_PATH = "/projects/:projectId";
export const PROJECTLESS_THREAD_DETAIL_ROUTE_PATH = "/threads/:threadId";
export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings";
@@ -45,10 +46,15 @@ export function getThreadRoutePath(args: ThreadRoutePathArgs): string {
: `/projects/${args.projectId}/threads/${args.threadId}`;
}
+export function getStandaloneAppRoutePath(applicationId: string): string {
+ return `/apps/${applicationId}`;
+}
+
const baseAppRoutePatterns: readonly string[] = [
APP_ROOT_ROUTE_PATH,
AUTH_CALLBACK_ROUTE_PATH,
APP_SETTINGS_ROUTE_PATH,
+ STANDALONE_APP_ROUTE_PATH,
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
PROJECT_SETTINGS_ROUTE_PATH,
PROJECT_ARCHIVED_ROUTE_PATH,
diff --git a/apps/app/src/lib/browser-view-bounds-sync.ts b/apps/app/src/lib/browser-view-bounds-sync.ts
new file mode 100644
index 000000000..7b879f1ae
--- /dev/null
+++ b/apps/app/src/lib/browser-view-bounds-sync.ts
@@ -0,0 +1,5 @@
+export const BROWSER_VIEW_BOUNDS_SYNC_EVENT = "bb:browser-view-bounds-sync";
+
+export function dispatchBrowserViewBoundsSync(): void {
+ window.dispatchEvent(new Event(BROWSER_VIEW_BOUNDS_SYNC_EVENT));
+}
diff --git a/apps/app/src/lib/file-content-urls.ts b/apps/app/src/lib/file-content-urls.ts
index 13ee9726b..04437f1ec 100644
--- a/apps/app/src/lib/file-content-urls.ts
+++ b/apps/app/src/lib/file-content-urls.ts
@@ -35,33 +35,56 @@ export function buildThreadStorageRawContentUrl(
return `/api/v1/threads/${encodeURIComponent(threadId)}/thread-storage/files/${encodePathSegments(path)}`;
}
-export function buildThreadAppEntryUrl(
- threadId: string,
- appId: string,
-): string {
- return `/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/`;
+export interface AppEntryUrlArgs {
+ applicationId: string;
+ /**
+ * Thread the app should target for its `message` capability. `null` for the
+ * standalone surface, where the app renders thread-independently and has no
+ * thread to post into.
+ */
+ targetThreadId: string | null;
+ /**
+ * Cache-busting token (typically the app detail's `dataUpdatedAt`) so the
+ * iframe reloads when the underlying app changes. Omitted when no reload
+ * tracking is needed.
+ */
+ reloadToken?: number | string;
}
-export function buildThreadAppAssetUrl(
- threadId: string,
- appId: string,
+export function buildAppEntryUrl({
+ applicationId,
+ targetThreadId,
+ reloadToken,
+}: AppEntryUrlArgs): string {
+ const params = new URLSearchParams();
+ if (targetThreadId !== null) {
+ params.set("targetThreadId", targetThreadId);
+ }
+ if (reloadToken !== undefined) {
+ params.set("v", String(reloadToken));
+ }
+ const query = params.toString();
+ return `/api/v1/apps/${encodeURIComponent(applicationId)}/${
+ query ? `?${query}` : ""
+ }`;
+}
+
+export function buildAppPublicFileUrl(
+ applicationId: string,
path: string,
): string {
- return `/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/${encodePathSegments(path)}`;
+ return `/api/v1/apps/${encodeURIComponent(
+ applicationId,
+ )}/${encodePathSegments(path)}`;
}
-export function buildThreadAppAssetBaseUrl(
- threadId: string,
- appId: string,
+export function buildAppPublicBaseUrl(
+ applicationId: string,
entryPath: string,
): string {
const lastSlash = entryPath.lastIndexOf("/");
const basePath = lastSlash === -1 ? "" : entryPath.slice(0, lastSlash + 1);
- return buildThreadAppAssetUrl(threadId, appId, basePath);
+ return buildAppPublicFileUrl(applicationId, basePath);
}
export function buildThreadHostFileContentUrl(
diff --git a/apps/app/src/lib/fixed-panel-tabs-state.test.ts b/apps/app/src/lib/fixed-panel-tabs-state.test.ts
index 7368e46d8..6dff9884c 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.test.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.test.ts
@@ -70,7 +70,6 @@ function makeFixedPanelTabsState(
});
}
-
describe("fixed panel tabs state storage", () => {
it("round-trips valid state", () => {
const state = makeFixedPanelTabsState();
@@ -86,7 +85,7 @@ describe("fixed panel tabs state storage", () => {
});
it("round-trips app tabs", () => {
- const appTab = createAppFixedPanelTab({ appId: "status" });
+ const appTab = createAppFixedPanelTab({ applicationId: "status" });
const state = makeFixedPanelTabsState({
secondary: {
tabs: [appTab],
diff --git a/apps/app/src/lib/fixed-panel-tabs-state.ts b/apps/app/src/lib/fixed-panel-tabs-state.ts
index 39ad99a13..d3557c099 100644
--- a/apps/app/src/lib/fixed-panel-tabs-state.ts
+++ b/apps/app/src/lib/fixed-panel-tabs-state.ts
@@ -82,7 +82,7 @@ const threadStorageFilePreviewFixedPanelTabSchema = z
.strict();
const appFixedPanelTabSchema = z
.object({
- appId: z.string().min(1),
+ applicationId: z.string().min(1),
id: z.string().min(1),
kind: z.literal("app"),
})
@@ -194,7 +194,7 @@ export interface ThreadStorageFilePreviewFixedPanelTab {
}
export interface AppFixedPanelTab {
- appId: string;
+ applicationId: string;
id: string;
kind: "app";
}
@@ -268,6 +268,28 @@ export interface FixedPanelTabsState {
lastUsedAt: number;
}
+interface GetActiveSecondaryAppIdArgs {
+ isSecondaryPanelOpen: boolean;
+ state: FixedPanelTabsState;
+}
+
+/**
+ * The application id of the secondary panel's active tab when that tab is an
+ * app and the panel is open, else null. Lets sidebar rows tell whether a
+ * thread's panel is currently showing a given app without reaching into the
+ * tab list shape themselves.
+ */
+export function getActiveSecondaryAppId(
+ { isSecondaryPanelOpen, state }: GetActiveSecondaryAppIdArgs,
+): string | null {
+ const { activeTabId, tabs } = state.secondary;
+ if (!isSecondaryPanelOpen || activeTabId === null) {
+ return null;
+ }
+ const activeTab = tabs.find((tab) => tab.id === activeTabId);
+ return activeTab?.kind === "app" ? activeTab.applicationId : null;
+}
+
interface FixedPanelTabsStorageKeyArgs {
threadId: string;
}
@@ -317,7 +339,7 @@ interface CreateThreadStorageFilePreviewFixedPanelTabArgs {
}
interface CreateAppFixedPanelTabArgs {
- appId: string;
+ applicationId: string;
}
interface CreateBrowserFixedPanelTabArgs {
@@ -401,11 +423,11 @@ export function createThreadStorageFilePreviewFixedPanelTab({
}
export function createAppFixedPanelTab({
- appId,
+ applicationId,
}: CreateAppFixedPanelTabArgs): AppFixedPanelTab {
return {
- appId,
- id: `app:${encodeURIComponent(appId)}`,
+ applicationId,
+ id: `app:${encodeURIComponent(applicationId)}`,
kind: "app",
};
}
@@ -413,7 +435,7 @@ export function createAppFixedPanelTab({
/**
* Browser tabs get a fresh unique id per instance — the URL is mutable (it
* changes on every navigation), so it cannot serve as a stable identity the way
- * an app id or file path does.
+ * an application id or file path does.
*/
export function createBrowserFixedPanelTab({
url,
@@ -697,7 +719,7 @@ export function areFixedPanelTabsEquivalent(
a.path === b.path
);
case "app":
- return b.kind === "app" && a.appId === b.appId;
+ return b.kind === "app" && a.applicationId === b.applicationId;
case "browser":
return b.kind === "browser" && a.url === b.url && a.title === b.title;
case "thread-storage-file-preview":
diff --git a/apps/app/src/lib/fixed-panel-tabs.ts b/apps/app/src/lib/fixed-panel-tabs.ts
index 82eb2771c..68b26caf7 100644
--- a/apps/app/src/lib/fixed-panel-tabs.ts
+++ b/apps/app/src/lib/fixed-panel-tabs.ts
@@ -38,6 +38,7 @@ interface LastFixedPanelTabsTouch {
}
type FixedPanelSecondaryPanelSetter = (panel: ThreadSecondaryPanel) => void;
+type FixedPanelSecondaryPanelOpener = () => void;
type FixedPanelSecondaryPanelCloser = () => void;
type FixedPanelSecondaryPanelToggler = () => void;
type FixedPanelTerminalIdSetter = (terminalId: string | null) => void;
@@ -304,6 +305,15 @@ export function useCloseFixedSecondaryPanel(
}, [updateState]);
}
+export function useOpenFixedSecondaryPanel(
+ threadId: FixedPanelTabsThreadId,
+): FixedPanelSecondaryPanelOpener {
+ const updateState = useUpdateFixedPanelTabsState(threadId);
+ return useCallback(() => {
+ updateState(openFixedSecondaryPanelState);
+ }, [updateState]);
+}
+
export function useToggleFixedSecondaryPanel(
threadId: string | null | undefined,
): FixedPanelSecondaryPanelToggler {
diff --git a/apps/app/src/views/standalone-app/StandaloneAppView.test.tsx b/apps/app/src/views/standalone-app/StandaloneAppView.test.tsx
new file mode 100644
index 000000000..6c0ad755f
--- /dev/null
+++ b/apps/app/src/views/standalone-app/StandaloneAppView.test.tsx
@@ -0,0 +1,97 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import type { AppDetail } from "@bb/server-contract";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import * as api from "@/lib/api";
+import { HttpError } from "@/lib/api";
+import { STANDALONE_APP_ROUTE_PATH } from "@/lib/app-route-paths";
+import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
+import { StandaloneAppView } from "./StandaloneAppView";
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ getApp: vi.fn(),
+ getAppMarkdownPreview: vi.fn(),
+ };
+});
+
+const HTML_APP: AppDetail = {
+ applicationId: "status",
+ name: "Review Board",
+ entry: { path: "index.html", kind: "html" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
+};
+
+function renderStandaloneApp(applicationId: string) {
+ const { wrapper } = createQueryClientTestHarness();
+ return render(
+
+
+ }
+ />
+
+ ,
+ { wrapper },
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("StandaloneAppView", () => {
+ it("renders the app surface in a thread-independent iframe", async () => {
+ vi.mocked(api.getApp).mockResolvedValue(HTML_APP);
+
+ renderStandaloneApp("status");
+
+ const frame = await screen.findByTitle("Review Board");
+ // No targetThreadId on the standalone surface — the app renders without a
+ // host thread.
+ expect(frame.getAttribute("src")).toMatch(
+ /^\/api\/v1\/apps\/status\/\?v=\d+$/u,
+ );
+ });
+
+ it("shows a clean not-found state when the app is missing", async () => {
+ vi.mocked(api.getApp).mockRejectedValue(
+ new HttpError({
+ status: 404,
+ code: "app_missing",
+ message: "App not found",
+ }),
+ );
+
+ renderStandaloneApp("gone");
+
+ expect(await screen.findByText("App not found.")).toBeTruthy();
+ });
+
+ it("surfaces an invalid manifest error", async () => {
+ vi.mocked(api.getApp).mockRejectedValue(
+ new HttpError({
+ status: 422,
+ code: "invalid_manifest",
+ message: "App manifest failed validation.",
+ }),
+ );
+
+ renderStandaloneApp("broken");
+
+ expect(
+ await screen.findByText("This app's manifest is invalid."),
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/app/src/views/standalone-app/StandaloneAppView.tsx b/apps/app/src/views/standalone-app/StandaloneAppView.tsx
new file mode 100644
index 000000000..3ff88e620
--- /dev/null
+++ b/apps/app/src/views/standalone-app/StandaloneAppView.tsx
@@ -0,0 +1,89 @@
+import { useApp } from "@/hooks/queries/thread-queries";
+import { useAppRoute } from "@/hooks/useAppRoute";
+import { AppViewer } from "@/components/app-viewer/AppViewer";
+import { EmptyState } from "@/components/ui/empty-state.js";
+import { Skeleton } from "@/components/ui/skeleton.js";
+import { HttpError } from "@/lib/api";
+import type { IconName } from "@/components/ui/icon.js";
+
+// Counters the padded `` so the app fills the surface edge to edge below
+// the global header, matching how other full surfaces (PageShell) bleed.
+const STANDALONE_SHELL_CLASS =
+ "-m-4 flex h-full min-h-0 flex-1 flex-col overflow-hidden md:-m-5";
+
+interface StandaloneAppMessageState {
+ icon: IconName;
+ message: string;
+}
+
+function resolveStandaloneAppErrorState(
+ error: unknown,
+): StandaloneAppMessageState {
+ const code = error instanceof HttpError ? error.code : undefined;
+ if (code === "app_missing") {
+ return { icon: "FileQuestion", message: "App not found." };
+ }
+ if (code === "invalid_manifest") {
+ return {
+ icon: "AlertTriangle",
+ message: "This app's manifest is invalid.",
+ };
+ }
+ return {
+ icon: "AlertTriangle",
+ message: error instanceof Error ? error.message : "Failed to load app.",
+ };
+}
+
+function StandaloneAppMessage({ icon, message }: StandaloneAppMessageState) {
+ return (
+
+
+
+ );
+}
+
+function StandaloneAppLoading() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Standalone, thread-independent surface for a global app at
+ * `/apps/:applicationId`. The app name is shown in the global header (see
+ * AppLayout); this view owns the missing/invalid/loading states and delegates
+ * the rendered app to the shared {@link AppViewer}. Deep-linking here loads the
+ * app with no thread or project context.
+ */
+export function StandaloneAppView() {
+ const { applicationId } = useAppRoute();
+ const appDetail = useApp(applicationId);
+
+ if (applicationId === undefined) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {appDetail.isError ? (
+
+ ) : appDetail.isPending ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 4b98086c3..f89329ed1 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -31,9 +31,9 @@ import {
} from "../../hooks/queries/environment-queries";
import {
getLatestPendingInteraction,
+ useApps,
useProjectThreadSubset,
useThread,
- useThreadApps,
useThreadComposerBootstrap,
useThreadDetailBootstrap,
useThreadPendingInteractions,
@@ -78,7 +78,10 @@ import { ThreadDetailSecondaryContent } from "./ThreadDetailSecondaryContent";
import { useThreadSecondaryPanelVisibility } from "./useThreadSecondaryPanelVisibility";
import type { HostConnectionNotice } from "./ThreadTimelinePane";
import { useThreadStorageViewer } from "@/components/secondary-panel/useThreadStorageViewer";
-import { getThreadConversationCollapsedAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
+import {
+ getThreadConversationCollapsedAtom,
+ getThreadSecondaryPanelOpenAtom,
+} from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import {
HostFilePreviewTabContent,
ThreadStorageFilePreviewTabContent,
@@ -99,10 +102,7 @@ import {
import { getBrowserUrlHost } from "@/lib/browser-url";
import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon";
import { useManagerStorageBrowser } from "@/components/secondary-panel/useManagerStorageBrowser";
-import {
- STATUS_APP_ID,
- useThreadFileTabs,
-} from "@/components/secondary-panel/useThreadFileTabs";
+import { useThreadFileTabs } from "@/components/secondary-panel/useThreadFileTabs";
import type { SecondaryPanelFileTab } from "@/components/secondary-panel/ThreadSecondaryPanel";
import { useEnvironmentMergeBase } from "@/components/secondary-panel/git-diff/useEnvironmentMergeBase";
import { useThreadGitActions } from "./useThreadGitActions";
@@ -194,6 +194,9 @@ export function ThreadDetailView() {
useFixedPanelTabsStorageMaintenance(threadId);
useThreadTerminalPanelStorageMaintenance(threadId);
const fixedPanelTabsState = useFixedPanelTabsState(threadId);
+ const isPersistedSecondaryPanelOpen = useAtomValue(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
const terminalPanelState = useThreadTerminalPanelState(threadId);
const activeFixedSecondaryTab = getActiveFixedSecondaryTab({
fixedPanelTabsState,
@@ -202,7 +205,7 @@ export function ThreadDetailView() {
activeFixedSecondaryTab,
});
const activeSecondaryPanel = getActiveThreadSecondaryPanel({
- fixedPanelTabsState,
+ isSecondaryPanelOpen: isPersistedSecondaryPanelOpen,
selectedSecondaryPanel,
});
const renderSecondaryPanelAsDrawer = useIsCompactViewport();
@@ -306,8 +309,8 @@ export function ThreadDetailView() {
threadId,
threadType: thread?.type,
});
- const threadAppsQuery = useThreadApps(threadId ?? "", {
- enabled: Boolean(threadId) && thread !== undefined,
+ const appsQuery = useApps({
+ enabled: thread !== undefined,
});
const {
activateAppTab,
@@ -336,7 +339,6 @@ export function ThreadDetailView() {
isNewTabActive,
openBrowserTab,
openNewTab,
- openApp,
openHostFile,
openStorageFile,
openWorkspaceFile,
@@ -344,7 +346,7 @@ export function ThreadDetailView() {
selectFileSearchResult,
updateBrowserTab,
} = useThreadFileTabs({
- apps: threadAppsQuery.data,
+ apps: appsQuery.data,
threadId,
environmentId: thread?.environmentId,
threadType: thread?.type,
@@ -370,24 +372,6 @@ export function ThreadDetailView() {
onSelectPath: openStorageFile,
selectedPath: activeStorageFilePath,
});
- const togglePersistedSecondaryPanel = useCallback(() => {
- if (fixedPanelTabsState.secondary.isOpen) {
- setThreadSecondaryPanel(null);
- return;
- }
- if (isManagerThread && activeFixedSecondaryTab === null) {
- openApp(STATUS_APP_ID);
- return;
- }
- toggleDefaultPersistedSecondaryPanel();
- }, [
- activeFixedSecondaryTab,
- fixedPanelTabsState.secondary.isOpen,
- isManagerThread,
- openApp,
- setThreadSecondaryPanel,
- toggleDefaultPersistedSecondaryPanel,
- ]);
const handleUseStandardManagerTimelineChange = useCallback(
(checked: boolean) => {
if (!isManagerThread) {
@@ -505,13 +489,13 @@ export function ThreadDetailView() {
togglePanel: toggleSecondaryPanel,
} = useThreadSecondaryPanelVisibility({
closePersistedPanel: closeThreadSecondaryPanel,
- isPersistedOpen: fixedPanelTabsState.secondary.isOpen,
+ isPersistedOpen: isPersistedSecondaryPanelOpen,
isCompactViewport: renderSecondaryPanelAsDrawer,
openPersistedDiffFile,
openPersistedDiffPanel,
openPersistedPanel: openPersistedSecondaryPanel,
threadId,
- togglePersistedPanel: togglePersistedSecondaryPanel,
+ togglePersistedPanel: toggleDefaultPersistedSecondaryPanel,
});
const [storedConversationCollapsed, setStoredConversationCollapsed] = useAtom(
getThreadConversationCollapsedAtom(threadId),
@@ -581,30 +565,29 @@ export function ThreadDetailView() {
},
[openSecondaryPanelDiffFile, openWorkspaceFile],
);
- const threadAppsById = useMemo(() => {
+ const appsById = useMemo(() => {
const entries = new Map(
- (threadAppsQuery.data ?? []).map((app) => [app.id, app]),
+ (appsQuery.data ?? []).map((app) => [app.applicationId, app]),
);
return entries;
- }, [threadAppsQuery.data]);
+ }, [appsQuery.data]);
const fileTabs = useMemo(() => {
const filenameOf = (path: string) => path.split("/").at(-1) ?? path;
const tabs = orderedSecondaryFileTabs.map((tab): SecondaryPanelFileTab => {
switch (tab.kind) {
case "app": {
- const app = threadAppsById.get(tab.appId);
- const appName = app?.name ?? tab.appId;
+ const app = appsById.get(tab.applicationId);
+ const appName = app?.name ?? tab.applicationId;
return {
id: tab.id,
filename: appName,
- isActive: tab.appId === activeAppId,
- isPinned: tab.appId === STATUS_APP_ID,
+ isActive: tab.applicationId === activeAppId,
leadingVisual: app ? (
) : undefined,
statusLabel: null,
- onSelect: () => activateAppTab(tab.appId),
- onClose: () => closeAppTab(tab.appId),
+ onSelect: () => activateAppTab(tab.applicationId),
+ onClose: () => closeAppTab(tab.applicationId),
};
}
case "browser": {
@@ -683,7 +666,7 @@ export function ThreadDetailView() {
closeWorkspaceFileTab,
isNewTabActive,
orderedSecondaryFileTabs,
- threadAppsById,
+ appsById,
]);
const requestedMergeBaseBranch =
selectedMergeBaseBranch ?? environmentMergeBaseBranch;
@@ -1222,7 +1205,7 @@ export function ThreadDetailView() {
onSelect={selectFileSearchResult}
/>
) : activeAppId ? (
-
+
) : activeWorkspaceFilePath ? (
{
expect(result.current.activeSecondaryPanel).toBe("git-diff");
expect(result.current.selectedSecondaryPanel).toBe("git-diff");
+ expect(result.current.isSecondaryPanelOpen).toBe(true);
expect(result.current.fixedPanelTabsState.secondary.isOpen).toBe(true);
expect(result.current.fixedPanelTabsState.secondary.activeTabId).toBe(
"git-diff",
@@ -130,6 +139,7 @@ describe("thread secondary panel selection", () => {
expect(result.current.activeSecondaryPanel).toBeNull();
expect(result.current.selectedSecondaryPanel).toBe("git-diff");
+ expect(result.current.isSecondaryPanelOpen).toBe(false);
expect(result.current.fixedPanelTabsState.secondary.isOpen).toBe(false);
expect(result.current.fixedPanelTabsState.secondary.activeTabId).toBe(
"git-diff",
@@ -144,9 +154,81 @@ describe("thread secondary panel selection", () => {
expect(result.current.activeSecondaryPanel).toBe("git-diff");
expect(result.current.selectedSecondaryPanel).toBe("git-diff");
+ expect(result.current.isSecondaryPanelOpen).toBe(true);
expect(result.current.fixedPanelTabsState.secondary.isOpen).toBe(true);
});
+ it("restores each thread's remembered panel-open state and hydrates it from storage", () => {
+ const threadA = "thr-panel-open-a";
+ const threadB = "thr-panel-open-b";
+ const { rerender, result, unmount } = renderHook(
+ (props: SelectionHookProps) => useSelectionHarness(props),
+ {
+ initialProps: { threadId: threadA },
+ wrapper: createTestWrapper({
+ initialEntries: [`/projects/proj_test/threads/${threadA}`],
+ }),
+ },
+ );
+
+ act(() => {
+ result.current.setThreadSecondaryPanel("thread-info");
+ });
+
+ expect(result.current.isSecondaryPanelOpen).toBe(true);
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: threadA }),
+ ),
+ ).toBe("true");
+
+ rerender({ threadId: threadB });
+
+ expect(result.current.isSecondaryPanelOpen).toBe(false);
+ expect(result.current.activeSecondaryPanel).toBeNull();
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: threadB }),
+ ),
+ ).toBeNull();
+
+ act(() => {
+ result.current.toggleThreadSecondaryPanel();
+ });
+ expect(result.current.isSecondaryPanelOpen).toBe(true);
+
+ act(() => {
+ result.current.toggleThreadSecondaryPanel();
+ });
+
+ expect(result.current.isSecondaryPanelOpen).toBe(false);
+ expect(
+ window.localStorage.getItem(
+ getThreadSecondaryPanelOpenStorageKey({ threadId: threadB }),
+ ),
+ ).toBe("false");
+
+ rerender({ threadId: threadA });
+
+ expect(result.current.isSecondaryPanelOpen).toBe(true);
+ expect(result.current.activeSecondaryPanel).toBe("thread-info");
+
+ unmount();
+
+ const { result: reloadedResult } = renderHook(
+ (props: SelectionHookProps) => useSelectionHarness(props),
+ {
+ initialProps: { threadId: threadA },
+ wrapper: createTestWrapper({
+ initialEntries: [`/projects/proj_test/threads/${threadA}`],
+ }),
+ },
+ );
+
+ expect(reloadedResult.current.isSecondaryPanelOpen).toBe(true);
+ expect(reloadedResult.current.activeSecondaryPanel).toBe("thread-info");
+ });
+
it("consumes a URL override without rewriting another thread preference", async () => {
const urlThreadId = "thr-url-source";
const otherThreadId = "thr-url-other";
diff --git a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
index 7eeae05c4..2279ef360 100644
--- a/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
+++ b/apps/app/src/views/thread-detail/threadSecondaryPanelSelection.ts
@@ -1,8 +1,10 @@
import { useCallback } from "react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { getThreadSecondaryPanelOpenAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms";
import {
useCloseFixedSecondaryPanel,
+ useOpenFixedSecondaryPanel,
useSetFixedSecondaryPanelTab,
- useToggleFixedSecondaryPanel,
} from "@/lib/fixed-panel-tabs";
import type {
FixedPanelTab,
@@ -25,7 +27,7 @@ interface GetSelectedThreadSecondaryPanelArgs {
}
interface GetActiveThreadSecondaryPanelArgs {
- fixedPanelTabsState: FixedPanelTabsState;
+ isSecondaryPanelOpen: boolean;
selectedSecondaryPanel: ActiveThreadSecondaryPanel;
}
@@ -72,10 +74,10 @@ export function getSelectedThreadSecondaryPanel({
}
export function getActiveThreadSecondaryPanel({
- fixedPanelTabsState,
+ isSecondaryPanelOpen,
selectedSecondaryPanel,
}: GetActiveThreadSecondaryPanelArgs): ActiveThreadSecondaryPanel {
- return fixedPanelTabsState.secondary.isOpen ? selectedSecondaryPanel : null;
+ return isSecondaryPanelOpen ? selectedSecondaryPanel : null;
}
export function useSetThreadSecondaryPanelSelection(
@@ -83,21 +85,52 @@ export function useSetThreadSecondaryPanelSelection(
): NullableSecondaryPanelSetter {
const closeFixedSecondaryPanel = useCloseFixedSecondaryPanel(threadId);
const setFixedSecondaryPanelTab = useSetFixedSecondaryPanelTab(threadId);
+ const setThreadSecondaryPanelOpen = useSetAtom(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
return useCallback(
(panel) => {
if (panel === null) {
+ setThreadSecondaryPanelOpen(false);
closeFixedSecondaryPanel();
return;
}
+ setThreadSecondaryPanelOpen(true);
setFixedSecondaryPanelTab(panel);
},
- [closeFixedSecondaryPanel, setFixedSecondaryPanelTab],
+ [
+ closeFixedSecondaryPanel,
+ setFixedSecondaryPanelTab,
+ setThreadSecondaryPanelOpen,
+ ],
);
}
export function useToggleThreadSecondaryPanelSelection(
threadId: ThreadSecondaryPanelThreadId,
): () => void {
- return useToggleFixedSecondaryPanel(threadId);
+ const closeFixedSecondaryPanel = useCloseFixedSecondaryPanel(threadId);
+ const openFixedSecondaryPanel = useOpenFixedSecondaryPanel(threadId);
+ const isSecondaryPanelOpen = useAtomValue(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
+ const setThreadSecondaryPanelOpen = useSetAtom(
+ getThreadSecondaryPanelOpenAtom(threadId),
+ );
+
+ return useCallback(() => {
+ if (isSecondaryPanelOpen) {
+ setThreadSecondaryPanelOpen(false);
+ closeFixedSecondaryPanel();
+ return;
+ }
+ setThreadSecondaryPanelOpen(true);
+ openFixedSecondaryPanel();
+ }, [
+ closeFixedSecondaryPanel,
+ isSecondaryPanelOpen,
+ openFixedSecondaryPanel,
+ setThreadSecondaryPanelOpen,
+ ]);
}
diff --git a/apps/cli/src/__tests__/command-output.test.ts b/apps/cli/src/__tests__/command-output.test.ts
index 5a0d7b157..9c43449c1 100644
--- a/apps/cli/src/__tests__/command-output.test.ts
+++ b/apps/cli/src/__tests__/command-output.test.ts
@@ -445,9 +445,10 @@ describe("CLI command output contracts", () => {
const output = collectLogPayloads(vi.mocked(console.log)).join("\n");
expect(output.trim().length).toBeGreaterThan(0);
expect(output).toContain("Apps");
- expect(output).toContain("apps/status/data/state.json");
+ expect(output).toContain("/apps//");
expect(output).toContain("window.bb.data");
- expect(output).toContain("bb app list --self");
+ expect(output).toContain("bb app current --json");
+ expect(output).toContain("Do not start a web server");
});
it("bb guide unknown chapter lists styling in available chapters", async () => {
@@ -1028,9 +1029,7 @@ describe("CLI command output contracts", () => {
});
expect(
writeErr.mock.calls.map((callArgs) => String(callArgs[0] ?? "")).join(""),
- ).toContain(
- "error: unknown option '--permission-mode'",
- );
+ ).toContain("error: unknown option '--permission-mode'");
});
it("bb manager list reports when no managers are hired", async () => {
@@ -1151,23 +1150,22 @@ describe("CLI command output contracts", () => {
});
it("bb app list renders resolved app summaries", async () => {
- vi.stubEnv("BB_THREAD_ID", "thr_current");
const apps = [
{
- id: "status",
- name: "Status",
+ applicationId: "status",
+ name: "Project Status",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
},
{
- id: "demo",
+ applicationId: "demo",
name: "Demo",
entry: { path: "readme.md", kind: "md" },
capabilities: [],
icon: {
kind: "logo",
- url: "/api/v1/threads/thr_current/apps/demo/icon",
+ url: "/api/v1/apps/demo/icon",
},
},
];
@@ -1176,12 +1174,8 @@ describe("CLI command output contracts", () => {
asServerClient({
api: {
v1: {
- threads: {
- ":id": {
- apps: {
- $get: get,
- },
- },
+ apps: {
+ $get: get,
},
},
},
@@ -1192,90 +1186,112 @@ describe("CLI command output contracts", () => {
registerAppCommands(program, () => "http://server"),
);
- expect(get).toHaveBeenCalledWith({ param: { id: "thr_current" } });
+ expect(get).toHaveBeenCalledWith();
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
- "ID Name Entry Capabilities Icon\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\nstatus Status html:index.html data,message ListTodo\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\ndemo Demo md:readme.md - logo",
+ "Application ID Name Entry Capabilities Icon\n-------------------------------- ------------------------ ------------------------ ------------------------ ------------------\nstatus Project Status html:index.html data,message ListTodo\n-------------------------------- ------------------------ ------------------------ ------------------------ ------------------\ndemo Demo md:readme.md - logo",
]);
});
- it("bb app new targets the current thread and posts the selected template", async () => {
- vi.stubEnv("BB_THREAD_ID", "thr_current");
+ it("bb app new derives a slug from display name", async () => {
const created = {
- id: "demo",
- name: "Demo",
+ applicationId: "review-board",
+ name: "Review Board",
entry: { path: "index.html", kind: "html" },
capabilities: ["data", "message"],
icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/review-board",
+ appDataPath: "/tmp/bb-data/apps/review-board/data",
};
const post = vi.fn(async () => created);
createClientMock.mockReturnValue(
asServerClient({
api: {
v1: {
- threads: {
- ":id": {
- apps: {
- $post: post,
- },
- },
+ apps: {
+ $post: post,
},
},
},
}),
);
- await runCommand(
- ["app", "new", "demo", "--template", "status"],
- (program) => registerAppCommands(program, () => "http://server"),
+ await runCommand(["app", "new", "--name", "Review Board"], (program) =>
+ registerAppCommands(program, () => "http://server"),
);
expect(post).toHaveBeenCalledWith({
- param: { id: "thr_current" },
- json: { id: "demo", name: "demo", template: "status" },
+ json: { applicationId: "review-board", name: "Review Board" },
});
expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
- "App created: demo",
- " Name: Demo",
- " Entry: html:index.html",
- " Capabilities: data,message",
- " Icon: ListTodo",
+ "Application ID: review-board",
+ " Name: Review Board",
+ " Entry: html:index.html",
+ " Capabilities: data,message",
+ " Icon: ListTodo",
+ " App root: /tmp/bb-data/apps/review-board",
+ " App data path: /tmp/bb-data/apps/review-board/data",
]);
});
- it("bb app new derives a valid id from a display name", async () => {
- vi.stubEnv("BB_THREAD_ID", "thr_current");
+ it("bb app new honors an explicit slug", async () => {
const created = {
- id: "my-app",
- name: "My App",
+ applicationId: "status",
+ name: "status",
entry: { path: "index.html", kind: "html" },
- capabilities: ["data"],
- icon: { kind: "builtin", name: "GridView" },
+ capabilities: ["data", "message"],
+ icon: { kind: "builtin", name: "ListTodo" },
+ appsRootPath: "/tmp/bb-data/apps",
+ appRootPath: "/tmp/bb-data/apps/status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
};
const post = vi.fn(async () => created);
createClientMock.mockReturnValue(
asServerClient({
api: {
v1: {
- threads: {
- ":id": {
- apps: {
- $post: post,
- },
- },
+ apps: {
+ $post: post,
},
},
},
}),
);
- await runCommand(["app", "new", "My App"], (program) =>
+ await runCommand(["app", "new", "--slug", "status"], (program) =>
registerAppCommands(program, () => "http://server"),
);
expect(post).toHaveBeenCalledWith({
- param: { id: "thr_current" },
- json: { id: "my-app", name: "My App", template: "blank" },
+ json: { applicationId: "status" },
});
+ expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
+ "Application ID: status",
+ " Name: status",
+ " Entry: html:index.html",
+ " Capabilities: data,message",
+ " Icon: ListTodo",
+ " App root: /tmp/bb-data/apps/status",
+ " App data path: /tmp/bb-data/apps/status/data",
+ ]);
+ });
+
+ it("bb app current renders runtime app paths", async () => {
+ vi.stubEnv("BB_APP_ID", "current");
+ vi.stubEnv("BB_APP_ROOT", "/tmp/bb-data/apps/current");
+ vi.stubEnv("BB_APP_DATA_PATH", "/tmp/bb-data/apps/current/data");
+ vi.stubEnv("BB_APPS_ROOT", "/tmp/bb-data/apps");
+
+ await runCommand(["app", "current"], (program) =>
+ registerAppCommands(program, () => "http://server"),
+ );
+
+ expect(collectLogPayloads(vi.mocked(console.log))).toEqual([
+ "Application ID: current",
+ " App root: /tmp/bb-data/apps/current",
+ " App data path: /tmp/bb-data/apps/current/data",
+ " Apps root: /tmp/bb-data/apps",
+ ]);
});
it("bb manager status includes managed child threads", async () => {
diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts
index 28552c8e8..76d6d9916 100644
--- a/apps/cli/src/commands/app.ts
+++ b/apps/cli/src/commands/app.ts
@@ -1,101 +1,117 @@
+import { Buffer } from "node:buffer";
+import { readFile } from "node:fs/promises";
import { Command } from "commander";
-import { appIdSchema, type AppId } from "@bb/domain";
+import {
+ applicationIdSchema,
+ deriveApplicationIdFromName,
+ jsonValueSchema,
+} from "@bb/domain";
+import type { ApplicationId, JsonValue } from "@bb/domain";
import type {
+ AppDataEntry,
+ AppDataListResponse,
AppDetail,
AppIcon,
AppSummary,
- AppTemplate,
- CreateThreadAppRequest,
+ CreateAppRequest,
} from "@bb/server-contract";
-import { appTemplateSchema } from "@bb/server-contract";
import { action } from "../action.js";
import { createClient, unwrap } from "../client.js";
import { renderBorderlessTable } from "../table.js";
-import {
- confirmDestructiveAction,
- outputJson,
- printContextLabel,
- requireThreadIdWithLabelOrSelf,
-} from "./helpers.js";
+import { confirmDestructiveAction, outputJson } from "./helpers.js";
type ResolveServerUrl = () => string;
-interface AppThreadCommandOptions {
+interface AppJsonOptions {
json?: boolean;
- self?: boolean;
}
-interface AppNewCommandOptions extends AppThreadCommandOptions {
+interface AppNewCommandOptions extends AppJsonOptions {
id?: string;
- template?: string;
+ name?: string;
+ slug?: string;
}
-interface AppRemoveCommandOptions extends AppThreadCommandOptions {
+interface AppDeleteCommandOptions extends AppJsonOptions {
yes?: boolean;
}
-interface ResolveAppCommandThreadArgs {
- options: AppThreadCommandOptions;
- threadId: string | undefined;
-}
+interface AppDataListCommandOptions extends AppJsonOptions {}
-interface AppOpenPayload {
- app: AppDetail;
- threadId: string;
- url: string;
+interface AppDataWriteCommandOptions {
+ file?: string;
+ stdin?: boolean;
}
-interface ResolveNewAppIdArgs {
- id: string | undefined;
- name: string;
+interface AppMessageCommandOptions {
+ json: string;
+ targetThread: string;
}
-function parseAppTemplate(value: string | undefined): AppTemplate {
- const parsed = appTemplateSchema.safeParse(value ?? "blank");
- if (!parsed.success) {
- throw new Error("Invalid app template. Expected 'blank' or 'status'.");
- }
- return parsed.data;
+interface CurrentAppRuntimeContext {
+ applicationId: ApplicationId;
+ appRootPath: string;
+ appDataPath: string;
+ appsRootPath: string;
}
-function resolveAppCommandThread(args: ResolveAppCommandThreadArgs): string {
- const resolved = requireThreadIdWithLabelOrSelf(
- args.threadId,
- args.options,
+function parseApplicationId(value: string): ApplicationId {
+ const parsed = applicationIdSchema.safeParse(value);
+ if (parsed.success) {
+ return parsed.data;
+ }
+ throw new Error(
+ "Invalid applicationId. Expected a lowercase slug like status or review-board.",
);
- printContextLabel(resolved, "Thread", "BB_THREAD_ID", args.options);
- return resolved.id;
}
-function slugifyAppName(name: string): string {
- return name
- .trim()
- .toLowerCase()
- .replace(/[^a-z0-9_-]+/gu, "-")
- .replace(/-+/gu, "-")
- .replace(/^-|-$/gu, "");
+function deriveApplicationIdFromNameForCli(name: string): ApplicationId {
+ try {
+ return deriveApplicationIdFromName(name);
+ } catch {
+ throw new Error("App name cannot be converted to a valid applicationId.");
+ }
}
-function resolveNewAppId(args: ResolveNewAppIdArgs): AppId {
- const candidate = args.id ?? slugifyAppName(args.name);
- const parsed = appIdSchema.safeParse(candidate);
- if (parsed.success) {
- return parsed.data;
+function resolveNewApplicationId(opts: AppNewCommandOptions): ApplicationId {
+ if (
+ opts.id !== undefined &&
+ opts.slug !== undefined &&
+ opts.id !== opts.slug
+ ) {
+ throw new Error("Use either --id or --slug, not both.");
}
- if (args.id !== undefined) {
- throw new Error(
- "Invalid app id. Use letters, numbers, underscores, or hyphens.",
- );
+ const explicitApplicationId = opts.id ?? opts.slug;
+ if (explicitApplicationId !== undefined) {
+ return parseApplicationId(explicitApplicationId);
}
- throw new Error(
- `Could not derive a valid app id from "${args.name}". Pass --id with letters, numbers, underscores, or hyphens.`,
- );
+ if (opts.name !== undefined) {
+ return deriveApplicationIdFromNameForCli(opts.name);
+ }
+ throw new Error("Provide --id , --slug , or --name .");
+}
+
+function buildCreateAppRequest(opts: AppNewCommandOptions): CreateAppRequest {
+ const applicationId = resolveNewApplicationId(opts);
+ const request: CreateAppRequest = { applicationId };
+ if (opts.name !== undefined) {
+ request.name = opts.name;
+ }
+ return request;
}
-function appUrl(baseUrl: string, threadId: string, appId: string): string {
- return `${baseUrl.replace(/\/$/u, "")}/api/v1/threads/${encodeURIComponent(
- threadId,
- )}/apps/${encodeURIComponent(appId)}/`;
+function encodePathSegments(value: string): string {
+ return value.split("/").map(encodeURIComponent).join("/");
+}
+
+function appDataUrl(
+ baseUrl: string,
+ applicationId: ApplicationId,
+ dataPath: string,
+): string {
+ return `${baseUrl.replace(/\/$/u, "")}/api/v1/apps/${encodeURIComponent(
+ applicationId,
+ )}/data/${encodePathSegments(dataPath)}`;
}
function formatIcon(icon: AppIcon): string {
@@ -110,12 +126,12 @@ function printAppsTable(apps: AppSummary[]): void {
console.log(
renderBorderlessTable(
{
- head: ["ID", "Name", "Entry", "Capabilities", "Icon"],
- colWidths: [24, 24, 24, 24, 18],
+ head: ["Application ID", "Name", "Entry", "Capabilities", "Icon"],
+ colWidths: [32, 24, 24, 24, 18],
trimTrailingWhitespace: true,
},
apps.map((app) => [
- app.id,
+ app.applicationId,
app.name,
`${app.entry.kind}:${app.entry.path}`,
app.capabilities.join(",") || "-",
@@ -126,157 +142,300 @@ function printAppsTable(apps: AppSummary[]): void {
}
function printAppDetail(app: AppDetail): void {
- console.log(`App created: ${app.id}`);
- console.log(` Name: ${app.name}`);
- console.log(` Entry: ${app.entry.kind}:${app.entry.path}`);
- console.log(` Capabilities: ${app.capabilities.join(",") || "-"}`);
- console.log(` Icon: ${formatIcon(app.icon)}`);
+ console.log(`Application ID: ${app.applicationId}`);
+ console.log(` Name: ${app.name}`);
+ console.log(` Entry: ${app.entry.kind}:${app.entry.path}`);
+ console.log(` Capabilities: ${app.capabilities.join(",") || "-"}`);
+ console.log(` Icon: ${formatIcon(app.icon)}`);
+ console.log(` App root: ${app.appRootPath}`);
+ console.log(` App data path: ${app.appDataPath}`);
+}
+
+function printDataEntries(entries: AppDataEntry[]): void {
+ if (entries.length === 0) {
+ console.log("No app data");
+ return;
+ }
+ console.log(
+ renderBorderlessTable(
+ {
+ head: ["Path", "Size", "Version"],
+ colWidths: [36, 10, 64],
+ trimTrailingWhitespace: true,
+ },
+ entries.map((entry) => [
+ entry.path,
+ String(entry.sizeBytes),
+ entry.version,
+ ]),
+ ),
+ );
+}
+
+function parseJsonValueInput(value: string): JsonValue {
+ return jsonValueSchema.parse(JSON.parse(value));
+}
+
+async function readStdin(): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+async function readWriteValue(
+ opts: AppDataWriteCommandOptions,
+): Promise {
+ if (opts.file && opts.stdin) {
+ throw new Error("Use either --file or --stdin, not both.");
+ }
+ if (!opts.file && !opts.stdin) {
+ throw new Error("Provide --file or --stdin.");
+ }
+ const content =
+ opts.file !== undefined
+ ? await readFile(opts.file, "utf8")
+ : await readStdin();
+ return parseJsonValueInput(content);
+}
+
+function readCurrentAppRuntimeContext(): CurrentAppRuntimeContext {
+ const applicationId = process.env.BB_APP_ID;
+ const appRootPath = process.env.BB_APP_ROOT;
+ const appDataPath = process.env.BB_APP_DATA_PATH;
+ const appsRootPath = process.env.BB_APPS_ROOT;
+ if (!applicationId || !appRootPath || !appDataPath || !appsRootPath) {
+ throw new Error("current_app_unavailable");
+ }
+ return {
+ applicationId: parseApplicationId(applicationId),
+ appRootPath,
+ appDataPath,
+ appsRootPath,
+ };
}
export function registerAppCommands(
program: Command,
getUrl: ResolveServerUrl,
): void {
- const app = program.command("app").description("Manage thread apps");
+ const app = program.command("app").description("Manage global apps");
+
+ app
+ .command("list")
+ .description("List global apps")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppJsonOptions) => {
+ const client = createClient(getUrl());
+ const apps = await unwrap(client.api.v1.apps.$get());
+ if (outputJson(opts, apps)) return;
+ printAppsTable(apps);
+ }),
+ );
+
+ app
+ .command("new")
+ .description("Create a global app")
+ .option("--id ", "Application slug id")
+ .option("--slug ", "Alias for --id")
+ .option(
+ "--name ",
+ "Human display name; derives slug when id is omitted",
+ )
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppNewCommandOptions) => {
+ const client = createClient(getUrl());
+ const created = await unwrap(
+ client.api.v1.apps.$post({
+ json: buildCreateAppRequest(opts),
+ }),
+ );
+ if (outputJson(opts, created)) return;
+ printAppDetail(created);
+ }),
+ );
app
- .command("new [threadId]")
- .description("Create a new app in a thread")
- .option("--id ", "App id. Defaults to a slug derived from name.")
- .option("--template ", "App template: blank or status", "blank")
- .option("--self", "Target BB_THREAD_ID explicitly")
+ .command("current")
+ .description("Show current app runtime context")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (opts: AppJsonOptions) => {
+ const current = readCurrentAppRuntimeContext();
+ if (outputJson(opts, current)) return;
+ console.log(`Application ID: ${current.applicationId}`);
+ console.log(` App root: ${current.appRootPath}`);
+ console.log(` App data path: ${current.appDataPath}`);
+ console.log(` Apps root: ${current.appsRootPath}`);
+ }),
+ );
+
+ app
+ .command("show ")
+ .description("Show a global app")
+ .option("--json", "Print machine-readable JSON output")
+ .action(
+ action(async (rawApplicationId: string, opts: AppJsonOptions) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const client = createClient(getUrl());
+ const detail = await unwrap(
+ client.api.v1.apps[":applicationId"].$get({
+ param: { applicationId },
+ }),
+ );
+ if (outputJson(opts, detail)) return;
+ printAppDetail(detail);
+ }),
+ );
+
+ app
+ .command("delete ")
+ .description("Delete a global app")
+ .option("--yes", "Skip the confirmation prompt")
.option("--json", "Print machine-readable JSON output")
.action(
action(
- async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppNewCommandOptions,
- ) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
- const template = parseAppTemplate(opts.template);
+ async (rawApplicationId: string, opts: AppDeleteCommandOptions) => {
+ const applicationId = parseApplicationId(rawApplicationId);
const client = createClient(getUrl());
- const request: CreateThreadAppRequest = {
- id: resolveNewAppId({ id: opts.id, name }),
- name,
- template,
- };
- const created = await unwrap(
- client.api.v1.threads[":id"].apps.$post({
- param: { id: threadId },
- json: request,
+ const appDetail = await unwrap(
+ client.api.v1.apps[":applicationId"].$get({
+ param: { applicationId },
}),
);
- if (outputJson(opts, created)) return;
- printAppDetail(created);
+ if (!opts.yes) {
+ const confirmed = await confirmDestructiveAction(
+ `Delete app "${appDetail.name}" (${applicationId})? This cannot be undone.`,
+ );
+ if (!confirmed) {
+ console.log(`App ${applicationId} deletion cancelled`);
+ return;
+ }
+ }
+ await unwrap<{ ok: true }>(
+ client.api.v1.apps[":applicationId"].$delete({
+ param: { applicationId },
+ }),
+ );
+ const payload = { ok: true, applicationId };
+ if (outputJson(opts, payload)) return;
+ console.log(`App ${applicationId} deleted`);
},
),
);
- app
- .command("list [threadId]")
- .description("List apps in a thread")
- .option("--self", "Target BB_THREAD_ID explicitly")
+ const data = app.command("data").description("Manage global app data");
+
+ data
+ .command("list [path]")
+ .description("List app data entries")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
- threadIdArg: string | undefined,
- opts: AppThreadCommandOptions,
+ rawApplicationId: string,
+ prefix: string | undefined,
+ opts: AppDataListCommandOptions,
) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
+ const applicationId = parseApplicationId(rawApplicationId);
const client = createClient(getUrl());
- const apps = await unwrap(
- client.api.v1.threads[":id"].apps.$get({
- param: { id: threadId },
+ const response = await unwrap(
+ client.api.v1.apps[":applicationId"].data.$get({
+ param: { applicationId },
+ query: prefix ? { prefix } : {},
}),
);
- if (outputJson(opts, apps)) return;
- printAppsTable(apps);
+ if (outputJson(opts, response.entries)) return;
+ printDataEntries(response.entries);
},
),
);
- app
- .command("open [threadId]")
- .description("Print an app URL")
- .option("--self", "Target BB_THREAD_ID explicitly")
- .option("--json", "Print machine-readable JSON output")
+ data
+ .command("read ")
+ .description("Read an app data JSON value")
+ .action(
+ action(async (rawApplicationId: string, dataPath: string) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const response = await unwrap(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ }),
+ );
+ console.log(JSON.stringify(response.value, null, 2));
+ }),
+ );
+
+ data
+ .command("write ")
+ .description("Write an app data JSON value")
+ .option("--file ", "Read JSON value from a local file")
+ .option("--stdin", "Read JSON value from stdin")
.action(
action(
async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppThreadCommandOptions,
+ rawApplicationId: string,
+ dataPath: string,
+ opts: AppDataWriteCommandOptions,
) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
- const baseUrl = getUrl();
- const client = createClient(baseUrl);
- const appDetail = await unwrap(
- client.api.v1.threads[":id"].apps[":appId"].$get({
- param: { id: threadId, appId: name },
+ const applicationId = parseApplicationId(rawApplicationId);
+ const value = await readWriteValue(opts);
+ await unwrap(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "PUT",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ value }),
}),
);
- const payload: AppOpenPayload = {
- threadId,
- app: appDetail,
- url: appUrl(baseUrl, threadId, appDetail.id),
- };
- if (outputJson(opts, payload)) return;
- console.log(payload.url);
+ console.log(`Wrote ${dataPath}`);
},
),
);
+ data
+ .command("delete ")
+ .description("Delete an app data value")
+ .action(
+ action(async (rawApplicationId: string, dataPath: string) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ await unwrap<{ ok: true }>(
+ fetch(appDataUrl(getUrl(), applicationId, dataPath), {
+ method: "DELETE",
+ headers: { Accept: "application/json" },
+ }),
+ );
+ console.log(`Deleted ${dataPath}`);
+ }),
+ );
+
app
- .command("rm [threadId]")
- .description("Remove an app from a thread")
- .option("--yes", "Skip the confirmation prompt")
- .option("--self", "Target BB_THREAD_ID explicitly")
- .option("--json", "Print machine-readable JSON output")
+ .command("message ")
+ .description("Send an app message to a target thread")
+ .requiredOption("--target-thread ", "Target thread")
+ .requiredOption("--json ", "JSON payload to send")
.action(
action(
- async (
- name: string,
- threadIdArg: string | undefined,
- opts: AppRemoveCommandOptions,
- ) => {
- const threadId = resolveAppCommandThread({
- threadId: threadIdArg,
- options: opts,
- });
+ async (rawApplicationId: string, opts: AppMessageCommandOptions) => {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const payload = parseJsonValueInput(opts.json);
const client = createClient(getUrl());
- const appDetail = await unwrap(
- client.api.v1.threads[":id"].apps[":appId"].$get({
- param: { id: threadId, appId: name },
+ await unwrap(
+ client.api.v1.apps[":applicationId"].message.$post({
+ param: { applicationId },
+ json: {
+ payload,
+ targetThreadId: opts.targetThread,
+ },
}),
);
- if (!opts.yes) {
- const confirmed = await confirmDestructiveAction(
- `Remove app "${appDetail.name}" from thread ${threadId}? This cannot be undone.`,
- );
- if (!confirmed) {
- console.log(`App ${name} removal cancelled`);
- return;
- }
- }
- await unwrap<{ ok: true }>(
- client.api.v1.threads[":id"].apps[":appId"].$delete({
- param: { id: threadId, appId: name },
- }),
- );
- const payload = { ok: true, threadId, appId: name };
- if (outputJson(opts, payload)) return;
- console.log(`App ${name} removed`);
+ console.log(`Message sent to ${opts.targetThread}`);
},
),
);
diff --git a/apps/desktop/src/desktop-browser-view.ts b/apps/desktop/src/desktop-browser-view.ts
index 5ec844ded..303fdf0a0 100644
--- a/apps/desktop/src/desktop-browser-view.ts
+++ b/apps/desktop/src/desktop-browser-view.ts
@@ -7,11 +7,13 @@ import {
import {
BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH,
BB_DESKTOP_BROWSER_MAX_URL_LENGTH,
+ clampBbDesktopBrowserViewBounds,
type BbDesktopBrowserAttachRequest,
type BbDesktopBrowserNavigateRequest,
type BbDesktopBrowserSetBoundsRequest,
type BbDesktopBrowserSetVisibleRequest,
type BbDesktopBrowserState,
+ type BbDesktopBrowserViewBounds,
} from "@bb/server-contract";
import {
BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL,
@@ -65,6 +67,17 @@ interface HostScopedTabArgs {
tabId: string;
}
+interface ClampBoundsToHostWindowArgs {
+ bounds: BbDesktopBrowserViewBounds;
+ hostWindow: BrowserWindow;
+}
+
+interface SetEntryBoundsArgs {
+ bounds: BbDesktopBrowserViewBounds;
+ entry: BrowserViewEntry;
+ hostWindow: BrowserWindow;
+}
+
export interface DesktopBrowserViewManager {
attach(args: HostScopedRequestArgs): void;
detach(args: HostScopedTabArgs): void;
@@ -99,6 +112,28 @@ function send(hostWindow: BrowserWindow, channel: string, payload: unknown): voi
hostWindow.webContents.send(channel, payload);
}
+function clampBoundsToHostWindow(
+ args: ClampBoundsToHostWindowArgs,
+): BbDesktopBrowserViewBounds {
+ const contentBounds = args.hostWindow.getContentBounds();
+ return clampBbDesktopBrowserViewBounds({
+ bounds: args.bounds,
+ viewport: {
+ width: contentBounds.width,
+ height: contentBounds.height,
+ },
+ });
+}
+
+function setEntryBounds(args: SetEntryBoundsArgs): void {
+ args.entry.view.setBounds(
+ clampBoundsToHostWindow({
+ bounds: args.bounds,
+ hostWindow: args.hostWindow,
+ }),
+ );
+}
+
function buildBrowserState(
tabId: string,
entry: BrowserViewEntry,
@@ -304,7 +339,7 @@ export function createDesktopBrowserViewManager(
attach({ hostWindow, request }) {
const key = browserViewKey(hostWindow, request.tabId);
const entry = entries.get(key) ?? createEntry(hostWindow, request.tabId);
- entry.view.setBounds(request.bounds);
+ setEntryBounds({ hostWindow, entry, bounds: request.bounds });
entry.view.setVisible(request.visible);
loadIfNeeded(entry, request.url);
pushState(hostWindow, request.tabId);
@@ -343,7 +378,7 @@ export function createDesktopBrowserViewManager(
},
setBounds({ hostWindow, request }) {
withEntry({ hostWindow, tabId: request.tabId }, (entry) => {
- entry.view.setBounds(request.bounds);
+ setEntryBounds({ hostWindow, entry, bounds: request.bounds });
});
},
setVisible({ hostWindow, request }) {
diff --git a/apps/desktop/test/desktop-browser-bounds.test.ts b/apps/desktop/test/desktop-browser-bounds.test.ts
new file mode 100644
index 000000000..3109aff61
--- /dev/null
+++ b/apps/desktop/test/desktop-browser-bounds.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampBbDesktopBrowserViewBounds,
+ type BbDesktopBrowserViewBounds,
+ type BbDesktopBrowserViewportBounds,
+} from "@bb/server-contract";
+
+interface BrowserBoundsClampTestCase {
+ bounds: BbDesktopBrowserViewBounds;
+ expected: BbDesktopBrowserViewBounds;
+ label: string;
+ viewport: BbDesktopBrowserViewportBounds;
+}
+
+const browserBoundsClampTestCases: BrowserBoundsClampTestCase[] = [
+ {
+ label: "anchors the left edge and trims overflow at the right and bottom",
+ bounds: { x: 180, y: 48, width: 400, height: 420 },
+ viewport: { width: 500, height: 360 },
+ expected: { x: 180, y: 48, width: 320, height: 312 },
+ },
+ {
+ label: "clamps negative origins to the host content edge",
+ bounds: { x: -24, y: -10, width: 200, height: 120 },
+ viewport: { width: 500, height: 360 },
+ expected: { x: 0, y: 0, width: 176, height: 110 },
+ },
+ {
+ label: "collapses bounds that start outside the host content area",
+ bounds: { x: 640, y: 400, width: 120, height: 90 },
+ viewport: { width: 500, height: 360 },
+ expected: { x: 500, y: 360, width: 0, height: 0 },
+ },
+];
+
+describe("desktop browser bounds containment", () => {
+ it.each(browserBoundsClampTestCases)("$label", (testCase) => {
+ expect(
+ clampBbDesktopBrowserViewBounds({
+ bounds: testCase.bounds,
+ viewport: testCase.viewport,
+ }),
+ ).toEqual(testCase.expected);
+ });
+});
diff --git a/apps/host-daemon/src/app-data-change-reporter.test.ts b/apps/host-daemon/src/app-data-change-reporter.test.ts
index e97fcbf98..19fa7da61 100644
--- a/apps/host-daemon/src/app-data-change-reporter.test.ts
+++ b/apps/host-daemon/src/app-data-change-reporter.test.ts
@@ -39,13 +39,8 @@ afterEach(async () => {
describe("AppDataChangeReporter", () => {
it("posts changed app data values and suppresses duplicate versions", async () => {
const rootPath = await makeTempDir("bb-app-data-reporter-");
- const statePath = path.join(
- rootPath,
- "apps",
- "status",
- "data",
- "state.json",
- );
+ const appDataPath = path.join(rootPath, "apps", "status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
@@ -55,24 +50,24 @@ describe("AppDataChangeReporter", () => {
postAppDataResync: async () => undefined,
});
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "status", appDataPath }],
+ });
await writeJsonFile(statePath, { workers: [] });
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "status",
path: "state.json",
deleted: false,
value: { workers: [] },
@@ -82,13 +77,8 @@ describe("AppDataChangeReporter", () => {
it("posts deleted events after observed files are removed", async () => {
const rootPath = await makeTempDir("bb-app-data-reporter-delete-");
- const statePath = path.join(
- rootPath,
- "apps",
- "status",
- "data",
- "state.json",
- );
+ const appDataPath = path.join(rootPath, "apps", "status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
@@ -99,24 +89,24 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "status", appDataPath }],
+ });
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
await fs.rm(statePath);
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
- expect(posted).toHaveLength(2);
- expect(posted[1]).toEqual({
- appId: "status",
- threadId: "thr_one",
+ expect(posted).toHaveLength(1);
+ expect(posted[0]).toEqual({
+ applicationId: "status",
path: "state.json",
deleted: true,
value: null,
@@ -126,15 +116,10 @@ describe("AppDataChangeReporter", () => {
it("re-primes tracked threads and posts resync hints after reconnect", async () => {
const rootPath = await makeTempDir("bb-app-data-reporter-reprime-");
- const statePath = path.join(
- rootPath,
- "apps",
- "status",
- "data",
- "state.json",
- );
+ const appDataPath = path.join(rootPath, "apps", "status", "data");
+ const statePath = path.join(appDataPath, "state.json");
const posted: HostDaemonAppDataChangePayload[] = [];
- const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async (payload) => {
@@ -146,31 +131,28 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "status", appDataPath }],
});
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
- expect(resyncs).toEqual([{ appId: "status", threadId: "thr_one" }]);
+ expect(resyncs).toEqual([{ applicationId: "status" }]);
expect(posted).toHaveLength(0);
await writeJsonFile(statePath, { workers: [{ id: "worker-1" }] });
await reporter.observe({
- appId: "status",
+ applicationId: "status",
+ appDataPath,
path: "state.json",
- threadId: "thr_one",
- threadStoragePath: rootPath,
});
expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "status",
path: "state.json",
deleted: false,
value: { workers: [{ id: "worker-1" }] },
@@ -179,14 +161,9 @@ describe("AppDataChangeReporter", () => {
it("posts resync hints for apps whose data disappeared while disconnected", async () => {
const rootPath = await makeTempDir("bb-app-data-reporter-delete-resync-");
- const statePath = path.join(
- rootPath,
- "apps",
- "status",
- "data",
- "state.json",
- );
- const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const appDataPath = path.join(rootPath, "apps", "status", "data");
+ const statePath = path.join(appDataPath, "state.json");
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async () => undefined,
@@ -196,22 +173,22 @@ describe("AppDataChangeReporter", () => {
});
await writeJsonFile(statePath, { workers: [] });
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "status", appDataPath }],
});
await fs.rm(statePath);
- await reporter.replaceTrackedThreads({
- targets: [{ threadId: "thr_one", threadStoragePath: rootPath }],
+ await reporter.replaceTrackedApplications({
+ targets: [{ applicationId: "status", appDataPath }],
});
expect(resyncs).toEqual([
- { appId: "status", threadId: "thr_one" },
- { appId: "status", threadId: "thr_one" },
+ { applicationId: "status" },
+ { applicationId: "status" },
]);
});
it("posts requested app data resync hints", async () => {
- const resyncs: Array<{ appId: string; threadId: string }> = [];
+ const resyncs: Array<{ applicationId: string }> = [];
const reporter = new AppDataChangeReporter({
logger: createLogger(),
postAppDataChange: async () => undefined,
@@ -221,10 +198,9 @@ describe("AppDataChangeReporter", () => {
});
await reporter.requestResync({
- appId: "status",
- threadId: "thr_one",
+ applicationId: "status",
});
- expect(resyncs).toEqual([{ appId: "status", threadId: "thr_one" }]);
+ expect(resyncs).toEqual([{ applicationId: "status" }]);
});
});
diff --git a/apps/host-daemon/src/app-data-change-reporter.ts b/apps/host-daemon/src/app-data-change-reporter.ts
index 449d6fd49..dac92e2f0 100644
--- a/apps/host-daemon/src/app-data-change-reporter.ts
+++ b/apps/host-daemon/src/app-data-change-reporter.ts
@@ -1,17 +1,18 @@
import type { HostDaemonAppDataChangePayload } from "@bb/host-daemon-contract";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
} from "@bb/domain";
import { CommandDispatchError } from "./command-dispatch-support.js";
import { runtimeErrorLogFields } from "./error-utils.js";
import type { HostDaemonLogger } from "./logger.js";
import {
- listThreadAppDataFromRoot,
- readAppDataFromRoot,
- type ThreadAppDataEntry,
+ listApplicationDataFromTargets,
+ readApplicationDataFromTarget,
+ type ApplicationDataEntry,
+ type ApplicationDataTarget,
} from "./app-data-files.js";
interface CreateAppDataChangeReporterOptions {
@@ -25,14 +26,12 @@ interface AppDataCacheEntry {
}
interface AppDataResyncPayload {
- appId: AppId;
- threadId: string;
+ applicationId: ApplicationId;
}
interface AppDataCacheKeyArgs {
- appId: AppId;
+ applicationId: ApplicationId;
path: AppDataPath;
- threadId: string;
}
interface ReportObservedDeleteArgs extends AppDataCacheKeyArgs {
@@ -48,40 +47,34 @@ interface AppDataReporterGenerationArgs {
generation: number;
}
-interface TrackedAppDataThread {
- threadId: string;
- threadStoragePath: string;
+interface ReplaceTrackedApplicationDataTargetsArgs {
+ targets: readonly ApplicationDataTarget[];
}
-interface ReplaceTrackedAppDataThreadsArgs {
- targets: readonly TrackedAppDataThread[];
-}
-
-interface ReprimeThreadArgs {
+interface ReprimeApplicationsArgs {
generation: number;
- target: TrackedAppDataThread;
+ targets: readonly ApplicationDataTarget[];
}
-interface ApplyThreadSnapshotArgs extends ReprimeThreadArgs {
- snapshotEntries: readonly ThreadAppDataEntry[];
- snapshotAppIds: readonly AppId[];
+interface ApplyApplicationSnapshotArgs extends ReprimeApplicationsArgs {
+ snapshotEntries: readonly ApplicationDataEntry[];
+ snapshotApplicationIds: readonly ApplicationId[];
}
interface CachedAppDataKey {
- appId: AppId;
+ applicationId: ApplicationId;
cacheKey: string;
path: AppDataPath;
}
export interface ObserveAppDataChangeArgs {
- appId: AppId;
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
- threadStoragePath: string;
}
function appDataCacheKey(args: AppDataCacheKeyArgs): string {
- return `${args.threadId}\0${args.appId}\0${args.path}`;
+ return `${args.applicationId}\0${args.path}`;
}
function isMissingAppDataEntryError(error: Error): boolean {
@@ -99,38 +92,30 @@ function isNonReportableAppDataReadError(error: Error): boolean {
export class AppDataChangeReporter {
private readonly cache = new Map();
- private readonly appIdsByThreadId = new Map>();
private readonly pendingByCacheKey = new Map>();
- private readonly trackedThreadIds = new Set();
+ private readonly trackedTargets = new Map();
private generation = 0;
constructor(private readonly options: CreateAppDataChangeReporterOptions) {}
- async replaceTrackedThreads(
- args: ReplaceTrackedAppDataThreadsArgs,
+ async replaceTrackedApplications(
+ args: ReplaceTrackedApplicationDataTargetsArgs,
): Promise {
this.generation += 1;
const generation = this.generation;
- const nextThreadIds = new Set(
- args.targets.map((target) => target.threadId),
- );
- this.replaceTrackedThreadIds(nextThreadIds);
+ this.replaceTrackedTargets(args.targets);
this.pendingByCacheKey.clear();
- await Promise.all(
- args.targets.map((target) =>
- this.reprimeThread({
- generation,
- target,
- }),
- ),
- );
+ await this.reprimeApplications({
+ generation,
+ targets: args.targets,
+ });
}
observe(args: ObserveAppDataChangeArgs): Promise {
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
+ const target = this.trackedTargets.get(args.applicationId);
+ if (!target) {
+ return Promise.resolve();
+ }
const cacheKey = appDataCacheKey(args);
const generation = this.generation;
const previous = this.pendingByCacheKey.get(cacheKey) ?? Promise.resolve();
@@ -138,16 +123,19 @@ export class AppDataChangeReporter {
.catch(() => undefined)
.then(() =>
this.reportObservedChange({
- change: args,
+ change: {
+ applicationId: args.applicationId,
+ appDataPath: target.appDataPath,
+ path: args.path,
+ },
generation,
}),
)
.catch((error) => {
this.options.logger.warn(
{
- appId: args.appId,
+ applicationId: args.applicationId,
path: args.path,
- threadId: args.threadId,
...runtimeErrorLogFields(error),
},
"Failed to report observed app data change",
@@ -163,10 +151,6 @@ export class AppDataChangeReporter {
}
requestResync(args: AppDataResyncPayload): Promise {
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
return this.postResyncHint(args);
}
@@ -174,81 +158,74 @@ export class AppDataChangeReporter {
return args.generation === this.generation;
}
- private replaceTrackedThreadIds(threadIds: ReadonlySet): void {
- for (const threadId of Array.from(this.trackedThreadIds)) {
- if (threadIds.has(threadId)) {
+ private replaceTrackedTargets(
+ targets: readonly ApplicationDataTarget[],
+ ): void {
+ const nextApplicationIds = new Set(
+ targets.map((target) => target.applicationId),
+ );
+ for (const applicationId of Array.from(this.trackedTargets.keys())) {
+ if (nextApplicationIds.has(applicationId)) {
continue;
}
- this.trackedThreadIds.delete(threadId);
- this.appIdsByThreadId.delete(threadId);
- for (const cached of this.cachedKeysForThread({ threadId })) {
+ this.trackedTargets.delete(applicationId);
+ for (const cached of this.cachedKeysForApplication({ applicationId })) {
this.cache.delete(cached.cacheKey);
}
}
- for (const threadId of threadIds) {
- this.trackedThreadIds.add(threadId);
+ for (const target of targets) {
+ this.trackedTargets.set(target.applicationId, target);
}
}
- private trackApp(args: AppDataResyncPayload): void {
- this.trackedThreadIds.add(args.threadId);
- let appIds = this.appIdsByThreadId.get(args.threadId);
- if (!appIds) {
- appIds = new Set();
- this.appIdsByThreadId.set(args.threadId, appIds);
- }
- appIds.add(args.appId);
- }
-
- private cachedKeysForThread(args: { threadId: string }): CachedAppDataKey[] {
- const prefix = `${args.threadId}\0`;
+ private cachedKeysForApplication(args: {
+ applicationId: ApplicationId;
+ }): CachedAppDataKey[] {
+ const prefix = `${args.applicationId}\0`;
return Array.from(this.cache.keys())
.filter((cacheKey) => cacheKey.startsWith(prefix))
.map((cacheKey) => {
- const [, rawAppId, rawDataPath] = cacheKey.split("\0");
+ const [rawApplicationId, rawDataPath] = cacheKey.split("\0");
return {
- appId: appIdSchema.parse(rawAppId),
+ applicationId: applicationIdSchema.parse(rawApplicationId),
path: appDataPathSchema.parse(rawDataPath),
cacheKey,
};
});
}
- private async reprimeThread(args: ReprimeThreadArgs): Promise {
+ private async reprimeApplications(
+ args: ReprimeApplicationsArgs,
+ ): Promise {
try {
- const snapshot = await listThreadAppDataFromRoot({
- rootPath: args.target.threadStoragePath,
+ const snapshot = await listApplicationDataFromTargets({
+ targets: args.targets,
});
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- const previousAppIds = new Set(
- this.appIdsByThreadId.get(args.target.threadId) ?? [],
- );
- await this.applyThreadSnapshot({
+ const previousApplicationIds = new Set(this.trackedTargets.keys());
+ await this.applyApplicationSnapshot({
...args,
snapshotEntries: snapshot.entries,
- snapshotAppIds: snapshot.appIds,
+ snapshotApplicationIds: snapshot.applicationIds,
});
- const resyncAppIds = new Set([
- ...previousAppIds,
- ...snapshot.appIds,
+ const resyncApplicationIds = new Set([
+ ...previousApplicationIds,
+ ...snapshot.applicationIds,
]);
await Promise.all(
- Array.from(resyncAppIds)
+ Array.from(resyncApplicationIds)
.sort((left, right) => left.localeCompare(right))
- .map((appId) =>
+ .map((applicationId) =>
this.postResyncHint({
- appId,
- threadId: args.target.threadId,
+ applicationId,
}),
),
);
} catch (error) {
this.options.logger.warn(
{
- threadId: args.target.threadId,
- threadStoragePath: args.target.threadStoragePath,
...runtimeErrorLogFields(error),
},
"Failed to reprime app data change cache",
@@ -256,30 +233,26 @@ export class AppDataChangeReporter {
}
}
- private async applyThreadSnapshot(
- args: ApplyThreadSnapshotArgs,
+ private async applyApplicationSnapshot(
+ args: ApplyApplicationSnapshotArgs,
): Promise {
const seenCacheKeys = new Set();
- const appIds = new Set(args.snapshotAppIds);
for (const snapshotEntry of args.snapshotEntries) {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- appIds.add(snapshotEntry.appId);
const cacheKey = appDataCacheKey({
- appId: snapshotEntry.appId,
+ applicationId: snapshotEntry.applicationId,
path: snapshotEntry.entry.path,
- threadId: args.target.threadId,
});
seenCacheKeys.add(cacheKey);
this.cache.set(cacheKey, { version: snapshotEntry.entry.version });
}
- this.appIdsByThreadId.set(args.target.threadId, appIds);
- for (const cached of this.cachedKeysForThread({
- threadId: args.target.threadId,
- })) {
- if (!seenCacheKeys.has(cached.cacheKey)) {
- this.cache.delete(cached.cacheKey);
+ for (const applicationId of args.snapshotApplicationIds) {
+ for (const cached of this.cachedKeysForApplication({ applicationId })) {
+ if (!seenCacheKeys.has(cached.cacheKey)) {
+ this.cache.delete(cached.cacheKey);
+ }
}
}
}
@@ -290,8 +263,7 @@ export class AppDataChangeReporter {
} catch (error) {
this.options.logger.warn(
{
- appId: args.appId,
- threadId: args.threadId,
+ applicationId: args.applicationId,
...runtimeErrorLogFields(error),
},
"Failed to report app data resync hint",
@@ -305,14 +277,17 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
+ const target = this.trackedTargets.get(args.change.applicationId);
+ if (!target) {
+ return;
+ }
const cacheKey = appDataCacheKey(args.change);
const previous = this.cache.get(cacheKey);
try {
- const entry = await readAppDataFromRoot({
- appId: args.change.appId,
+ const entry = await readApplicationDataFromTarget({
path: args.change.path,
- rootPath: args.change.threadStoragePath,
+ target,
});
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
@@ -321,8 +296,7 @@ export class AppDataChangeReporter {
return;
}
await this.options.postAppDataChange({
- appId: args.change.appId,
- threadId: args.change.threadId,
+ applicationId: args.change.applicationId,
path: entry.path,
deleted: false,
value: entry.value,
@@ -331,27 +305,21 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- this.trackApp({
- appId: args.change.appId,
- threadId: args.change.threadId,
- });
this.cache.set(cacheKey, { version: entry.version });
} catch (error) {
if (error instanceof Error && isMissingAppDataEntryError(error)) {
await this.reportObservedDelete({
- appId: args.change.appId,
+ applicationId: args.change.applicationId,
generation: args.generation,
path: args.change.path,
- threadId: args.change.threadId,
});
return;
}
if (error instanceof Error && isNonReportableAppDataReadError(error)) {
this.options.logger.warn(
{
- appId: args.change.appId,
+ applicationId: args.change.applicationId,
path: args.change.path,
- threadId: args.change.threadId,
...runtimeErrorLogFields(error),
},
"Ignoring unreadable observed app data file",
@@ -368,9 +336,12 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
+ const cacheKey = appDataCacheKey(args);
+ if (!this.cache.has(cacheKey)) {
+ return;
+ }
await this.options.postAppDataChange({
- appId: args.appId,
- threadId: args.threadId,
+ applicationId: args.applicationId,
path: args.path,
deleted: true,
value: null,
@@ -379,10 +350,6 @@ export class AppDataChangeReporter {
if (!this.isCurrentGeneration({ generation: args.generation })) {
return;
}
- this.trackApp({
- appId: args.appId,
- threadId: args.threadId,
- });
- this.cache.delete(appDataCacheKey(args));
+ this.cache.delete(cacheKey);
}
}
diff --git a/apps/host-daemon/src/app-data-files.test.ts b/apps/host-daemon/src/app-data-files.test.ts
index cab6c6dc0..d126d352c 100644
--- a/apps/host-daemon/src/app-data-files.test.ts
+++ b/apps/host-daemon/src/app-data-files.test.ts
@@ -1,20 +1,76 @@
import { randomUUID } from "node:crypto";
+import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
-import { listThreadAppDataFromRoot } from "./app-data-files.js";
+import { afterEach, describe, expect, it } from "vitest";
+import { listApplicationDataTargetsFromRoot } from "./app-data-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 })),
+ );
+});
describe("app data files", () => {
- it("treats a missing thread storage root as an empty snapshot", async () => {
+ it("treats a missing apps root as an empty target list", async () => {
const missingRoot = path.join(
os.tmpdir(),
- `bb-missing-thread-storage-${randomUUID()}`,
+ `bb-missing-apps-${randomUUID()}`,
);
await expect(
- listThreadAppDataFromRoot({
- rootPath: missingRoot,
+ listApplicationDataTargetsFromRoot({
+ appsRootPath: missingRoot,
+ }),
+ ).resolves.toEqual([]);
+ });
+
+ it("lists valid global application data targets", async () => {
+ const dataDir = await makeTempDir("bb-app-data-files-");
+ const appsRootPath = path.join(dataDir, "apps");
+ const applicationPath = path.join(appsRootPath, "valid");
+ await fs.mkdir(path.join(applicationPath, "data"), { recursive: true });
+ await fs.writeFile(
+ path.join(applicationPath, "manifest.json"),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "valid",
+ name: "Valid App",
+ entry: "index.html",
+ }),
+ "utf8",
+ );
+ await fs.mkdir(path.join(appsRootPath, "broken"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(appsRootPath, "broken", "manifest.json"),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "other",
+ name: "Broken App",
}),
- ).resolves.toEqual({ appIds: [], entries: [] });
+ "utf8",
+ );
+
+ const resolvedApplicationPath = await fs.realpath(applicationPath);
+ await expect(
+ listApplicationDataTargetsFromRoot({ appsRootPath }),
+ ).resolves.toEqual([
+ {
+ applicationId: "valid",
+ appDataPath: path.join(resolvedApplicationPath, "data"),
+ },
+ ]);
});
});
diff --git a/apps/host-daemon/src/app-data-files.ts b/apps/host-daemon/src/app-data-files.ts
index 2e1d5fb86..532ff6900 100644
--- a/apps/host-daemon/src/app-data-files.ts
+++ b/apps/host-daemon/src/app-data-files.ts
@@ -2,12 +2,17 @@ import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
jsonValueSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
type JsonValue,
} from "@bb/domain";
import {
@@ -26,25 +31,24 @@ export interface AppDataEntry {
version: string;
}
-export interface ThreadAppDataEntry {
- appId: AppId;
+export interface ApplicationDataEntry {
+ applicationId: ApplicationId;
entry: AppDataEntry;
}
-export interface ThreadAppDataSnapshot {
- appIds: AppId[];
- entries: ThreadAppDataEntry[];
+export interface ApplicationDataSnapshot {
+ applicationIds: ApplicationId[];
+ entries: ApplicationDataEntry[];
}
-interface AppDataTargetArgs {
- appId: AppId;
- path: AppDataPath;
- rootPath: string;
+export interface ApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
}
-interface ResolveAppDataRootArgs {
- appId: AppId;
- rootPath: string;
+interface AppDataTargetArgs {
+ path: AppDataPath;
+ target: ApplicationDataTarget;
}
interface ReadAppDataJsonArgs {
@@ -57,8 +61,12 @@ interface ListAppDataFilesArgs {
currentDirectory: string;
}
-interface ListThreadAppDataArgs {
- rootPath: string;
+interface ListApplicationDataArgs {
+ targets: readonly ApplicationDataTarget[];
+}
+
+interface ListApplicationDataTargetsFromRootArgs {
+ appsRootPath: string;
}
function sha256(bytes: Buffer): string {
@@ -91,41 +99,68 @@ function parseJsonValue(args: ReadAppDataJsonArgs): JsonValue {
}
}
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return (
+ entryName.startsWith(".tmp-app_") || entryName.startsWith(".delete-app_")
+ );
+}
+
+async function isValidApplicationManifest(
+ appsRootPath: string,
+ applicationId: ApplicationId,
+): Promise {
+ const dataDir = path.dirname(appsRootPath);
+ try {
+ const rawManifest = JSON.parse(
+ await fs.readFile(
+ resolveApplicationManifestPath(dataDir, applicationId),
+ "utf8",
+ ),
+ );
+ return (
+ rawManifest !== null &&
+ typeof rawManifest === "object" &&
+ "id" in rawManifest &&
+ rawManifest.id === applicationId &&
+ "name" in rawManifest &&
+ typeof rawManifest.name === "string" &&
+ rawManifest.name.trim().length > 0
+ );
+ } catch {
+ return false;
+ }
+}
+
async function resolveAppDataRoot(
- args: ResolveAppDataRootArgs,
+ target: ApplicationDataTarget,
): Promise {
- const appId = appIdSchema.parse(args.appId);
- if (!path.isAbsolute(args.rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
+ if (!path.isAbsolute(target.appDataPath)) {
+ throw new CommandDispatchError(
+ "invalid_path",
+ "appDataPath must be absolute",
+ );
}
- const threadStoragePath = await resolveNonSymlinkDirectoryPath({
- description: "Thread storage root",
- path: args.rootPath,
- });
- const appDataPath = path.join(threadStoragePath, "apps", appId, "data");
const resolvedAppDataPath = await resolveNonSymlinkDirectoryPath({
description: "App data directory",
- path: appDataPath,
+ path: target.appDataPath,
});
- if (!isPathWithinRoot(resolvedAppDataPath, threadStoragePath)) {
+ const appRootPath = path.dirname(resolvedAppDataPath);
+ if (!isPathWithinRoot(resolvedAppDataPath, appRootPath)) {
throw new CommandDispatchError(
"invalid_path",
- "App data path escapes thread storage root",
+ "App data path escapes app root",
);
}
return resolvedAppDataPath;
}
-export async function readAppDataFromRoot(
+export async function readApplicationDataFromTarget(
args: AppDataTargetArgs,
): Promise {
const appDataPath = appDataPathSchema.parse(args.path);
let appDataRoot: string;
try {
- appDataRoot = await resolveAppDataRoot({
- appId: args.appId,
- rootPath: args.rootPath,
- });
+ appDataRoot = await resolveAppDataRoot(args.target);
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
throw new ExpectedCommandDispatchError(
@@ -204,56 +239,58 @@ async function listAppDataFilePaths(
return paths.sort((left, right) => left.localeCompare(right));
}
-export async function listThreadAppDataFromRoot(
- args: ListThreadAppDataArgs,
-): Promise {
- if (!path.isAbsolute(args.rootPath)) {
- throw new CommandDispatchError("invalid_path", "rootPath must be absolute");
- }
- let threadStoragePath: string;
+export async function listApplicationDataTargetsFromRoot(
+ args: ListApplicationDataTargetsFromRootArgs,
+): Promise {
+ let appsRootPath;
try {
- threadStoragePath = await resolveNonSymlinkDirectoryPath({
- description: "Thread storage root",
- path: args.rootPath,
+ appsRootPath = await resolveNonSymlinkDirectoryPath({
+ description: "Apps root",
+ path: args.appsRootPath,
});
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
- // Archived/deleted thread storage can be cleaned up while the daemon is
- // still reconciling tracked targets. A missing root is an empty snapshot.
- return { appIds: [], entries: [] };
+ return [];
}
throw error;
}
- const appsRoot = path.join(threadStoragePath, "apps");
- let appDirectories;
- try {
- appDirectories = await fs.readdir(appsRoot, { withFileTypes: true });
- } catch (error) {
- if (isFsErrorWithCode(error, "ENOENT")) {
- return { appIds: [], entries: [] };
+ const entries = await fs.readdir(appsRootPath, { withFileTypes: true });
+ const dataDir = path.dirname(appsRootPath);
+ const targets: ApplicationDataTarget[] = [];
+ for (const entry of entries) {
+ if (
+ !entry.isDirectory() ||
+ isIgnoredApplicationStorageEntry(entry.name)
+ ) {
+ continue;
}
- throw error;
- }
-
- const appIds: AppId[] = [];
- const snapshotEntries: ThreadAppDataEntry[] = [];
- for (const directory of appDirectories) {
- if (!directory.isDirectory()) {
+ const parsed = applicationIdSchema.safeParse(entry.name);
+ if (!parsed.success) {
continue;
}
- const parsedAppId = appIdSchema.safeParse(directory.name);
- if (!parsedAppId.success) {
+ if (!(await isValidApplicationManifest(appsRootPath, parsed.data))) {
continue;
}
- const appId = parsedAppId.data;
- appIds.push(appId);
- const appDataRoot = path.join(appsRoot, appId, "data");
+ targets.push({
+ applicationId: parsed.data,
+ appDataPath: resolveApplicationDataPath(dataDir, parsed.data),
+ });
+ }
+ return targets.sort((left, right) =>
+ left.applicationId.localeCompare(right.applicationId),
+ );
+}
+
+export async function listApplicationDataFromTargets(
+ args: ListApplicationDataArgs,
+): Promise {
+ const applicationIds: ApplicationId[] = [];
+ const snapshotEntries: ApplicationDataEntry[] = [];
+ for (const target of args.targets) {
+ applicationIds.push(target.applicationId);
let resolvedAppDataRoot;
try {
- resolvedAppDataRoot = await resolveNonSymlinkDirectoryPath({
- description: "App data directory",
- path: appDataRoot,
- });
+ resolvedAppDataRoot = await resolveAppDataRoot(target);
} catch (error) {
if (isFsErrorWithCode(error, "ENOENT")) {
continue;
@@ -266,25 +303,30 @@ export async function listThreadAppDataFromRoot(
});
for (const dataPath of dataPaths) {
snapshotEntries.push({
- appId,
- entry: await readAppDataFromRoot({
- appId,
+ applicationId: target.applicationId,
+ entry: await readApplicationDataFromTarget({
path: dataPath,
- rootPath: threadStoragePath,
+ target,
}),
});
}
}
- appIds.sort((left, right) => left.localeCompare(right));
+ applicationIds.sort((left, right) => left.localeCompare(right));
snapshotEntries.sort((left, right) => {
- const appOrder = left.appId.localeCompare(right.appId);
+ const appOrder = left.applicationId.localeCompare(right.applicationId);
return appOrder === 0
? left.entry.path.localeCompare(right.entry.path)
: appOrder;
});
return {
- appIds,
+ applicationIds,
entries: snapshotEntries,
};
}
+
+export async function ensureAppsRootPath(dataDir: string): Promise {
+ const appsRootPath = resolveAppsRootPath(dataDir);
+ await fs.mkdir(appsRootPath, { recursive: true });
+ return appsRootPath;
+}
diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts
index cd5b87545..04a210909 100644
--- a/apps/host-daemon/src/app.test.ts
+++ b/apps/host-daemon/src/app.test.ts
@@ -134,6 +134,7 @@ function createFetchRecorder(
heartbeatIntervalMs: 30000,
leaseTimeoutMs: 90000,
trackedThreadTargets: [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: args.retiredEnvironmentIds ?? [],
},
{ status: 201 },
@@ -573,6 +574,7 @@ describe("createHostDaemonApp", () => {
heartbeatIntervalMs: 30000,
leaseTimeoutMs: 90000,
trackedThreadTargets: [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: [],
},
{ status: 201 },
@@ -651,6 +653,7 @@ describe("createHostDaemonApp", () => {
shutdown: vi.fn(async () => undefined),
} satisfies AgentRuntime;
const hostWatcher = {
+ watchApplicationStorageRoot: vi.fn(() => () => undefined),
watchWorkspace: vi.fn(() => stopWatchingStatus),
watchThreadStorageRoot: vi.fn(() => () => undefined),
} satisfies HostWatcher;
diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts
index 378f99482..68aebeb95 100644
--- a/apps/host-daemon/src/app.ts
+++ b/apps/host-daemon/src/app.ts
@@ -1,4 +1,3 @@
-import path from "node:path";
import { CommandRouter } from "./command-router.js";
import { createDaemon, type HostDaemon } from "./daemon.js";
import {
@@ -34,6 +33,12 @@ import {
import { createReplayCaptureService } from "@bb/replay-capture/writer";
import { createServerClient } from "./server-client.js";
import { AppDataChangeReporter } from "./app-data-change-reporter.js";
+import { resolveDataDirSkillsRootPath } from "@bb/config/app-storage-paths";
+import {
+ ensureAppsRootPath,
+ listApplicationDataTargetsFromRoot,
+} from "./app-data-files.js";
+import { cleanupInjectedSkillStagingDirs } from "./injected-skills.js";
import {
ServerConnection,
type CreateReconnectingWebSocket,
@@ -325,6 +330,13 @@ export async function createHostDaemonApp(
? { env: { BB_THREAD_STORAGE: options.threadStorageRootPath } }
: {},
);
+ const appsRootPath = await ensureAppsRootPath(options.dataDir);
+ const dataDirSkillsRootPath = resolveDataDirSkillsRootPath(options.dataDir);
+ await cleanupInjectedSkillStagingDirs({
+ dataDir: options.dataDir,
+ keepCatalogHashes: [],
+ logger: options.logger,
+ });
const sessionState: SessionState = {
value: null,
};
@@ -371,6 +383,12 @@ export async function createHostDaemonApp(
postAppDataResync: (payload) => serverClient.postAppDataResync(payload),
});
+ async function refreshTrackedApplicationDataTargets(): Promise {
+ const targets = await listApplicationDataTargetsFromRoot({ appsRootPath });
+ runtimeManager.replaceTrackedApplicationDataTargets(targets);
+ await appDataChangeReporter.replaceTrackedApplications({ targets });
+ }
+
function buildInteractiveInterruptKey(
request: PendingInteractiveInterruptRequest,
): string {
@@ -493,8 +511,12 @@ export async function createHostDaemonApp(
runtimeManager = new RuntimeManager({
bridgeBundleDir: options.bridgeBundleDir,
createRuntime: options.createRuntime,
+ dataDir: options.dataDir,
+ dataDirSkillsRootPath,
hostWatcher: options.hostWatcher,
+ logger: options.logger,
shellEnv: options.runtimeShellEnv,
+ appsRootPath,
onCapture: (entry) => {
replayCapture?.recordRuntimeCaptureEntry(entry);
},
@@ -531,12 +553,57 @@ export async function createHostDaemonApp(
change: "thread-storage-changed",
});
},
- onThreadAppDataChanged: (change) => {
+ onApplicationStorageTargetsChanged: () => {
+ void refreshTrackedApplicationDataTargets()
+ .then(() => {
+ sendServerMessage({
+ type: "application-storage-changed",
+ });
+ })
+ .catch((error) => {
+ options.logger.warn(
+ {
+ appsRootPath,
+ ...runtimeErrorLogFields(error),
+ },
+ "Failed to refresh tracked app data targets",
+ );
+ });
+ },
+ onApplicationDataChanged: (change) => {
void appDataChangeReporter.observe(change);
},
- onThreadAppDataResync: (change) => {
+ onApplicationDataResync: (change) => {
void appDataChangeReporter.requestResync(change);
},
+ onInjectedSkillsChanged: (change) => {
+ options.logger.debug(
+ {
+ applicationId: change.applicationId,
+ changedPaths: change.changedPaths,
+ sourceType: change.sourceType,
+ },
+ "Injected skills changed; future runtime launches will rescan",
+ );
+ },
+ onApplicationStorageWatchError: ({ error }) => {
+ options.logger.warn(
+ {
+ rootPath: error.rootPath,
+ watchError: error.message,
+ },
+ "Application storage watch unavailable; retrying in background",
+ );
+ },
+ onDataDirSkillsWatchError: ({ error }) => {
+ options.logger.warn(
+ {
+ rootPath: error.rootPath,
+ watchError: error.message,
+ },
+ "Data-dir skills watch unavailable; retrying in background",
+ );
+ },
onThreadStorageWatchError: ({ error }) => {
options.logger.warn(
{
@@ -742,11 +809,11 @@ export async function createHostDaemonApp(
runtimeManager.replaceTrackedThreadStorageTargets(
session.trackedThreadTargets,
);
- void appDataChangeReporter.replaceTrackedThreads({
- targets: session.trackedThreadTargets.map((target) => ({
- threadId: target.threadId,
- threadStoragePath: path.join(threadStorageRootPath, target.threadId),
- })),
+ runtimeManager.replaceTrackedApplicationDataTargets(
+ session.trackedApplicationDataTargets,
+ );
+ void appDataChangeReporter.replaceTrackedApplications({
+ targets: session.trackedApplicationDataTargets,
});
void eventBuffer.flush().catch((error) => {
options.logger.warn(
diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts
index 4c874f1f1..b019fdce7 100644
--- a/apps/host-daemon/src/command-dispatch-support.ts
+++ b/apps/host-daemon/src/command-dispatch-support.ts
@@ -8,6 +8,7 @@ import type { AvailableModel, ProviderInfo } from "@bb/domain";
import type { BufferedEventInput } from "./event-buffer.js";
import type {
HostDaemonCommand,
+ HostDaemonInjectedSkillSource,
HostDaemonOnlineRpcCommand,
WorkspaceContext,
} from "@bb/host-daemon-contract";
@@ -214,6 +215,7 @@ export async function requireWorkspaceEnvironment(
args: {
dataDir?: string;
environmentId: string;
+ injectedSkillSources?: readonly HostDaemonInjectedSkillSource[];
workspaceContext: WorkspaceContext;
},
runtimeManager: RuntimeManager,
@@ -227,11 +229,13 @@ export async function requireWorkspaceEnvironment(
`Loaded environment ${args.environmentId} is bound to ${existing.path}, not ${args.workspaceContext.workspacePath}`,
);
}
- return existing;
}
return runtimeManager.ensureEnvironment({
environmentId: args.environmentId,
+ ...(args.injectedSkillSources !== undefined
+ ? { injectedSkillSources: args.injectedSkillSources }
+ : {}),
...(args.dataDir
? { personalWorkspaceRoot: getPersonalWorkspaceRoot(args.dataDir) }
: {}),
diff --git a/apps/host-daemon/src/command-handlers/host-files.test.ts b/apps/host-daemon/src/command-handlers/host-files.test.ts
index 7e3d356b1..d189004af 100644
--- a/apps/host-daemon/src/command-handlers/host-files.test.ts
+++ b/apps/host-daemon/src/command-handlers/host-files.test.ts
@@ -249,19 +249,19 @@ describe("writeHostRelativeFile and deleteHostRelativeFile", () => {
const result = await writeHostRelativeFile({
type: "host.write_file_relative",
rootPath,
- path: "apps/status/data/state.json",
+ path: "data/state.json",
dotfiles: "deny",
content,
contentEncoding: "utf8",
});
expect(result).toMatchObject({
- path: "apps/status/data/state.json",
+ path: "data/state.json",
hash: createHash("sha256").update(content).digest("hex"),
sizeBytes: Buffer.byteLength(content),
});
await expect(
- fs.readFile(path.join(rootPath, "apps/status/data/state.json"), "utf8"),
+ fs.readFile(path.join(rootPath, "data/state.json"), "utf8"),
).resolves.toBe(content);
});
diff --git a/apps/host-daemon/src/command-handlers/thread.ts b/apps/host-daemon/src/command-handlers/thread.ts
index 7c35779cf..af7d1f3cb 100644
--- a/apps/host-daemon/src/command-handlers/thread.ts
+++ b/apps/host-daemon/src/command-handlers/thread.ts
@@ -70,6 +70,7 @@ export async function startThread(
const entry = await requireResolvedWorkspaceForCommand({
dataDir: options.dataDir,
environmentId: command.environmentId,
+ injectedSkillSources: command.injectedSkillSources,
runtimeManager: options.runtimeManager,
workspaceContext: command.workspaceContext,
});
@@ -107,6 +108,7 @@ export async function ensureThreadRuntime(
const entry = await requireResolvedWorkspaceForCommand({
dataDir: options.dataDir,
environmentId: command.environmentId,
+ injectedSkillSources: resumeContext.injectedSkillSources,
runtimeManager: options.runtimeManager,
workspaceContext: resumeContext.workspaceContext,
});
diff --git a/apps/host-daemon/src/injected-skills.test.ts b/apps/host-daemon/src/injected-skills.test.ts
new file mode 100644
index 000000000..9baf60bb1
--- /dev/null
+++ b/apps/host-daemon/src/injected-skills.test.ts
@@ -0,0 +1,246 @@
+import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import type {
+ AgentRuntimeClaudeCodeSkillRoot,
+ AgentRuntimeCodexSkillRoot,
+ AgentRuntimeSkillRoot,
+} from "@bb/agent-runtime";
+import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract";
+import { stageInjectedSkillSources } from "./injected-skills.js";
+
+interface WriteSkillArgs {
+ body?: string;
+ name: string;
+ rootPath: string;
+}
+
+interface StageSourceArgs {
+ dataDir: string;
+ skillRootPath: string;
+ skillName: string;
+}
+
+interface CapturedWarning {
+ context: object;
+ message: string;
+}
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), "bb-host-skills-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs
+ .splice(0)
+ .map((dir) => rm(dir, { recursive: true, force: true })),
+ );
+});
+
+function isCodexSkillRoot(
+ root: AgentRuntimeSkillRoot,
+): root is AgentRuntimeCodexSkillRoot {
+ return root.providerId === "codex";
+}
+
+function isClaudeCodeSkillRoot(
+ root: AgentRuntimeSkillRoot,
+): root is AgentRuntimeClaudeCodeSkillRoot {
+ return root.providerId === "claude-code";
+}
+
+async function writeSkill(args: WriteSkillArgs): Promise {
+ const skillRootPath = path.join(args.rootPath, args.name);
+ await mkdir(path.join(skillRootPath, "references"), { recursive: true });
+ await writeFile(
+ path.join(skillRootPath, "SKILL.md"),
+ [
+ "---",
+ `name: ${args.name}`,
+ `description: Use ${args.name} when host staging tests run.`,
+ "---",
+ "",
+ args.body ?? "# Skill",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ await writeFile(
+ path.join(skillRootPath, "references", "notes.md"),
+ "supporting notes\n",
+ "utf8",
+ );
+ return skillRootPath;
+}
+
+function createDataDirSource(args: StageSourceArgs): HostDaemonInjectedSkillSource {
+ return {
+ sourceType: "data-dir",
+ applicationId: null,
+ name: args.skillName,
+ description: `Use ${args.skillName} when host staging tests run.`,
+ sourceRootPath: args.skillRootPath,
+ skillFilePath: path.join(args.skillRootPath, "SKILL.md"),
+ };
+}
+
+describe("injected skill staging", () => {
+ it("creates a shared staged snapshot for Codex and Claude Code", async () => {
+ const dataDir = await makeTempDir();
+ const skillRootPath = await writeSkill({
+ rootPath: path.join(dataDir, "source-skills"),
+ name: "release-notes",
+ });
+
+ const staged = await stageInjectedSkillSources({
+ dataDir,
+ injectedSkillSources: [
+ createDataDirSource({
+ dataDir,
+ skillName: "release-notes",
+ skillRootPath,
+ }),
+ ],
+ });
+
+ const codexRoot = staged.skillRoots.find(isCodexSkillRoot);
+ const claudeRoot = staged.skillRoots.find(isClaudeCodeSkillRoot);
+ expect(codexRoot).toEqual({
+ id: `global-skills:${staged.catalogHash}:codex`,
+ providerId: "codex",
+ skillDirectoryRootPath: path.join(
+ dataDir,
+ "runtime",
+ "global-skills",
+ staged.catalogHash,
+ "skills",
+ ),
+ });
+ expect(claudeRoot).toEqual({
+ id: `global-skills:${staged.catalogHash}:claude-code`,
+ providerId: "claude-code",
+ localPluginPath: path.join(
+ dataDir,
+ "runtime",
+ "global-skills",
+ staged.catalogHash,
+ ),
+ skillNames: ["release-notes"],
+ });
+
+ if (!claudeRoot) {
+ throw new Error("Expected Claude Code skill root");
+ }
+ await expect(
+ readFile(
+ path.join(claudeRoot.localPluginPath, "skills", "release-notes", "SKILL.md"),
+ "utf8",
+ ),
+ ).resolves.toContain("name: release-notes");
+ await expect(
+ readFile(
+ path.join(
+ claudeRoot.localPluginPath,
+ "skills",
+ "release-notes",
+ "references",
+ "notes.md",
+ ),
+ "utf8",
+ ),
+ ).resolves.toBe("supporting notes\n");
+ await expect(
+ readFile(
+ path.join(claudeRoot.localPluginPath, ".claude-plugin", "plugin.json"),
+ "utf8",
+ ).then((content) => JSON.parse(content)),
+ ).resolves.toMatchObject({
+ name: "bb-global-skills",
+ skills: ["./skills/release-notes"],
+ });
+ });
+
+ it("changes the catalog hash when skill content changes", async () => {
+ const dataDir = await makeTempDir();
+ const sourceRootPath = path.join(dataDir, "source-skills");
+ const skillRootPath = await writeSkill({
+ body: "first body",
+ rootPath: sourceRootPath,
+ name: "release-notes",
+ });
+ const source = createDataDirSource({
+ dataDir,
+ skillName: "release-notes",
+ skillRootPath,
+ });
+ const first = await stageInjectedSkillSources({
+ dataDir,
+ injectedSkillSources: [source],
+ });
+
+ await writeFile(
+ path.join(skillRootPath, "SKILL.md"),
+ [
+ "---",
+ "name: release-notes",
+ "description: Use release-notes when host staging tests run.",
+ "---",
+ "",
+ "second body",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ const second = await stageInjectedSkillSources({
+ dataDir,
+ injectedSkillSources: [source],
+ });
+
+ expect(second.catalogHash).not.toBe(first.catalogHash);
+ });
+
+ it("skips symlinked files during staging", async () => {
+ const dataDir = await makeTempDir();
+ const outsideDir = await makeTempDir();
+ const skillRootPath = await writeSkill({
+ rootPath: path.join(dataDir, "source-skills"),
+ name: "release-notes",
+ });
+ await writeFile(path.join(outsideDir, "escape.md"), "escape\n", "utf8");
+ await symlink(
+ path.join(outsideDir, "escape.md"),
+ path.join(skillRootPath, "references", "escape.md"),
+ );
+ const warnings: CapturedWarning[] = [];
+
+ const staged = await stageInjectedSkillSources({
+ dataDir,
+ injectedSkillSources: [
+ createDataDirSource({
+ dataDir,
+ skillName: "release-notes",
+ skillRootPath,
+ }),
+ ],
+ logger: {
+ debug: () => undefined,
+ warn: (context, message) => {
+ warnings.push({ context, message });
+ },
+ },
+ });
+
+ expect(staged.skillRoots).toEqual([]);
+ expect(warnings).toEqual([
+ expect.objectContaining({
+ message: "Skipping injected skill during staging",
+ }),
+ ]);
+ });
+});
diff --git a/apps/host-daemon/src/injected-skills.ts b/apps/host-daemon/src/injected-skills.ts
new file mode 100644
index 000000000..cee671f6d
--- /dev/null
+++ b/apps/host-daemon/src/injected-skills.ts
@@ -0,0 +1,563 @@
+import { createHash } from "node:crypto";
+import type { Dirent } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { resolveDataDirSkillsRootPath } from "@bb/config/app-storage-paths";
+import type { AgentRuntimeSkillRoot } from "@bb/agent-runtime";
+import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract";
+
+const STAGING_ROOT_SEGMENTS = ["runtime", "global-skills"] as const;
+const SKILL_FILE_NAME = "SKILL.md";
+const SKILL_NAME_PATTERN =
+ /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u;
+const MAX_STAGED_SKILL_FILES = 1_000;
+const MAX_STAGED_SKILL_BYTES = 10 * 1024 * 1024;
+const MAX_STAGED_SKILL_DEPTH = 24;
+export const EMPTY_SKILL_CATALOG_HASH = createHash("sha256")
+ .update("bb-global-skills-v1-empty")
+ .digest("hex");
+
+export interface InjectedSkillsLogger {
+ debug(context: object, message: string): void;
+ warn(context: object, message: string): void;
+}
+
+export interface StageInjectedSkillSourcesArgs {
+ dataDir: string;
+ injectedSkillSources: readonly HostDaemonInjectedSkillSource[];
+ logger?: InjectedSkillsLogger;
+}
+
+export interface CleanupInjectedSkillStagingDirsArgs {
+ dataDir: string;
+ keepCatalogHashes: readonly string[];
+ logger?: InjectedSkillsLogger;
+}
+
+export interface StagedInjectedSkills {
+ catalogHash: string;
+ skillRoots: readonly AgentRuntimeSkillRoot[];
+}
+
+interface CollectedSkillFile {
+ bytes: Buffer;
+ relativePath: string;
+}
+
+interface CollectedSkillDirectory {
+ relativePath: string;
+}
+
+interface CollectedSkillTree {
+ directories: CollectedSkillDirectory[];
+ files: CollectedSkillFile[];
+ source: HostDaemonInjectedSkillSource;
+ totalBytes: number;
+}
+
+interface CollectSkillTreeArgs {
+ source: HostDaemonInjectedSkillSource;
+}
+
+interface WalkSkillTreeArgs {
+ currentPath: string;
+ depth: number;
+ rootPath: string;
+ state: SkillTreeCollectionState;
+}
+
+interface SkillTreeCollectionState {
+ directories: CollectedSkillDirectory[];
+ files: CollectedSkillFile[];
+ totalBytes: number;
+}
+
+interface StageTreeArgs {
+ skillDirectoryPath: string;
+ tree: CollectedSkillTree;
+}
+
+interface WriteStageRootArgs {
+ catalogHash: string;
+ dataDir: string;
+ trees: readonly CollectedSkillTree[];
+}
+
+interface BuildSkillRootsArgs {
+ catalogHash: string;
+ skillNames: readonly string[];
+ stageRootPath: string;
+}
+
+interface PluginManifestAuthor {
+ name: string;
+}
+
+interface ClaudePluginManifest {
+ $schema: string;
+ name: string;
+ version: string;
+ description: string;
+ author: PluginManifestAuthor;
+ skills: string[];
+}
+
+interface CatalogSkillEntry {
+ applicationId: string | null;
+ description: string;
+ name: string;
+ sourceRootPath: string;
+ sourceType: HostDaemonInjectedSkillSource["sourceType"];
+}
+
+interface CatalogFile {
+ catalogHash: string;
+ generatedAt: string;
+ skills: CatalogSkillEntry[];
+}
+
+interface CreateCatalogFileArgs {
+ catalogHash: string;
+ trees: readonly CollectedSkillTree[];
+}
+
+export interface InjectedSkillRootsDiagnostics {
+ dataDirSkillsRootPath: string;
+ stagingRootPath: string;
+}
+
+function createNoopLogger(): InjectedSkillsLogger {
+ return {
+ debug: () => undefined,
+ warn: () => undefined,
+ };
+}
+
+function isFsErrorWithCode(error: Error, code: string): boolean {
+ return "code" in error && error.code === code;
+}
+
+function resolveStagingRootPath(dataDir: string): string {
+ return path.join(dataDir, ...STAGING_ROOT_SEGMENTS);
+}
+
+function resolveStageRootPath(dataDir: string, catalogHash: string): string {
+ return path.join(resolveStagingRootPath(dataDir), catalogHash);
+}
+
+function normalizeRelativePath(relativePath: string): string {
+ return relativePath.split(path.sep).join("/");
+}
+
+function isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
+ const relativePath = path.relative(rootPath, candidatePath);
+ return (
+ relativePath.length === 0 ||
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
+ );
+}
+
+function sortDirentsByName(left: Dirent, right: Dirent): number {
+ return left.name.localeCompare(right.name);
+}
+
+function sortTreesByName(
+ left: CollectedSkillTree,
+ right: CollectedSkillTree,
+): number {
+ return left.source.name.localeCompare(right.source.name);
+}
+
+function assertUsableSource(source: HostDaemonInjectedSkillSource): void {
+ if (!SKILL_NAME_PATTERN.test(source.name)) {
+ throw new Error(`Invalid injected skill name: ${source.name}`);
+ }
+ if (!path.isAbsolute(source.sourceRootPath)) {
+ throw new Error(
+ `Injected skill source root must be absolute: ${source.sourceRootPath}`,
+ );
+ }
+ if (!path.isAbsolute(source.skillFilePath)) {
+ throw new Error(
+ `Injected skill file path must be absolute: ${source.skillFilePath}`,
+ );
+ }
+ if (!isPathWithinRoot(source.sourceRootPath, source.skillFilePath)) {
+ throw new Error(
+ `Injected skill file path escapes source root: ${source.skillFilePath}`,
+ );
+ }
+ if (path.basename(source.skillFilePath) !== SKILL_FILE_NAME) {
+ throw new Error(
+ `Injected skill file path must end with ${SKILL_FILE_NAME}: ${source.skillFilePath}`,
+ );
+ }
+}
+
+async function walkSkillTree(args: WalkSkillTreeArgs): Promise {
+ if (args.depth > MAX_STAGED_SKILL_DEPTH) {
+ throw new Error(
+ `Skill tree exceeds max depth ${MAX_STAGED_SKILL_DEPTH}: ${args.rootPath}`,
+ );
+ }
+
+ const entries = (await fs.readdir(args.currentPath, {
+ withFileTypes: true,
+ })).sort(sortDirentsByName);
+
+ for (const entry of entries) {
+ const sourcePath = path.join(args.currentPath, entry.name);
+ if (!isPathWithinRoot(args.rootPath, sourcePath)) {
+ throw new Error(`Skill tree entry escapes source root: ${sourcePath}`);
+ }
+ const relativePath = normalizeRelativePath(
+ path.relative(args.rootPath, sourcePath),
+ );
+ const entryStat = await fs.lstat(sourcePath);
+ if (entryStat.isSymbolicLink()) {
+ throw new Error(`Skill tree contains a symlink: ${sourcePath}`);
+ }
+ if (entryStat.isDirectory()) {
+ args.state.directories.push({ relativePath });
+ await walkSkillTree({
+ currentPath: sourcePath,
+ depth: args.depth + 1,
+ rootPath: args.rootPath,
+ state: args.state,
+ });
+ continue;
+ }
+ if (!entryStat.isFile()) {
+ throw new Error(`Skill tree entry is not a regular file: ${sourcePath}`);
+ }
+ if (args.state.files.length + 1 > MAX_STAGED_SKILL_FILES) {
+ throw new Error(
+ `Skill tree exceeds max file count ${MAX_STAGED_SKILL_FILES}: ${args.rootPath}`,
+ );
+ }
+ if (args.state.totalBytes + entryStat.size > MAX_STAGED_SKILL_BYTES) {
+ throw new Error(
+ `Skill tree exceeds max byte count ${MAX_STAGED_SKILL_BYTES}: ${args.rootPath}`,
+ );
+ }
+ const bytes = await fs.readFile(sourcePath);
+ args.state.files.push({
+ bytes,
+ relativePath,
+ });
+ args.state.totalBytes += entryStat.size;
+ }
+}
+
+async function collectSkillTree(
+ args: CollectSkillTreeArgs,
+): Promise {
+ assertUsableSource(args.source);
+ const rootStat = await fs.lstat(args.source.sourceRootPath);
+ if (rootStat.isSymbolicLink()) {
+ throw new Error(
+ `Injected skill source root is a symlink: ${args.source.sourceRootPath}`,
+ );
+ }
+ if (!rootStat.isDirectory()) {
+ throw new Error(
+ `Injected skill source root is not a directory: ${args.source.sourceRootPath}`,
+ );
+ }
+ const skillFileStat = await fs.lstat(args.source.skillFilePath);
+ if (skillFileStat.isSymbolicLink()) {
+ throw new Error(
+ `Injected skill file is a symlink: ${args.source.skillFilePath}`,
+ );
+ }
+ if (!skillFileStat.isFile()) {
+ throw new Error(
+ `Injected skill file is not a regular file: ${args.source.skillFilePath}`,
+ );
+ }
+
+ const state: SkillTreeCollectionState = {
+ directories: [],
+ files: [],
+ totalBytes: 0,
+ };
+ await walkSkillTree({
+ currentPath: args.source.sourceRootPath,
+ depth: 0,
+ rootPath: args.source.sourceRootPath,
+ state,
+ });
+
+ return {
+ directories: state.directories,
+ files: state.files,
+ source: args.source,
+ totalBytes: state.totalBytes,
+ };
+}
+
+function hashCollectedTrees(trees: readonly CollectedSkillTree[]): string {
+ const hash = createHash("sha256");
+ hash.update("bb-global-skills-v1");
+ for (const tree of trees) {
+ hash.update("\0skill\0");
+ hash.update(tree.source.name);
+ hash.update("\0");
+ hash.update(tree.source.description);
+ hash.update("\0");
+ hash.update(tree.source.sourceType);
+ hash.update("\0");
+ hash.update(tree.source.applicationId ?? "");
+ hash.update("\0");
+ hash.update(tree.source.sourceRootPath);
+ for (const file of tree.files) {
+ hash.update("\0file\0");
+ hash.update(file.relativePath);
+ hash.update("\0");
+ hash.update(createHash("sha256").update(file.bytes).digest("hex"));
+ }
+ }
+ return hash.digest("hex");
+}
+
+async function copyCollectedTree(args: StageTreeArgs): Promise {
+ await fs.mkdir(args.skillDirectoryPath, { recursive: true });
+ for (const directory of args.tree.directories) {
+ await fs.mkdir(path.join(args.skillDirectoryPath, directory.relativePath), {
+ recursive: true,
+ });
+ }
+ for (const file of args.tree.files) {
+ const destinationPath = path.join(
+ args.skillDirectoryPath,
+ file.relativePath,
+ );
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
+ await fs.writeFile(destinationPath, file.bytes);
+ }
+}
+
+function createClaudePluginManifest(
+ skillNames: readonly string[],
+): ClaudePluginManifest {
+ return {
+ $schema: "https://anthropic.com/claude-code/plugin.schema.json",
+ name: "bb-global-skills",
+ version: "0.1.0",
+ description: "Global skills staged by bb.",
+ author: {
+ name: "bb",
+ },
+ skills: skillNames.map((skillName) => `./skills/${skillName}`),
+ };
+}
+
+function createCatalogFile(args: CreateCatalogFileArgs): CatalogFile {
+ return {
+ catalogHash: args.catalogHash,
+ generatedAt: new Date().toISOString(),
+ skills: args.trees.map((tree) => ({
+ applicationId: tree.source.applicationId,
+ description: tree.source.description,
+ name: tree.source.name,
+ sourceRootPath: tree.source.sourceRootPath,
+ sourceType: tree.source.sourceType,
+ })),
+ };
+}
+
+async function writeStageRoot(args: WriteStageRootArgs): Promise {
+ const stagingRootPath = resolveStagingRootPath(args.dataDir);
+ const stageRootPath = resolveStageRootPath(args.dataDir, args.catalogHash);
+ try {
+ await fs.access(path.join(stageRootPath, "catalog.json"));
+ return stageRootPath;
+ } catch (error) {
+ if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+
+ await fs.mkdir(stagingRootPath, { recursive: true });
+ const tempRootPath = path.join(
+ stagingRootPath,
+ `.tmp-${args.catalogHash}-${process.pid}-${Date.now()}`,
+ );
+ await fs.rm(tempRootPath, { recursive: true, force: true });
+ await fs.mkdir(path.join(tempRootPath, "skills"), { recursive: true });
+ await fs.mkdir(path.join(tempRootPath, ".claude-plugin"), {
+ recursive: true,
+ });
+
+ try {
+ const skillNames = args.trees.map((tree) => tree.source.name);
+ for (const tree of args.trees) {
+ await copyCollectedTree({
+ skillDirectoryPath: path.join(tempRootPath, "skills", tree.source.name),
+ tree,
+ });
+ }
+ await fs.writeFile(
+ path.join(tempRootPath, ".claude-plugin", "plugin.json"),
+ `${JSON.stringify(createClaudePluginManifest(skillNames), null, 2)}\n`,
+ "utf8",
+ );
+ await fs.writeFile(
+ path.join(tempRootPath, "catalog.json"),
+ `${JSON.stringify(
+ createCatalogFile({
+ catalogHash: args.catalogHash,
+ trees: args.trees,
+ }),
+ null,
+ 2,
+ )}\n`,
+ "utf8",
+ );
+ await fs.rename(tempRootPath, stageRootPath);
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (isFsErrorWithCode(error, "EEXIST") ||
+ isFsErrorWithCode(error, "ENOTEMPTY"))
+ ) {
+ await fs.rm(tempRootPath, { recursive: true, force: true });
+ return stageRootPath;
+ }
+ await fs.rm(tempRootPath, { recursive: true, force: true });
+ throw error;
+ }
+
+ return stageRootPath;
+}
+
+function buildSkillRoots(args: BuildSkillRootsArgs): AgentRuntimeSkillRoot[] {
+ if (args.skillNames.length === 0) {
+ return [];
+ }
+ return [
+ {
+ id: `global-skills:${args.catalogHash}:codex`,
+ providerId: "codex",
+ skillDirectoryRootPath: path.join(args.stageRootPath, "skills"),
+ },
+ {
+ id: `global-skills:${args.catalogHash}:claude-code`,
+ providerId: "claude-code",
+ localPluginPath: args.stageRootPath,
+ skillNames: [...args.skillNames],
+ },
+ ];
+}
+
+export async function stageInjectedSkillSources(
+ args: StageInjectedSkillSourcesArgs,
+): Promise {
+ if (args.injectedSkillSources.length === 0) {
+ return {
+ catalogHash: EMPTY_SKILL_CATALOG_HASH,
+ skillRoots: [],
+ };
+ }
+
+ const logger = args.logger ?? createNoopLogger();
+ const trees: CollectedSkillTree[] = [];
+ const sortedSources = [...args.injectedSkillSources].sort((left, right) =>
+ left.name.localeCompare(right.name),
+ );
+ for (const source of sortedSources) {
+ try {
+ trees.push(await collectSkillTree({ source }));
+ } catch (error) {
+ logger.warn(
+ {
+ applicationId: source.applicationId,
+ name: source.name,
+ sourceRootPath: source.sourceRootPath,
+ sourceType: source.sourceType,
+ reason:
+ error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "Unable to stage injected skill",
+ },
+ "Skipping injected skill during staging",
+ );
+ }
+ }
+
+ const sortedTrees = trees.sort(sortTreesByName);
+ if (sortedTrees.length === 0) {
+ return {
+ catalogHash: EMPTY_SKILL_CATALOG_HASH,
+ skillRoots: [],
+ };
+ }
+
+ const catalogHash = hashCollectedTrees(sortedTrees);
+ const stageRootPath = await writeStageRoot({
+ catalogHash,
+ dataDir: args.dataDir,
+ trees: sortedTrees,
+ });
+ const skillNames = sortedTrees.map((tree) => tree.source.name);
+ return {
+ catalogHash,
+ skillRoots: buildSkillRoots({
+ catalogHash,
+ skillNames,
+ stageRootPath,
+ }),
+ };
+}
+
+export async function cleanupInjectedSkillStagingDirs(
+ args: CleanupInjectedSkillStagingDirsArgs,
+): Promise {
+ const stagingRootPath = resolveStagingRootPath(args.dataDir);
+ const keep = new Set(args.keepCatalogHashes);
+ let entries: Dirent[];
+ try {
+ entries = await fs.readdir(stagingRootPath, { withFileTypes: true });
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return;
+ }
+ throw error;
+ }
+
+ const logger = args.logger ?? createNoopLogger();
+ await Promise.all(
+ entries.map(async (entry) => {
+ if (!entry.isDirectory() || entry.name.startsWith(".tmp-")) {
+ await fs.rm(path.join(stagingRootPath, entry.name), {
+ recursive: true,
+ force: true,
+ });
+ return;
+ }
+ if (keep.has(entry.name)) {
+ return;
+ }
+ logger.debug(
+ {
+ catalogHash: entry.name,
+ stagingRootPath,
+ },
+ "Removing unused injected skill staging directory",
+ );
+ await fs.rm(path.join(stagingRootPath, entry.name), {
+ recursive: true,
+ force: true,
+ });
+ }),
+ );
+}
+
+export function resolveInjectedSkillRootsForDiagnostics(
+ dataDir: string,
+): InjectedSkillRootsDiagnostics {
+ return {
+ dataDirSkillsRootPath: resolveDataDirSkillsRootPath(dataDir),
+ stagingRootPath: resolveStagingRootPath(dataDir),
+ };
+}
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 97b639501..4b8a55c8a 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -6,8 +6,10 @@ import { promisify } from "node:util";
import type { AgentRuntime, AgentRuntimeOptions } from "@bb/agent-runtime";
import type { ThreadEvent } from "@bb/domain";
import { threadScope, turnScope } from "@bb/domain";
+import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract";
import type {
HostWatcher,
+ WatchApplicationStorageRootArgs,
ThreadStorageWatchError,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
@@ -53,10 +55,19 @@ type WatchWorkspaceImplementation = (
type WatchThreadStorageRootImplementation = (
args: WatchThreadStorageRootArgs,
) => StopWatchingPathChanges;
+type WatchApplicationStorageRootImplementation = (
+ args: WatchApplicationStorageRootArgs,
+) => StopWatchingPathChanges;
interface RunGitOptions {
cwd: string;
}
+interface WriteInjectedSkillSourceArgs {
+ dataDir: string;
+ name: string;
+ token: string;
+}
+
interface RuntimeOptionsRef {
current: AgentRuntimeOptions | null;
}
@@ -91,6 +102,34 @@ async function initRepo(): Promise {
return repoPath;
}
+async function writeInjectedSkillSource(
+ args: WriteInjectedSkillSourceArgs,
+): Promise {
+ const sourceRootPath = path.join(args.dataDir, "skills", args.name);
+ await fs.mkdir(sourceRootPath, { recursive: true });
+ await fs.writeFile(
+ path.join(sourceRootPath, "SKILL.md"),
+ [
+ "---",
+ `name: ${args.name}`,
+ `description: Use ${args.name} when runtime manager tests run.`,
+ "---",
+ "",
+ args.token,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ return {
+ sourceType: "data-dir",
+ applicationId: null,
+ name: args.name,
+ description: `Use ${args.name} when runtime manager tests run.`,
+ sourceRootPath,
+ skillFilePath: path.join(sourceRootPath, "SKILL.md"),
+ };
+}
+
afterEach(async () => {
await Promise.all(
tempDirs
@@ -202,6 +241,7 @@ function createFakeWorkspace(path: string) {
function createFakeHostWatcher(
args: {
+ watchApplicationStorageRootImplementation?: WatchApplicationStorageRootImplementation;
watchThreadStorageRootImplementation?: WatchThreadStorageRootImplementation;
watchWorkspaceImplementation?: WatchWorkspaceImplementation;
} = {},
@@ -212,13 +252,20 @@ function createFakeHostWatcher(
const watchThreadStorageRoot = vi.fn(
args.watchThreadStorageRootImplementation ?? ((_args) => () => undefined),
);
+ const watchApplicationStorageRoot =
+ vi.fn(
+ args.watchApplicationStorageRootImplementation ??
+ ((_args) => () => undefined),
+ );
const hostWatcher = {
+ watchApplicationStorageRoot,
watchWorkspace,
watchThreadStorageRoot,
} satisfies HostWatcher;
return {
hostWatcher,
+ watchApplicationStorageRoot,
watchThreadStorageRoot,
watchWorkspace,
};
@@ -275,6 +322,109 @@ describe("RuntimeManager", () => {
expect(entry.path).toBe("/tmp/env-1");
});
+ it("passes staged injected skill roots to created runtimes", async () => {
+ const dataDir = await makeTempDir("bb-runtime-manager-skills-");
+ const source = await writeInjectedSkillSource({
+ dataDir,
+ name: "release-notes",
+ token: "first-token",
+ });
+ const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
+ const runtimeOptions: RuntimeOptionsRef = { current: null };
+ const manager = new RuntimeManager({
+ dataDir,
+ provisionWorkspace,
+ createRuntime: (options) => {
+ runtimeOptions.current = options;
+ return createFakeRuntime();
+ },
+ });
+
+ const entry = await manager.ensureEnvironment({
+ environmentId: "env-skills",
+ injectedSkillSources: [source],
+ workspacePath: "/tmp/env-1",
+ });
+
+ expect(entry.skillCatalogHash).toMatch(/^[a-f0-9]{64}$/u);
+ expect(runtimeOptions.current?.skillRoots).toEqual([
+ {
+ id: `global-skills:${entry.skillCatalogHash}:codex`,
+ providerId: "codex",
+ skillDirectoryRootPath: path.join(
+ dataDir,
+ "runtime",
+ "global-skills",
+ entry.skillCatalogHash ?? "",
+ "skills",
+ ),
+ },
+ {
+ id: `global-skills:${entry.skillCatalogHash}:claude-code`,
+ providerId: "claude-code",
+ localPluginPath: path.join(
+ dataDir,
+ "runtime",
+ "global-skills",
+ entry.skillCatalogHash ?? "",
+ ),
+ skillNames: ["release-notes"],
+ },
+ ]);
+ });
+
+ it("does not reuse an idle runtime with a stale skill catalog hash", async () => {
+ const dataDir = await makeTempDir("bb-runtime-manager-skills-stale-");
+ const source = await writeInjectedSkillSource({
+ dataDir,
+ name: "release-notes",
+ token: "first-token",
+ });
+ const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
+ const runtimes = [createFakeRuntime(), createFakeRuntime()];
+ const createRuntime = vi.fn(() => {
+ const runtime = runtimes.shift();
+ if (!runtime) {
+ throw new Error("Unexpected runtime creation");
+ }
+ return runtime;
+ });
+ const manager = new RuntimeManager({
+ dataDir,
+ provisionWorkspace,
+ createRuntime,
+ });
+
+ const firstEntry = await manager.ensureEnvironment({
+ environmentId: "env-skills",
+ injectedSkillSources: [source],
+ workspacePath: "/tmp/env-1",
+ });
+ await fs.writeFile(
+ source.skillFilePath,
+ [
+ "---",
+ "name: release-notes",
+ "description: Use release-notes when runtime manager tests run.",
+ "---",
+ "",
+ "second-token",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ const secondEntry = await manager.ensureEnvironment({
+ environmentId: "env-skills",
+ injectedSkillSources: [source],
+ workspacePath: "/tmp/env-1",
+ });
+
+ expect(secondEntry).not.toBe(firstEntry);
+ expect(secondEntry.skillCatalogHash).not.toBe(firstEntry.skillCatalogHash);
+ expect(createRuntime).toHaveBeenCalledTimes(2);
+ expect(firstEntry.runtime.shutdown).toHaveBeenCalledTimes(1);
+ });
+
it("applies unmanaged checkout provisioning to existing runtime entries", async () => {
const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1");
const createRuntime = vi.fn(() => createFakeRuntime());
@@ -1086,15 +1236,11 @@ describe("RuntimeManager", () => {
},
});
const onThreadStorageChanged = vi.fn();
- const onThreadAppDataChanged = vi.fn();
- const onThreadAppDataResync = vi.fn();
const manager = new RuntimeManager({
hostWatcher,
provisionWorkspace: createProvisionWorkspaceMock("/tmp/env-storage"),
createRuntime: vi.fn(() => createFakeRuntime()),
onThreadStorageChanged,
- onThreadAppDataChanged,
- onThreadAppDataResync,
threadStorageRootPath: "/tmp/bb-data/thread-storage",
});
@@ -1115,20 +1261,6 @@ describe("RuntimeManager", () => {
environmentId: "env-storage",
threadId: "thread-2",
});
- watchThreadStorageRootArgs?.onChange({
- kind: "thread-app-data-changed",
- appId: "status",
- environmentId: "env-storage",
- path: "state.json",
- threadId: "thread-1",
- });
- watchThreadStorageRootArgs?.onChange({
- kind: "thread-app-data-resync",
- appId: "status",
- environmentId: "env-storage",
- threadId: "thread-1",
- });
-
expect(watchThreadStorageRoot).toHaveBeenCalledTimes(1);
expect(watchThreadStorageRoot).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1144,22 +1276,82 @@ describe("RuntimeManager", () => {
threadId: "thread-2",
});
expect(onThreadStorageChanged).toHaveBeenCalledTimes(2);
- expect(onThreadAppDataChanged).toHaveBeenCalledWith({
- appId: "status",
- environmentId: "env-storage",
+ expect(stopWatchingPathChanges).not.toHaveBeenCalled();
+
+ await manager.destroyEnvironment("env-storage");
+
+ expect(stopWatchingPathChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it("installs one shared application storage root watcher for app data", async () => {
+ const stopWatchingPathChanges = vi.fn(() => undefined);
+ let watchApplicationStorageRootArgs:
+ | WatchApplicationStorageRootArgs
+ | undefined;
+ const { hostWatcher, watchApplicationStorageRoot } = createFakeHostWatcher({
+ watchApplicationStorageRootImplementation: (args) => {
+ watchApplicationStorageRootArgs = args;
+ return stopWatchingPathChanges;
+ },
+ });
+ const onApplicationStorageTargetsChanged = vi.fn();
+ const onApplicationDataChanged = vi.fn();
+ const onApplicationDataResync = vi.fn();
+ const manager = new RuntimeManager({
+ appsRootPath: "/tmp/bb-data/apps",
+ hostWatcher,
+ provisionWorkspace: createProvisionWorkspaceMock("/tmp/env-storage"),
+ createRuntime: vi.fn(() => createFakeRuntime()),
+ onApplicationStorageTargetsChanged,
+ onApplicationDataChanged,
+ onApplicationDataResync,
+ });
+
+ manager.replaceTrackedApplicationDataTargets([
+ {
+ applicationId: "status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
+ },
+ ]);
+
+ expect(watchApplicationStorageRoot).toHaveBeenCalledTimes(1);
+ expect(watchApplicationStorageRoot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appsRootPath: "/tmp/bb-data/apps",
+ }),
+ );
+ expect(
+ watchApplicationStorageRootArgs?.resolveApplicationTarget("status"),
+ ).toEqual({
+ applicationId: "status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
+ });
+
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-storage-targets-changed",
+ });
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-data-changed",
+ applicationId: "status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
path: "state.json",
- threadId: "thread-1",
- threadStoragePath: "/tmp/bb-data/thread-storage/thread-1",
});
- expect(onThreadAppDataResync).toHaveBeenCalledWith({
- appId: "status",
- environmentId: "env-storage",
- threadId: "thread-1",
- threadStoragePath: "/tmp/bb-data/thread-storage/thread-1",
+ watchApplicationStorageRootArgs?.onChange({
+ kind: "application-data-resync",
+ applicationId: "status",
});
- expect(stopWatchingPathChanges).not.toHaveBeenCalled();
- await manager.destroyEnvironment("env-storage");
+ expect(onApplicationStorageTargetsChanged).toHaveBeenCalledTimes(1);
+ expect(onApplicationDataChanged).toHaveBeenCalledWith({
+ applicationId: "status",
+ appDataPath: "/tmp/bb-data/apps/status/data",
+ path: "state.json",
+ });
+ expect(onApplicationDataResync).toHaveBeenCalledWith({
+ applicationId: "status",
+ });
+
+ await manager.shutdownAll();
expect(stopWatchingPathChanges).toHaveBeenCalledTimes(1);
});
diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts
index aea4290fa..1e31d1480 100644
--- a/apps/host-daemon/src/runtime-manager.ts
+++ b/apps/host-daemon/src/runtime-manager.ts
@@ -4,11 +4,13 @@ import {
createAgentRuntime,
type AgentRuntime,
type AgentRuntimeOptions,
+ type AgentRuntimeSkillRoot,
type AgentRuntimeProcessExitInfo,
} from "@bb/agent-runtime";
+import type { Logger } from "@bb/logger";
import type {
AppDataPath,
- AppId,
+ ApplicationId,
PendingInteractionCreate,
PendingInteractionResolution,
ThreadEvent,
@@ -23,10 +25,16 @@ import type {
HostDaemonActiveThread,
HostDaemonEnvironmentChange,
HostDaemonLoadedEnvironment,
+ HostDaemonTrackedApplicationDataTarget,
HostDaemonTrackedThreadTarget,
+ HostDaemonInjectedSkillSource,
} from "@bb/host-daemon-contract";
import type {
+ ApplicationDataWatchTarget,
+ ApplicationStorageWatchError,
+ DataDirSkillsWatchError,
HostWatcher,
+ InjectedSkillsObservedChange,
ThreadStorageWatchError,
WorkspaceWatchError,
WorkspaceStatusWatchChangeKind,
@@ -36,6 +44,12 @@ import {
type HostWorkspace,
type ProvisionWorkspaceArgs,
} from "@bb/host-workspace";
+import {
+ cleanupInjectedSkillStagingDirs,
+ EMPTY_SKILL_CATALOG_HASH,
+ stageInjectedSkillSources,
+ type InjectedSkillsLogger,
+} from "./injected-skills.js";
type StopWatching = () => void | Promise;
@@ -56,6 +70,11 @@ interface ThreadStorageTarget {
threadId: string;
}
+interface ApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
interface WorkspaceWatchState {
lastLocalFingerprint: string | null;
lastSharedRefsFingerprint: string | null;
@@ -69,6 +88,30 @@ interface BuildUnexpectedProviderExitEventsArgs {
threads: Map;
}
+interface RuntimeSkillConfig {
+ catalogHash: string;
+ skillRoots: readonly AgentRuntimeSkillRoot[];
+}
+
+interface CreateEntryArgs extends EnsureEnvironmentArgs {
+ skillConfig: RuntimeSkillConfig | null;
+}
+
+interface ApplyExistingEnvironmentProvisionArgs {
+ entry: RuntimeEntry;
+ provision: ProvisionWorkspaceArgs | undefined;
+}
+
+interface EnsureCompatibleEntryArgs {
+ entry: RuntimeEntry;
+ skillConfig: RuntimeSkillConfig | null;
+}
+
+interface ReplaceEntryForSkillCatalogArgs {
+ entry: RuntimeEntry;
+ skillConfig: RuntimeSkillConfig;
+}
+
function lazyProvisionOpts(
environmentId: string,
workspacePath: string,
@@ -98,11 +141,10 @@ function lazyProvisionOpts(
}
}
-function toErrorMessage(error: unknown): string {
- if (error instanceof Error && error.message.trim().length > 0) {
- return error.message;
- }
- return "Unknown workspace watch error";
+function toErrorMessage(error: Error): string {
+ return error.message.trim().length > 0
+ ? error.message
+ : "Unknown workspace watch error";
}
function formatProviderProcessExitStatus(
@@ -149,6 +191,7 @@ function workspaceWatchKindsIncludeSharedRefs(
export interface RuntimeEntry {
environmentId: string;
runtime: AgentRuntime;
+ skillCatalogHash: string | null;
stopWatchingStatus: StopWatching;
workspace: HostWorkspace;
path: string;
@@ -156,38 +199,38 @@ export interface RuntimeEntry {
threads: Map;
}
-export interface ThreadAppDataChangedNotification {
- appId: AppId;
- environmentId: string;
+export interface ApplicationDataChangedNotification {
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
- threadStoragePath: string;
}
-export interface ThreadAppDataResyncNotification {
- appId: AppId;
- environmentId: string;
- threadId: string;
- threadStoragePath: string;
+export interface ApplicationDataResyncNotification {
+ applicationId: ApplicationId;
+}
+
+export interface InjectedSkillsChangedNotification {
+ applicationId: ApplicationId | null;
+ changedPaths: string[];
+ sourceType: InjectedSkillsObservedChange["sourceType"];
}
export interface EnsureEnvironmentArgs {
environmentId: string;
+ injectedSkillSources?: readonly HostDaemonInjectedSkillSource[];
personalWorkspaceRoot?: string;
workspacePath?: string;
workspaceProvisionType?: WorkspaceProvisionType;
provision?: ProvisionWorkspaceArgs;
}
-interface ApplyExistingEnvironmentProvisionArgs {
- entry: RuntimeEntry;
- provision: ProvisionWorkspaceArgs | undefined;
-}
-
export interface RuntimeManagerOptions {
bridgeBundleDir?: AgentRuntimeOptions["bridgeBundleDir"];
createRuntime?: (options: AgentRuntimeOptions) => AgentRuntime;
+ dataDir?: string;
+ dataDirSkillsRootPath?: string | null;
hostWatcher?: HostWatcher;
+ logger?: Pick;
provisionWorkspace?: (
options: ProvisionWorkspaceArgs,
) => Promise;
@@ -199,8 +242,17 @@ export interface RuntimeManagerOptions {
environmentId: string;
threadId: string;
}) => void;
- onThreadAppDataChanged?: (args: ThreadAppDataChangedNotification) => void;
- onThreadAppDataResync?: (args: ThreadAppDataResyncNotification) => void;
+ appsRootPath?: string | null;
+ onInjectedSkillsChanged?: (args: InjectedSkillsChangedNotification) => void;
+ onApplicationStorageTargetsChanged?: () => void;
+ onApplicationDataChanged?: (args: ApplicationDataChangedNotification) => void;
+ onApplicationDataResync?: (args: ApplicationDataResyncNotification) => void;
+ onApplicationStorageWatchError?: (args: {
+ error: ApplicationStorageWatchError;
+ }) => void;
+ onDataDirSkillsWatchError?: (args: {
+ error: DataDirSkillsWatchError;
+ }) => void;
onThreadStorageWatchError?: (args: {
error: ThreadStorageWatchError;
}) => void;
@@ -218,6 +270,7 @@ export interface RuntimeManagerOptions {
}
interface RuntimeWorkspaceWriteRootsArgs {
+ appsRootPath: string | null | undefined;
threadStorageRootPath: string | null | undefined;
workspaceRoots: readonly string[];
}
@@ -233,10 +286,16 @@ export class RuntimeManager {
string,
ThreadStorageTarget
>();
+ private readonly trackedApplicationDataTargets = new Map<
+ ApplicationId,
+ ApplicationDataTarget
+ >();
private providerMaintenanceRuntime: AgentRuntime | null = null;
private pendingProviderMaintenanceRuntime: Promise | null =
null;
private managedShellEnv: NonNullable = {};
+ private stopWatchingApplicationStorageRoot: StopWatching = STOP_WATCHING;
+ private stopWatchingDataDirSkillsRoot: StopWatching = STOP_WATCHING;
private stopWatchingThreadStorageRoot: StopWatching = STOP_WATCHING;
constructor(private readonly options: RuntimeManagerOptions = {}) {
@@ -244,12 +303,17 @@ export class RuntimeManager {
this.hostWatcher = options.hostWatcher;
this.provisionWorkspace = options.provisionWorkspace ?? provisionWorkspace;
this.baseShellEnv = { ...(options.shellEnv ?? {}) };
+ this.ensureApplicationStorageWatcher();
+ this.ensureDataDirSkillsWatcher();
}
private runtimeWorkspaceWriteRoots(
args: RuntimeWorkspaceWriteRootsArgs,
): string[] {
const roots = [...args.workspaceRoots];
+ if (args.appsRootPath) {
+ roots.push(args.appsRootPath);
+ }
if (args.threadStorageRootPath) {
// Provider runtimes are environment-scoped and may host multiple threads.
// BB_THREAD_STORAGE still points agents at their own thread subdirectory;
@@ -363,7 +427,10 @@ export class RuntimeManager {
error: {
environmentId: args.environmentId,
kind: "workspace-watch-error",
- message: toErrorMessage(error),
+ message:
+ error instanceof Error
+ ? toErrorMessage(error)
+ : "Unknown workspace watch error",
rootPath: args.workspacePath,
},
});
@@ -492,6 +559,107 @@ export class RuntimeManager {
};
}
+ private getInjectedSkillsLogger(): InjectedSkillsLogger | undefined {
+ return this.options.logger;
+ }
+
+ private async resolveRuntimeSkillConfig(
+ args: EnsureEnvironmentArgs,
+ ): Promise {
+ if (args.injectedSkillSources === undefined) {
+ return null;
+ }
+ if (args.injectedSkillSources.length === 0) {
+ return {
+ catalogHash: EMPTY_SKILL_CATALOG_HASH,
+ skillRoots: [],
+ };
+ }
+ if (!this.options.dataDir) {
+ throw new Error("Runtime skill staging requires a host dataDir");
+ }
+ return stageInjectedSkillSources({
+ dataDir: this.options.dataDir,
+ injectedSkillSources: args.injectedSkillSources,
+ logger: this.getInjectedSkillsLogger(),
+ });
+ }
+
+ private entryHasActiveRuntimeWork(entry: RuntimeEntry): boolean {
+ if (entry.terminals.size > 0) {
+ return true;
+ }
+ for (const thread of entry.threads.values()) {
+ if (thread.status === "active") {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private async cleanupUnusedInjectedSkillStagingDirs(): Promise {
+ if (!this.options.dataDir) {
+ return;
+ }
+ try {
+ await cleanupInjectedSkillStagingDirs({
+ dataDir: this.options.dataDir,
+ keepCatalogHashes: [...this.entries.values()].flatMap((entry) =>
+ entry.skillCatalogHash === null ? [] : [entry.skillCatalogHash],
+ ),
+ logger: this.getInjectedSkillsLogger(),
+ });
+ } catch (error) {
+ this.options.logger?.warn(
+ {
+ reason:
+ error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "Unable to clean injected skill staging directories",
+ },
+ "Failed to clean injected skill staging directories",
+ );
+ }
+ }
+
+ private async replaceEntryForSkillCatalog(
+ args: ReplaceEntryForSkillCatalogArgs,
+ ): Promise {
+ if (this.entryHasActiveRuntimeWork(args.entry)) {
+ throw new Error(
+ `Environment ${args.entry.environmentId} already has an active runtime with injected skill catalog ${args.entry.skillCatalogHash ?? "none"}; requested ${args.skillConfig.catalogHash}`,
+ );
+ }
+
+ this.entries.delete(args.entry.environmentId);
+ this.removeTrackedThreadStorageTargetsForEnvironment(
+ args.entry.environmentId,
+ );
+ this.stopWatchingThreadStorageIfNoTrackedThreads();
+ await this.stopWatchingStatus(args.entry);
+ await args.entry.runtime.shutdown();
+ await this.cleanupUnusedInjectedSkillStagingDirs();
+ }
+
+ private async ensureCompatibleEntry(
+ args: EnsureCompatibleEntryArgs,
+ ): Promise {
+ if (
+ args.skillConfig === null ||
+ args.entry.skillCatalogHash === args.skillConfig.catalogHash ||
+ (args.entry.skillCatalogHash === null &&
+ args.skillConfig.skillRoots.length === 0)
+ ) {
+ return args.entry;
+ }
+
+ await this.replaceEntryForSkillCatalog({
+ entry: args.entry,
+ skillConfig: args.skillConfig,
+ });
+ return null;
+ }
+
replaceManagedShellEnv(
shellEnv: NonNullable,
): void {
@@ -515,6 +683,19 @@ export class RuntimeManager {
this.stopWatchingThreadStorageIfNoTrackedThreads();
}
+ replaceTrackedApplicationDataTargets(
+ targets: readonly HostDaemonTrackedApplicationDataTarget[],
+ ): void {
+ this.trackedApplicationDataTargets.clear();
+ for (const target of targets) {
+ this.trackedApplicationDataTargets.set(target.applicationId, {
+ applicationId: target.applicationId,
+ appDataPath: target.appDataPath,
+ });
+ }
+ this.ensureApplicationStorageWatcher();
+ }
+
async openWorkspace(path: string): Promise {
return this.provisionWorkspace({
workspaceProvisionType: "unmanaged",
@@ -545,28 +726,48 @@ export class RuntimeManager {
}
async ensureEnvironment(args: EnsureEnvironmentArgs): Promise {
+ const skillConfig = await this.resolveRuntimeSkillConfig(args);
const existing = this.entries.get(args.environmentId);
if (existing) {
await this.applyExistingEnvironmentProvision({
entry: existing,
provision: args.provision,
});
- return existing;
+ const compatible = await this.ensureCompatibleEntry({
+ entry: existing,
+ skillConfig,
+ });
+ if (compatible) {
+ return compatible;
+ }
}
const pending = this.pendingEntries.get(args.environmentId);
if (pending) {
- return pending;
+ const entry = await pending;
+ const compatible = await this.ensureCompatibleEntry({
+ entry,
+ skillConfig,
+ });
+ if (compatible) {
+ return compatible;
+ }
}
- const creation = this.createEntry(args).finally(() => {
- this.pendingEntries.delete(args.environmentId);
- });
+ const creation = this.createEntry({
+ ...args,
+ skillConfig,
+ })
+ .then((entry) => {
+ this.entries.set(args.environmentId, entry);
+ return entry;
+ })
+ .finally(() => {
+ this.pendingEntries.delete(args.environmentId);
+ });
this.pendingEntries.set(args.environmentId, creation);
- const entry = await creation;
- this.entries.set(args.environmentId, entry);
- return entry;
+ return creation;
}
private async applyExistingEnvironmentProvision(
@@ -606,6 +807,7 @@ export class RuntimeManager {
this.stopWatchingThreadStorageIfNoTrackedThreads();
await entry.runtime.shutdown();
await entry.workspace.destroy();
+ await this.cleanupUnusedInjectedSkillStagingDirs();
}
async forgetEnvironment(environmentId: string): Promise {
@@ -630,6 +832,7 @@ export class RuntimeManager {
this.entries.delete(environmentId);
await this.stopWatchingStatus(entry);
await entry.runtime.shutdown();
+ await this.cleanupUnusedInjectedSkillStagingDirs();
}
async evictIdleEnvironments(): Promise {
@@ -665,6 +868,7 @@ export class RuntimeManager {
throw firstRejected.reason;
}
+ await this.cleanupUnusedInjectedSkillStagingDirs();
return shutdownResults.flatMap((result) =>
result.status === "fulfilled" ? [result.value] : [],
);
@@ -682,6 +886,7 @@ export class RuntimeManager {
this.entries.clear();
this.pendingEntries.clear();
this.trackedThreadStorageTargets.clear();
+ this.trackedApplicationDataTargets.clear();
for (const entry of entries) {
await this.stopWatchingStatus(entry);
@@ -702,6 +907,11 @@ export class RuntimeManager {
}
await this.stopWatchingThreadStorageRoot();
this.stopWatchingThreadStorageRoot = STOP_WATCHING;
+ await this.stopWatchingApplicationStorageRoot();
+ this.stopWatchingApplicationStorageRoot = STOP_WATCHING;
+ await this.stopWatchingDataDirSkillsRoot();
+ this.stopWatchingDataDirSkillsRoot = STOP_WATCHING;
+ await this.cleanupUnusedInjectedSkillStagingDirs();
}
private buildUnexpectedProviderExitEvents(
@@ -756,7 +966,9 @@ export class RuntimeManager {
let runtime: AgentRuntime | null = null;
runtime = this.createRuntime({
workspacePath,
- additionalWorkspaceWriteRoots: [],
+ additionalWorkspaceWriteRoots: this.options.appsRootPath
+ ? [this.options.appsRootPath]
+ : [],
shellEnv: this.getShellEnv(),
threadStorageRootPath: this.options.threadStorageRootPath ?? undefined,
bridgeBundleDir: this.options.bridgeBundleDir,
@@ -789,9 +1001,7 @@ export class RuntimeManager {
return runtime;
}
- private async createEntry(
- args: EnsureEnvironmentArgs,
- ): Promise {
+ private async createEntry(args: CreateEntryArgs): Promise {
const provision =
args.provision ??
(args.workspacePath
@@ -815,6 +1025,7 @@ export class RuntimeManager {
workspace.getAdditionalWorkspaceWriteRoots(),
]);
const additionalWorkspaceWriteRoots = this.runtimeWorkspaceWriteRoots({
+ appsRootPath: this.options.appsRootPath,
threadStorageRootPath: this.options.threadStorageRootPath,
workspaceRoots: workspaceWriteRoots,
});
@@ -844,6 +1055,9 @@ export class RuntimeManager {
runtime = this.createRuntime({
workspacePath: workspace.path,
additionalWorkspaceWriteRoots,
+ ...(args.skillConfig
+ ? { skillRoots: args.skillConfig.skillRoots }
+ : {}),
shellEnv: this.getShellEnv(),
threadStorageRootPath: this.options.threadStorageRootPath ?? undefined,
bridgeBundleDir: this.options.bridgeBundleDir,
@@ -917,6 +1131,7 @@ export class RuntimeManager {
return {
environmentId: args.environmentId,
runtime,
+ skillCatalogHash: args.skillConfig?.catalogHash ?? null,
stopWatchingStatus,
terminals: new Set(),
workspace,
@@ -956,32 +1171,90 @@ export class RuntimeManager {
threadId: event.threadId,
});
}
- if (event.kind === "thread-app-data-changed") {
- this.options.onThreadAppDataChanged?.({
- appId: event.appId,
- environmentId: event.environmentId,
+ },
+ onWatchError: (error) => {
+ this.options.onThreadStorageWatchError?.({
+ error,
+ });
+ },
+ });
+ }
+
+ private ensureApplicationStorageWatcher(): void {
+ if (
+ !this.hostWatcher ||
+ this.stopWatchingApplicationStorageRoot !== STOP_WATCHING
+ ) {
+ return;
+ }
+
+ const appsRootPath = this.options.appsRootPath;
+ if (!appsRootPath) {
+ return;
+ }
+
+ this.stopWatchingApplicationStorageRoot =
+ this.hostWatcher.watchApplicationStorageRoot({
+ appsRootPath,
+ resolveApplicationTarget: (applicationId) =>
+ this.findTrackedApplicationDataTarget(applicationId),
+ onChange: (event) => {
+ if (event.kind === "application-storage-targets-changed") {
+ this.options.onApplicationStorageTargetsChanged?.();
+ }
+ if (event.kind === "application-data-changed") {
+ this.options.onApplicationDataChanged?.({
+ applicationId: event.applicationId,
+ appDataPath: event.appDataPath,
path: event.path,
- threadId: event.threadId,
- threadStoragePath: path.join(
- threadStorageRootPath,
- event.threadId,
- ),
});
}
- if (event.kind === "thread-app-data-resync") {
- this.options.onThreadAppDataResync?.({
- appId: event.appId,
- environmentId: event.environmentId,
- threadId: event.threadId,
- threadStoragePath: path.join(
- threadStorageRootPath,
- event.threadId,
- ),
+ if (event.kind === "application-data-resync") {
+ this.options.onApplicationDataResync?.({
+ applicationId: event.applicationId,
+ });
+ }
+ if (event.kind === "injected-skills-changed") {
+ this.options.onInjectedSkillsChanged?.({
+ applicationId: event.applicationId,
+ changedPaths: event.changedPaths,
+ sourceType: event.sourceType,
});
}
},
onWatchError: (error) => {
- this.options.onThreadStorageWatchError?.({
+ this.options.onApplicationStorageWatchError?.({
+ error,
+ });
+ },
+ });
+ }
+
+ private ensureDataDirSkillsWatcher(): void {
+ if (
+ !this.hostWatcher?.watchDataDirSkillsRoot ||
+ this.stopWatchingDataDirSkillsRoot !== STOP_WATCHING
+ ) {
+ return;
+ }
+
+ const dataDirSkillsRootPath = this.options.dataDirSkillsRootPath;
+ if (!dataDirSkillsRootPath) {
+ return;
+ }
+
+ this.stopWatchingDataDirSkillsRoot =
+ this.hostWatcher.watchDataDirSkillsRoot({
+ dataDirSkillsRootPath,
+ onChange: (event) => {
+ this.options.onInjectedSkillsChanged?.({
+ applicationId: event.applicationId,
+ changedPaths: event.changedPaths,
+ sourceType: event.sourceType,
+ });
+ },
+ onWatchError: (error) => {
+ this.options.onDataDirSkillsWatchError?.({
error,
});
},
@@ -994,6 +1267,12 @@ export class RuntimeManager {
return this.trackedThreadStorageTargets.get(threadId) ?? null;
}
+ private findTrackedApplicationDataTarget(
+ applicationId: ApplicationId,
+ ): ApplicationDataWatchTarget | null {
+ return this.trackedApplicationDataTargets.get(applicationId) ?? null;
+ }
+
private removeTrackedThreadStorageTargetsForEnvironment(
environmentId: string,
): void {
diff --git a/apps/host-daemon/src/runtime-shell-env.test.ts b/apps/host-daemon/src/runtime-shell-env.test.ts
index c9a90bd08..1dd25dbd5 100644
--- a/apps/host-daemon/src/runtime-shell-env.test.ts
+++ b/apps/host-daemon/src/runtime-shell-env.test.ts
@@ -142,6 +142,7 @@ describe("prepareRuntimeShellEnv", () => {
it("prepends the configured bb executable directory to PATH", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
hostDaemonPort: 3002,
inheritedPath: "/usr/bin",
@@ -149,6 +150,7 @@ describe("prepareRuntimeShellEnv", () => {
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
BB_HOST_DAEMON_PORT: "3002",
});
@@ -159,12 +161,14 @@ describe("prepareRuntimeShellEnv", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
hostDaemonPort: 3002,
serverUrl: "http://127.0.0.1:3334",
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/local/bin:/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
BB_HOST_DAEMON_PORT: "3002",
});
@@ -173,12 +177,14 @@ describe("prepareRuntimeShellEnv", () => {
it("omits the host daemon port when the local API is disabled", () => {
expect(
prepareRuntimeShellEnv({
+ appsRootPath: "/tmp/bb-data/apps",
bbExecutableDirectory: "/tmp/bb-bin",
inheritedPath: "/usr/bin",
serverUrl: "http://127.0.0.1:3334",
}),
).toEqual({
PATH: `/tmp/bb-bin${delimiter}/usr/bin`,
+ BB_APPS_ROOT: "/tmp/bb-data/apps",
BB_SERVER_URL: "http://127.0.0.1:3334",
});
});
diff --git a/apps/host-daemon/src/runtime-shell-env.ts b/apps/host-daemon/src/runtime-shell-env.ts
index 17b017d7d..b53c6dd49 100644
--- a/apps/host-daemon/src/runtime-shell-env.ts
+++ b/apps/host-daemon/src/runtime-shell-env.ts
@@ -10,6 +10,7 @@ export interface ResolveLocalBbExecutableDirectoryOptions {
}
export interface PrepareRuntimeShellEnvOptions {
+ appsRootPath: string;
bbExecutableDirectory: string;
hostDaemonPort?: number;
serverUrl: string;
@@ -97,6 +98,7 @@ export function prepareRuntimeShellEnv(
options.bbExecutableDirectory,
options.inheritedPath ?? process.env.PATH,
),
+ BB_APPS_ROOT: options.appsRootPath,
BB_SERVER_URL: options.serverUrl,
};
assignIfDefined({
diff --git a/apps/host-daemon/src/server-connection.ts b/apps/host-daemon/src/server-connection.ts
index fe4575926..6fb1541b2 100644
--- a/apps/host-daemon/src/server-connection.ts
+++ b/apps/host-daemon/src/server-connection.ts
@@ -51,6 +51,10 @@ type HostDaemonEnvironmentChangeMessage = Extract<
{ type: "environment-change" }
>;
+const APPLICATION_STORAGE_CHANGED_MESSAGE = {
+ type: "application-storage-changed",
+} satisfies HostDaemonDaemonWsMessage;
+
function environmentChangeMessageKey(
message: HostDaemonEnvironmentChangeMessage,
): string {
@@ -96,6 +100,7 @@ export class ServerConnection {
string,
HostDaemonEnvironmentChangeMessage
>();
+ private pendingApplicationStorageChanged = false;
constructor(private readonly options: ServerConnectionOptions) {
this.sessionCloseHandler = options.onSessionClose;
@@ -133,6 +138,7 @@ export class ServerConnection {
async shutdown(): Promise {
this.stopped = true;
this.pendingEnvironmentChanges.clear();
+ this.pendingApplicationStorageChanged = false;
this.stopPollingFallback();
this.clearHeartbeat();
this.clearSession();
@@ -157,6 +163,9 @@ export class ServerConnection {
if (payload.type === "environment-change") {
this.pendingEnvironmentChanges.delete(environmentChangeMessageKey(payload));
}
+ if (payload.type === "application-storage-changed") {
+ this.pendingApplicationStorageChanged = false;
+ }
return true;
}
@@ -281,7 +290,7 @@ export class ServerConnection {
"Connected to server",
);
await this.options.onSessionOpened?.(session);
- this.flushPendingEnvironmentChanges();
+ this.flushPendingRecoverableMessages();
if (!settled) {
settled = true;
resolve(session);
@@ -344,21 +353,26 @@ export class ServerConnection {
private bufferMessageIfRecoverable(
message: HostDaemonDaemonWsMessage,
): void {
- if (message.type !== "environment-change") {
- return;
+ if (message.type === "environment-change") {
+ this.pendingEnvironmentChanges.set(
+ environmentChangeMessageKey(message),
+ message,
+ );
+ }
+ if (message.type === "application-storage-changed") {
+ this.pendingApplicationStorageChanged = true;
}
- this.pendingEnvironmentChanges.set(
- environmentChangeMessageKey(message),
- message,
- );
}
- private flushPendingEnvironmentChanges(): void {
+ private flushPendingRecoverableMessages(): void {
for (const message of Array.from(this.pendingEnvironmentChanges.values())) {
if (!this.sendMessage(message)) {
return;
}
}
+ if (this.pendingApplicationStorageChanged) {
+ this.sendMessage(APPLICATION_STORAGE_CHANGED_MESSAGE);
+ }
}
private handleWebSocketMessage(data: unknown): void {
diff --git a/apps/host-daemon/src/start-host-daemon.ts b/apps/host-daemon/src/start-host-daemon.ts
index d6790e116..a74feabe7 100644
--- a/apps/host-daemon/src/start-host-daemon.ts
+++ b/apps/host-daemon/src/start-host-daemon.ts
@@ -3,6 +3,7 @@ import {
loadHostDaemonStartConfig,
type HostDaemonConnectionConfig,
} from "@bb/config/host-daemon";
+import { resolveAppsRootPath } from "@bb/config/app-storage-paths";
import type { HostType, ToolCallRequest, ToolCallResponse } from "@bb/domain";
import { createHostWatcher, type HostWatcher } from "@bb/host-watcher";
import { createLogger } from "@bb/logger";
@@ -158,6 +159,7 @@ export async function startHostDaemon(
hostType,
}));
const runtimeShellEnv = prepareRuntimeShellEnv({
+ appsRootPath: resolveAppsRootPath(dataDir),
bbExecutableDirectory,
hostDaemonPort: localApiConfig?.port,
serverUrl,
diff --git a/apps/host-daemon/src/workspace-resolution.ts b/apps/host-daemon/src/workspace-resolution.ts
index 402793cbc..40f52c950 100644
--- a/apps/host-daemon/src/workspace-resolution.ts
+++ b/apps/host-daemon/src/workspace-resolution.ts
@@ -1,4 +1,5 @@
import type {
+ HostDaemonInjectedSkillSource,
WorkspaceContext,
WorkspaceResolutionFailure,
WorkspaceResolutionFailureCode,
@@ -29,6 +30,7 @@ interface WorkspaceResolutionFailureFromErrorArgs {
interface ResolveWorkspaceForCommandArgs {
dataDir?: string;
environmentId: string;
+ injectedSkillSources?: readonly HostDaemonInjectedSkillSource[];
requireGit?: boolean;
requireManagedWorktree?: boolean;
runtimeManager: RuntimeManager;
@@ -126,6 +128,9 @@ export async function resolveWorkspaceForCommand(
{
dataDir: args.dataDir,
environmentId: args.environmentId,
+ ...(args.injectedSkillSources !== undefined
+ ? { injectedSkillSources: args.injectedSkillSources }
+ : {}),
workspaceContext: args.workspaceContext,
},
args.runtimeManager,
diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts
index c9d2a7608..d88c2883c 100644
--- a/apps/host-daemon/test/command/command-router.test.ts
+++ b/apps/host-daemon/test/command/command-router.test.ts
@@ -266,6 +266,7 @@ function createStandardRuntimeCommandContext(args: {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
};
}
@@ -1417,6 +1418,7 @@ describe("CommandRouter", () => {
providerThreadId: "provider-a",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
},
target: { mode: "start" },
@@ -1449,6 +1451,7 @@ describe("CommandRouter", () => {
providerThreadId: "provider-b",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
},
target: { mode: "start" },
diff --git a/apps/host-daemon/test/command/thread-dispatch.test.ts b/apps/host-daemon/test/command/thread-dispatch.test.ts
index 38e1712ad..7ddd1ce34 100644
--- a/apps/host-daemon/test/command/thread-dispatch.test.ts
+++ b/apps/host-daemon/test/command/thread-dispatch.test.ts
@@ -67,6 +67,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
};
@@ -126,6 +127,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-thread-stale-turn",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
},
@@ -183,6 +185,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -291,6 +294,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-submit-attachments",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -356,6 +360,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -413,6 +418,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -471,6 +477,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -538,6 +545,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -595,6 +603,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
{
@@ -653,6 +662,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-runtime-failed-turn-attachments",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -700,6 +710,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
harness.dispatchOptions(),
@@ -827,6 +838,7 @@ describe("thread command dispatch", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
harness.dispatchOptions(),
@@ -877,6 +889,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-thread-resume-after-archive",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -957,6 +970,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -987,6 +1001,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "auto", expectedTurnId: "turn-1" },
@@ -1063,6 +1078,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1096,6 +1112,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1146,6 +1163,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "auto", expectedTurnId: "turn-1" },
@@ -1203,6 +1221,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "auto", expectedTurnId: "turn-old" },
@@ -1254,6 +1273,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: "turn-old" },
@@ -1299,6 +1319,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: null },
@@ -1339,6 +1360,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1410,6 +1432,7 @@ describe("thread command dispatch", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1578,6 +1601,7 @@ describe("thread command dispatch", () => {
},
},
],
+ injectedSkillSources: [],
instructionMode: "replace",
},
harness.dispatchOptions(),
@@ -1616,6 +1640,7 @@ describe("thread command dispatch", () => {
},
instructions: "test",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
threadStoragePath: storagePath,
},
@@ -1651,6 +1676,7 @@ describe("thread command dispatch", () => {
},
instructions: "test",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
harness.dispatchOptions(),
@@ -1760,6 +1786,7 @@ describe("thread command dispatch", () => {
},
instructions: "test",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
threadStoragePath: "/tmp/evil-escape",
},
diff --git a/apps/host-daemon/test/connection/server-connection.test.ts b/apps/host-daemon/test/connection/server-connection.test.ts
index e0fc4e245..e1e7f55c1 100644
--- a/apps/host-daemon/test/connection/server-connection.test.ts
+++ b/apps/host-daemon/test/connection/server-connection.test.ts
@@ -174,6 +174,7 @@ describe("ServerConnection", () => {
providerThreadId: "provider-1",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -582,6 +583,33 @@ describe("ServerConnection", () => {
await connection.shutdown();
});
+ it("sends application storage change hints over the daemon websocket", async () => {
+ testServer = await createTestServer();
+ const server = testServer;
+ const { connection } = createConnection(server);
+
+ await connection.start();
+ expect(
+ connection.sendMessage({
+ type: "application-storage-changed",
+ }),
+ ).toBe(true);
+
+ await waitFor(() =>
+ server.heartbeats.some(
+ (entry) => entry.message.type === "application-storage-changed",
+ ),
+ );
+ expect(server.heartbeats).toContainEqual({
+ sessionId: "session-1",
+ message: {
+ type: "application-storage-changed",
+ },
+ });
+
+ await connection.shutdown();
+ });
+
it("buffers deduplicated environment change hints while disconnected and flushes after reconnect", async () => {
testServer = await createTestServer();
const server = testServer;
@@ -643,6 +671,46 @@ describe("ServerConnection", () => {
await connection.shutdown();
});
+ it("buffers one application storage change hint while disconnected and flushes after reconnect", async () => {
+ testServer = await createTestServer();
+ const server = testServer;
+ const { connection } = createConnection(server);
+
+ expect(
+ connection.sendMessage({
+ type: "application-storage-changed",
+ }),
+ ).toBe(false);
+ expect(
+ connection.sendMessage({
+ type: "application-storage-changed",
+ }),
+ ).toBe(false);
+ expect(server.heartbeats).toEqual([]);
+
+ await connection.start();
+ await waitFor(
+ () =>
+ server.heartbeats.filter(
+ (entry) => entry.message.type === "application-storage-changed",
+ ).length === 1,
+ );
+ expect(
+ server.heartbeats.filter(
+ (entry) => entry.message.type === "application-storage-changed",
+ ),
+ ).toEqual([
+ {
+ sessionId: "session-1",
+ message: {
+ type: "application-storage-changed",
+ },
+ },
+ ]);
+
+ await connection.shutdown();
+ });
+
it("includes active threads when opening the session", async () => {
testServer = await createTestServer();
const activeThreads: HostDaemonActiveThread[] = [
diff --git a/apps/host-daemon/test/helpers/test-server.ts b/apps/host-daemon/test/helpers/test-server.ts
index 47138ab09..30396c702 100644
--- a/apps/host-daemon/test/helpers/test-server.ts
+++ b/apps/host-daemon/test/helpers/test-server.ts
@@ -266,6 +266,7 @@ export async function createTestServer(
heartbeatIntervalMs: options.heartbeatIntervalMs ?? 25,
leaseTimeoutMs: options.leaseTimeoutMs ?? 1_000,
trackedThreadTargets: options.trackedThreadTargets ?? [],
+ trackedApplicationDataTargets: [],
retiredEnvironmentIds: [],
},
201,
diff --git a/apps/host-daemon/test/integration/daemon.integration.test.ts b/apps/host-daemon/test/integration/daemon.integration.test.ts
index 9bd85ef2b..49efd7e72 100644
--- a/apps/host-daemon/test/integration/daemon.integration.test.ts
+++ b/apps/host-daemon/test/integration/daemon.integration.test.ts
@@ -496,6 +496,7 @@ function createStandardThreadStartCommand(args: {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
};
}
@@ -533,6 +534,7 @@ function createTurnSubmitCommand(args: {
providerThreadId: args.providerThreadId,
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
},
target: { mode: "start" as const },
diff --git a/apps/server/package.json b/apps/server/package.json
index 9ad631528..83e6eb8d5 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
- "build": "node ../../scripts/build-node-entry.mjs src/index.ts dist/index.js --clean-dist --templates --external ./start-server.js --copy-dir ../../packages/db/drizzle dist/drizzle --copy-dir src/services/threads/default-template dist/default-template && node ../../scripts/build-node-entry.mjs src/start-server.ts dist/start-server.js",
+ "build": "node ../../scripts/build-node-entry.mjs src/index.ts dist/index.js --clean-dist --templates --external ./start-server.js --copy-dir ../../packages/db/drizzle dist/drizzle && node ../../scripts/build-node-entry.mjs src/start-server.ts dist/start-server.js",
"bench": "vitest bench --config vitest.config.ts",
"start": "node dist/index.js",
"start:prod": "cross-env NODE_ENV=production node dist/index.js",
diff --git a/apps/server/src/internal/app-data-changes.ts b/apps/server/src/internal/app-data-changes.ts
index 13a70484c..e8db5f3f4 100644
--- a/apps/server/src/internal/app-data-changes.ts
+++ b/apps/server/src/internal/app-data-changes.ts
@@ -7,46 +7,10 @@ import {
import type { Hono } from "hono";
import type { AppDeps } from "../types.js";
import { ApiError } from "../errors.js";
-import {
- requireEnvironment,
- requirePublicThread,
-} from "../services/lib/entity-lookup.js";
import {
requireAuthenticatedDaemonSession,
- type RequireAuthenticatedDaemonSessionArgs,
} from "./session-state.js";
-interface RequireSessionOwnedThreadArgs {
- context: RequireAuthenticatedDaemonSessionArgs["context"];
- deps: AppDeps;
- sessionId: string;
- threadId: string;
-}
-
-function requireSessionOwnedThread(args: RequireSessionOwnedThreadArgs): void {
- const session = requireAuthenticatedDaemonSession({
- context: args.context,
- db: args.deps.db,
- sessionId: args.sessionId,
- });
- const thread = requirePublicThread(args.deps.db, args.threadId);
- if (!thread.environmentId) {
- throw new ApiError(
- 403,
- "invalid_request",
- "Thread does not belong to an environment",
- );
- }
- const environment = requireEnvironment(args.deps.db, thread.environmentId);
- if (environment.hostId !== session.hostId) {
- throw new ApiError(
- 403,
- "invalid_request",
- "Thread does not belong to the session host",
- );
- }
-}
-
export function registerInternalAppDataChangeRoutes(
app: Hono,
deps: AppDeps,
@@ -59,17 +23,15 @@ export function registerInternalAppDataChangeRoutes(
"/session/app-data-change",
hostDaemonAppDataChangeRequestSchema,
async (context, payload) => {
- requireSessionOwnedThread({
+ requireAuthenticatedDaemonSession({
context,
- deps,
+ db: deps.db,
sessionId: payload.sessionId,
- threadId: payload.threadId,
});
- deps.hub.notifyThreadAppData({
+ deps.hub.notifyAppData({
type: "app-data.changed",
- threadId: payload.threadId,
- appId: payload.appId,
+ applicationId: payload.applicationId,
path: payload.path,
value: payload.value,
deleted: payload.deleted,
@@ -83,17 +45,15 @@ export function registerInternalAppDataChangeRoutes(
"/session/app-data-resync",
hostDaemonAppDataResyncRequestSchema,
async (context, payload) => {
- requireSessionOwnedThread({
+ requireAuthenticatedDaemonSession({
context,
- deps,
+ db: deps.db,
sessionId: payload.sessionId,
- threadId: payload.threadId,
});
- deps.hub.notifyThreadAppData({
+ deps.hub.notifyAppData({
type: "app-data.resync",
- threadId: payload.threadId,
- appId: payload.appId,
+ applicationId: payload.applicationId,
});
return context.json({ ok: true });
},
diff --git a/apps/server/src/internal/session.ts b/apps/server/src/internal/session.ts
index 61a17fb98..f8f6f9dc7 100644
--- a/apps/server/src/internal/session.ts
+++ b/apps/server/src/internal/session.ts
@@ -24,6 +24,7 @@ import {
import { requireAuthenticatedDaemonSession } from "./session-state.js";
import { readAttachment } from "../services/projects/attachments.js";
import { handleHostSessionOpened } from "./session-owner-side-effects.js";
+import { listTrackedApplicationDataTargets } from "../services/apps/tracked-application-data-targets.js";
export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
const { get, post } = typedRoutes(app, {
@@ -73,6 +74,11 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
leaseTimeoutMs: LEASE_TIMEOUT_MS,
});
+ const trackedApplicationDataTargets =
+ await listTrackedApplicationDataTargets({
+ dataDir: session.dataDir,
+ });
+
await handleHostSessionOpened(deps, {
activeThreads: payload.activeThreads,
hostId: daemon.hostId,
@@ -103,6 +109,7 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void {
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
leaseTimeoutMs: LEASE_TIMEOUT_MS,
trackedThreadTargets,
+ trackedApplicationDataTargets,
retiredEnvironmentIds,
},
201,
diff --git a/apps/server/src/routes/apps.ts b/apps/server/src/routes/apps.ts
new file mode 100644
index 000000000..085e8d47e
--- /dev/null
+++ b/apps/server/src/routes/apps.ts
@@ -0,0 +1,1587 @@
+import { Buffer } from "node:buffer";
+import { createHash, randomUUID } from "node:crypto";
+import {
+ mkdir,
+ readdir,
+ readFile,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
+import type { Dirent, Stats } from "node:fs";
+import path from "node:path";
+import mimeTypes from "mime-types";
+import type { Hono } from "hono";
+import type { ZodIssue } from "zod";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveApplicationPublicPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import {
+ appDataPathSchema,
+ applicationIdSchema,
+ deriveApplicationIdFromName,
+ jsonValueSchema,
+} from "@bb/domain";
+import type { AppDataPath, ApplicationId, JsonValue } from "@bb/domain";
+import {
+ appDataListQuerySchema,
+ appDataWriteRequestSchema,
+ appManifestSchema,
+ appMessageRequestSchema,
+ createAppRequestSchema,
+ typedRoutes,
+ type AppCapability,
+ type AppDataEntry,
+ type AppDetail,
+ type AppEntry,
+ type AppIcon,
+ type AppManifest,
+ type AppSummary,
+ type CreateAppRequest,
+ type PublicApiSchema,
+} from "@bb/server-contract";
+import type { AppDeps, LoggedWorkSessionDeps } from "../types.js";
+import { ApiError } from "../errors.js";
+import { requirePublicThread } from "../services/lib/entity-lookup.js";
+import { requireThreadCommandEnvironment } from "../services/threads/thread-command-environment.js";
+import { sendThreadMessage } from "../services/threads/thread-send.js";
+import { injectAppClientScript } from "../services/threads/app-client-script.js";
+import { buildBlankAppIndexHtml } from "../services/threads/blank-app-scaffold.js";
+import {
+ extractRoutePath,
+ parseSafeRelativeRoutePath,
+ type SafeRelativeRoutePath,
+} from "./relative-route-path.js";
+
+interface InvalidAppManifestErrorArgs extends ApplicationManifestReadArgs {
+ issues: AppManifestValidationIssues;
+ manifestPath: string;
+}
+
+interface LogInvalidAppManifestArgs {
+ error: InvalidAppManifestError;
+ message: string;
+}
+
+interface ApplicationManifestReadArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+}
+
+interface ApplicationSummaryArgs extends ApplicationManifestReadArgs {
+ manifest: AppManifest;
+}
+
+interface ApplicationDetailArgs extends ApplicationManifestReadArgs {}
+
+interface ReadApplicationRelativeFileArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dotfiles: "allow" | "deny";
+ path: string;
+ rootKind: "app" | "data" | "public";
+}
+
+interface ApplicationRootForKindArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ rootKind: "app" | "data" | "public";
+}
+
+interface ReadApplicationDataEntryArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dataPath: AppDataPath;
+}
+
+interface ListApplicationDataEntriesArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ dataPath: AppDataPath | "";
+}
+
+interface WriteApplicationDataEntryArgs extends ReadApplicationDataEntryArgs {
+ value: JsonValue;
+}
+
+interface DeleteApplicationDataEntryArgs extends ReadApplicationDataEntryArgs {}
+
+interface CreateInjectedAppHtmlResponseArgs {
+ appSessionToken: AppSessionToken | null;
+ capabilities: AppCapability[];
+ html: string;
+ applicationId: ApplicationId;
+ requestUrl: string;
+ targetThreadId: string | null;
+}
+
+interface ApplicationRouteSegmentArgs {
+ applicationId: string;
+}
+
+interface ServeApplicationStaticFileArgs {
+ deps: AppDeps;
+ rawApplicationId: string;
+ rawPath: string;
+}
+
+interface ApplicationStaticFile {
+ bytes: Buffer;
+ path: string;
+}
+
+interface ReadApplicationStaticFileArgs {
+ applicationId: ApplicationId;
+ staticPath: ApplicationStaticPath;
+ dataDir: string;
+}
+
+interface LogoResolution {
+ extension: string;
+}
+
+interface CreateGlobalApplicationArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ name: string;
+}
+
+interface CreateApplicationTempRootArgs {
+ appsRootPath: string;
+ applicationId: ApplicationId;
+}
+
+interface PublishApplicationTempRootArgs {
+ applicationPath: string;
+ tempRootPath: string;
+}
+
+interface AppSession {
+ applicationId: ApplicationId;
+ projectId: string;
+ threadId: string;
+}
+
+interface ResolveAppMessageTargetArgs {
+ applicationId: ApplicationId;
+ payload: {
+ appSessionToken?: string;
+ targetThreadId?: string;
+ };
+ sessions: AppSessionStore;
+}
+
+interface CreateAppSessionArgs {
+ applicationId: ApplicationId;
+ projectId: string;
+ threadId: string;
+}
+
+interface AppSessionStore {
+ create(args: CreateAppSessionArgs): AppSessionToken;
+ get(token: string): AppSession | null;
+}
+
+type ApplicationStaticPath = SafeRelativeRoutePath;
+type AppManifestValidationIssues = readonly ZodIssue[];
+type AppManifestValidationLoggerDeps = Pick;
+type GlobalAppListDeps = Pick;
+type AppSessionToken = string;
+type LogoExtension = "svg" | "png" | "jpg" | "jpeg";
+
+const DATA_DIRECTORY_NAME = "data";
+const MANIFEST_FILE_NAME = "manifest.json";
+const PUBLIC_DIRECTORY_NAME = "public";
+const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
+const NO_STORE_CACHE_CONTROL = "no-store";
+const CONTENT_TYPE_OPTIONS = "nosniff";
+const HTML_ENTRY_MAX_BYTES = 5 * 1024 * 1024;
+const LOGO_MAX_BYTES = 1024 * 1024;
+const LOGO_EXTENSIONS: readonly LogoExtension[] = ["svg", "png", "jpg", "jpeg"];
+const APP_SESSION_TOKEN_PREFIX = "appsess_";
+const INVALID_APP_MANIFEST_MESSAGE =
+ "App manifest failed validation. Inspect manifest.json or rebuild the app.";
+const globalAppListSignatureByDataDir = new Map();
+
+class InvalidAppManifestError extends ApiError {
+ readonly applicationId: ApplicationId;
+ readonly manifestPath: string;
+ readonly issues: AppManifestValidationIssues;
+
+ constructor(args: InvalidAppManifestErrorArgs) {
+ super(422, "invalid_manifest", INVALID_APP_MANIFEST_MESSAGE);
+ this.name = "InvalidAppManifestError";
+ this.applicationId = args.applicationId;
+ this.manifestPath = args.manifestPath;
+ this.issues = args.issues;
+ }
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+function canonicalizeJson(value: JsonValue | AppManifest): string {
+ return `${JSON.stringify(value, null, 2)}\n`;
+}
+
+function isFsErrorWithCode(error: Error, code: string): boolean {
+ return "code" in error && error.code === code;
+}
+
+function parseApplicationId(rawApplicationId: string): ApplicationId {
+ const parsed = applicationIdSchema.safeParse(rawApplicationId);
+ if (!parsed.success) {
+ throw new ApiError(400, "invalid_request", "Invalid applicationId");
+ }
+ return parsed.data;
+}
+
+function parseAppDataPath(rawPath: string): AppDataPath {
+ const parsed = appDataPathSchema.safeParse(rawPath);
+ if (!parsed.success) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ return parsed.data;
+}
+
+function applicationRouteSegment(args: ApplicationRouteSegmentArgs): string {
+ return `/apps/${encodeURIComponent(args.applicationId)}/`;
+}
+
+function applicationDataRouteSegment(
+ args: ApplicationRouteSegmentArgs,
+): string {
+ return `/apps/${encodeURIComponent(args.applicationId)}/data/`;
+}
+
+function parseAppDataRoutePath(rawPath: string): AppDataPath {
+ return parseAppDataPath(
+ parseSafeRelativeRoutePath({
+ rawPath,
+ dotfileSegmentPolicy: "allow",
+ invalidPathMessage: "Invalid app data path",
+ }).relativePath,
+ );
+}
+
+function parseOptionalAppDataPrefix(
+ rawPrefix: string | undefined,
+): AppDataPath | "" {
+ if (rawPrefix === undefined || rawPrefix === "") {
+ return "";
+ }
+ return parseAppDataPath(rawPrefix);
+}
+
+function applicationRootForKind(args: ApplicationRootForKindArgs): string {
+ if (args.rootKind === "app") {
+ return resolveApplicationPath(args.dataDir, args.applicationId);
+ }
+ if (args.rootKind === "public") {
+ return resolveApplicationPublicPath(args.dataDir, args.applicationId);
+ }
+ return resolveApplicationDataPath(args.dataDir, args.applicationId);
+}
+
+function parseApplicationStaticPath(rawPath: string): ApplicationStaticPath {
+ return parseSafeRelativeRoutePath({
+ rawPath,
+ directoryIndexPath: "index.html",
+ dotfileSegmentPolicy: "not-found",
+ invalidPathMessage: "Invalid app static path",
+ });
+}
+
+function summarizeAppManifestValidationIssues(
+ issues: AppManifestValidationIssues,
+): string {
+ return issues
+ .map((issue) => {
+ const issuePath = issue.path.map(String).join(".");
+ return `${issuePath || ""}: ${issue.message}`;
+ })
+ .join("; ");
+}
+
+function logInvalidAppManifest(
+ deps: AppManifestValidationLoggerDeps,
+ args: LogInvalidAppManifestArgs,
+): void {
+ deps.logger.warn(
+ {
+ applicationId: args.error.applicationId,
+ manifestPath: args.error.manifestPath,
+ issueSummary: summarizeAppManifestValidationIssues(args.error.issues),
+ issues: args.error.issues,
+ },
+ args.message,
+ );
+}
+
+async function readApplicationRelativeFile(
+ args: ReadApplicationRelativeFileArgs,
+): Promise {
+ const rootPath = applicationRootForKind(args);
+ const filePath = path.join(rootPath, args.path);
+ if (
+ args.dotfiles === "deny" &&
+ args.path.split("/").some((segment) => segment.startsWith("."))
+ ) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ const relativePath = path.relative(rootPath, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app file path");
+ }
+ try {
+ return await readFile(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ throw error;
+ }
+}
+
+async function statApplicationRelativeFile(
+ args: ReadApplicationRelativeFileArgs,
+): Promise {
+ const rootPath = applicationRootForKind(args);
+ const filePath = path.join(rootPath, args.path);
+ const relativePath = path.relative(rootPath, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app file path");
+ }
+ try {
+ return await stat(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+ throw error;
+ }
+}
+
+async function ensureApplicationFolderExists(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ try {
+ const applicationStats = await stat(
+ resolveApplicationPath(args.dataDir, args.applicationId),
+ );
+ if (!applicationStats.isDirectory()) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath: resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ ),
+ issues: [],
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "app_missing", "App not found");
+ }
+ throw error;
+ }
+}
+
+async function readApplicationManifest(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ await ensureApplicationFolderExists(args);
+ const manifestPath = resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ let manifestContent: string;
+ try {
+ manifestContent = await readFile(manifestPath, "utf8");
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath,
+ issues: [],
+ });
+ }
+ throw error;
+ }
+
+ let parsedJson;
+ try {
+ parsedJson = JSON.parse(manifestContent);
+ } catch {
+ throw new ApiError(
+ 422,
+ "invalid_manifest",
+ "App manifest is not valid JSON",
+ );
+ }
+ const manifest = appManifestSchema.safeParse(parsedJson);
+ if (!manifest.success) {
+ throw new InvalidAppManifestError({
+ ...args,
+ manifestPath,
+ issues: manifest.error.issues,
+ });
+ }
+ if (manifest.data.id !== args.applicationId) {
+ throw new ApiError(
+ 422,
+ "invalid_manifest",
+ "App manifest id must match its directory name",
+ );
+ }
+ return manifest.data;
+}
+
+async function readApplicationManifestForRequest(
+ deps: AppDeps,
+ args: ApplicationManifestReadArgs,
+): Promise {
+ try {
+ return await readApplicationManifest(args);
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Rejected invalid global app manifest",
+ });
+ }
+ throw error;
+ }
+}
+
+function entryKindForPath(entryPath: string): AppEntry["kind"] {
+ const normalized = entryPath.toLowerCase();
+ if (normalized.endsWith(".html")) {
+ return "html";
+ }
+ if (normalized.endsWith(".md")) {
+ return "md";
+ }
+ throw new ApiError(
+ 422,
+ "invalid_manifest",
+ "App entry must end in .html or .md",
+ );
+}
+
+async function resolveApplicationEntry(
+ args: ApplicationManifestReadArgs & { manifest: AppManifest },
+): Promise {
+ if (args.manifest.entry !== undefined) {
+ return {
+ path: args.manifest.entry,
+ kind: entryKindForPath(args.manifest.entry),
+ };
+ }
+
+ for (const candidate of ["index.html", "index.md"]) {
+ try {
+ await statApplicationRelativeFile({
+ ...args,
+ path: candidate,
+ rootKind: "public",
+ dotfiles: "deny",
+ });
+ return {
+ path: candidate,
+ kind: entryKindForPath(candidate),
+ };
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "ENOENT") {
+ continue;
+ }
+ throw error;
+ }
+ }
+
+ throw new ApiError(
+ 404,
+ "ENOENT",
+ "App entry not found: index.html or index.md",
+ );
+}
+
+async function tryResolveLogo(
+ args: ApplicationManifestReadArgs,
+): Promise {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(
+ resolveApplicationPath(args.dataDir, args.applicationId),
+ {
+ withFileTypes: true,
+ },
+ );
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return null;
+ }
+ throw error;
+ }
+ const topLevelFiles = new Set(
+ entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
+ );
+ for (const extension of LOGO_EXTENSIONS) {
+ if (topLevelFiles.has(`logo.${extension}`)) {
+ return { extension };
+ }
+ }
+ return null;
+}
+
+async function resolveApplicationIcon(
+ args: ApplicationSummaryArgs,
+): Promise {
+ if (args.manifest.icon !== undefined) {
+ return { kind: "builtin", name: args.manifest.icon };
+ }
+ const logo = await tryResolveLogo(args);
+ if (logo) {
+ return {
+ kind: "logo",
+ url: `/api/v1/apps/${encodeURIComponent(args.applicationId)}/icon`,
+ };
+ }
+ return { kind: "builtin", name: "GridView" };
+}
+
+async function buildApplicationSummary(
+ args: ApplicationSummaryArgs,
+): Promise {
+ const [entry, icon] = await Promise.all([
+ resolveApplicationEntry(args),
+ resolveApplicationIcon(args),
+ ]);
+ return {
+ applicationId: args.manifest.id,
+ name: args.manifest.name,
+ entry,
+ capabilities: args.manifest.capabilities,
+ icon,
+ };
+}
+
+async function buildApplicationDetail(
+ deps: AppDeps,
+ args: ApplicationDetailArgs,
+): Promise {
+ const manifest = await readApplicationManifestForRequest(deps, args);
+ return {
+ ...(await buildApplicationSummary({
+ ...args,
+ manifest,
+ })),
+ appsRootPath: resolveAppsRootPath(args.dataDir),
+ appRootPath: resolveApplicationPath(args.dataDir, args.applicationId),
+ appDataPath: resolveApplicationDataPath(args.dataDir, args.applicationId),
+ };
+}
+
+function assertAppCapability(
+ manifest: AppManifest,
+ capability: AppCapability,
+): void {
+ if (!manifest.capabilities.includes(capability)) {
+ throw new ApiError(
+ 403,
+ "invalid_request",
+ `App does not have the ${capability} capability`,
+ );
+ }
+}
+
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return entryName.startsWith(".tmp-") || entryName.startsWith(".delete-");
+}
+
+async function listGlobalApplications(
+ deps: GlobalAppListDeps,
+): Promise {
+ const appsRootPath = resolveAppsRootPath(deps.config.dataDir);
+ let entries: Dirent[];
+ try {
+ entries = await readdir(appsRootPath, { withFileTypes: true });
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ const applicationIds = entries
+ .filter(
+ (entry) =>
+ entry.isDirectory() && !isIgnoredApplicationStorageEntry(entry.name),
+ )
+ .map((entry) => applicationIdSchema.safeParse(entry.name))
+ .filter((entry) => entry.success)
+ .map((entry) => entry.data)
+ .sort((left, right) => left.localeCompare(right));
+
+ const summaries: AppSummary[] = [];
+ for (const applicationId of applicationIds) {
+ try {
+ const manifest = await readApplicationManifest({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ summaries.push(
+ await buildApplicationSummary({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ manifest,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof InvalidAppManifestError) {
+ logInvalidAppManifest(deps, {
+ error,
+ message: "Skipping invalid global app manifest",
+ });
+ continue;
+ }
+ if (error instanceof ApiError && error.body.code === "invalid_manifest") {
+ deps.logger.warn(
+ {
+ applicationId,
+ appRootPath: resolveApplicationPath(
+ deps.config.dataDir,
+ applicationId,
+ ),
+ message: error.body.message,
+ },
+ "Skipping invalid global app manifest",
+ );
+ continue;
+ }
+ throw error;
+ }
+ }
+ return summaries;
+}
+
+function globalAppListSignature(apps: readonly AppSummary[]): string {
+ return JSON.stringify(apps);
+}
+
+function rememberGlobalAppListSignature(
+ deps: Pick,
+ apps: readonly AppSummary[],
+): void {
+ globalAppListSignatureByDataDir.set(
+ deps.config.dataDir,
+ globalAppListSignature(apps),
+ );
+}
+
+export async function refreshGlobalAppListSignature(
+ deps: GlobalAppListDeps,
+): Promise {
+ const apps = await listGlobalApplications(deps);
+ rememberGlobalAppListSignature(deps, apps);
+ return apps;
+}
+
+export async function notifyGlobalAppsChanged(
+ deps: GlobalAppListDeps,
+): Promise {
+ await refreshGlobalAppListSignature(deps);
+ deps.hub.notifySystem(["apps-changed"]);
+}
+
+export async function notifyGlobalAppsChangedIfListChanged(
+ deps: GlobalAppListDeps,
+): Promise {
+ const apps = await listGlobalApplications(deps);
+ const nextSignature = globalAppListSignature(apps);
+ const previousSignature = globalAppListSignatureByDataDir.get(
+ deps.config.dataDir,
+ );
+ globalAppListSignatureByDataDir.set(deps.config.dataDir, nextSignature);
+ if (previousSignature !== nextSignature) {
+ deps.hub.notifySystem(["apps-changed"]);
+ }
+}
+
+function createHtmlResponse(html: string): Response {
+ return new Response(html, {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": HTML_CONTENT_TYPE,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+function buildAppWebSocketUrl(
+ deps: LoggedWorkSessionDeps,
+ requestUrl: string,
+): string {
+ if (deps.config.isDevelopment) {
+ return `ws://localhost:${deps.config.serverPort}/ws`;
+ }
+ const url = new URL(requestUrl);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ url.pathname = "/ws";
+ url.search = "";
+ url.hash = "";
+ return url.toString();
+}
+
+function createInjectedAppHtmlResponse(
+ deps: LoggedWorkSessionDeps,
+ args: CreateInjectedAppHtmlResponseArgs,
+): Response {
+ return createHtmlResponse(
+ injectAppClientScript(args.html, {
+ appId: args.applicationId,
+ applicationId: args.applicationId,
+ appSessionToken: args.appSessionToken,
+ capabilities: args.capabilities,
+ targetThreadId: args.targetThreadId,
+ dataUrl: `/api/v1/apps/${encodeURIComponent(args.applicationId)}/data`,
+ messageUrl: `/api/v1/apps/${encodeURIComponent(
+ args.applicationId,
+ )}/message`,
+ wsUrl: buildAppWebSocketUrl(deps, args.requestUrl),
+ }),
+ );
+}
+
+function createAppSessionStore(): AppSessionStore {
+ const sessions = new Map();
+ return {
+ create(args) {
+ const token = `${APP_SESSION_TOKEN_PREFIX}${randomUUID().replaceAll(
+ "-",
+ "",
+ )}`;
+ sessions.set(token, args);
+ return token;
+ },
+ get(token) {
+ return sessions.get(token) ?? null;
+ },
+ };
+}
+
+function maybeCreateAppSession(
+ deps: AppDeps,
+ sessions: AppSessionStore,
+ applicationId: ApplicationId,
+ rawTargetThreadId: string | undefined,
+): AppSessionToken | null {
+ if (rawTargetThreadId === undefined || rawTargetThreadId.trim() === "") {
+ return null;
+ }
+ const thread = requirePublicThread(deps.db, rawTargetThreadId);
+ return sessions.create({
+ applicationId,
+ projectId: thread.projectId,
+ threadId: thread.id,
+ });
+}
+
+async function serveApplicationEntry(
+ deps: AppDeps,
+ sessions: AppSessionStore,
+ rawApplicationId: string,
+ requestUrl: string,
+ rawTargetThreadId: string | undefined,
+): Promise {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ const entry = await resolveApplicationEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ manifest,
+ });
+ if (entry.kind !== "html") {
+ throw new ApiError(404, "invalid_request", "App entry is not HTML");
+ }
+ const metadata = await statApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "public",
+ path: entry.path,
+ dotfiles: "deny",
+ });
+ if (metadata.size > HTML_ENTRY_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App HTML entry exceeds the 5 MB limit",
+ false,
+ );
+ }
+ const result = await readApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "public",
+ path: entry.path,
+ dotfiles: "deny",
+ });
+ const token = maybeCreateAppSession(
+ deps,
+ sessions,
+ applicationId,
+ rawTargetThreadId,
+ );
+ return createInjectedAppHtmlResponse(deps, {
+ applicationId,
+ requestUrl,
+ targetThreadId: rawTargetThreadId ?? null,
+ appSessionToken: token,
+ html: result.toString("utf8"),
+ capabilities: manifest.capabilities,
+ });
+}
+
+function isPrivateApplicationStaticPath(
+ staticPath: ApplicationStaticPath,
+): boolean {
+ const [topLevelSegment] = staticPath.relativePath.split("/");
+ return (
+ topLevelSegment === MANIFEST_FILE_NAME ||
+ topLevelSegment === DATA_DIRECTORY_NAME
+ );
+}
+
+async function readApplicationStaticFile(
+ args: ReadApplicationStaticFileArgs,
+): Promise {
+ if (isPrivateApplicationStaticPath(args.staticPath)) {
+ throw new ApiError(404, "ENOENT", "App file not found");
+ }
+
+ return {
+ path: args.staticPath.relativePath,
+ bytes: await readApplicationRelativeFile({
+ dataDir: args.dataDir,
+ applicationId: args.applicationId,
+ rootKind: "public",
+ path: args.staticPath.relativePath,
+ dotfiles: "deny",
+ }),
+ };
+}
+
+async function serveApplicationStaticFile(
+ args: ServeApplicationStaticFileArgs,
+): Promise {
+ const applicationId = parseApplicationId(args.rawApplicationId);
+ const staticPath = parseApplicationStaticPath(args.rawPath);
+ await readApplicationManifestForRequest(args.deps, {
+ dataDir: args.deps.config.dataDir,
+ applicationId,
+ });
+ const result = await readApplicationStaticFile({
+ dataDir: args.deps.config.dataDir,
+ applicationId,
+ staticPath,
+ });
+ const contentType =
+ mimeTypes.lookup(result.path) || "application/octet-stream";
+ return new Response(new Uint8Array(result.bytes), {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": contentType,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+async function serveApplicationIcon(
+ deps: AppDeps,
+ rawApplicationId: string,
+): Promise {
+ const applicationId = parseApplicationId(rawApplicationId);
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ if (manifest.icon !== undefined) {
+ throw new ApiError(404, "ENOENT", "App uses a built-in icon");
+ }
+ const logo = await tryResolveLogo({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ if (!logo) {
+ throw new ApiError(404, "ENOENT", "App logo not found");
+ }
+ const logoPath = `logo.${logo.extension}`;
+ const metadata = await statApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "app",
+ path: logoPath,
+ dotfiles: "deny",
+ });
+ if (metadata.size > LOGO_MAX_BYTES) {
+ throw new ApiError(
+ 413,
+ "file_too_large",
+ "App logo exceeds the 1 MB limit",
+ false,
+ );
+ }
+ const result = await readApplicationRelativeFile({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ rootKind: "app",
+ path: logoPath,
+ dotfiles: "deny",
+ });
+ const contentType = mimeTypes.lookup(logoPath) || "application/octet-stream";
+ return new Response(new Uint8Array(result), {
+ status: 200,
+ headers: {
+ "cache-control": NO_STORE_CACHE_CONTROL,
+ "content-type": contentType,
+ "x-content-type-options": CONTENT_TYPE_OPTIONS,
+ },
+ });
+}
+
+async function readApplicationDataEntry(
+ args: ReadApplicationDataEntryArgs,
+): Promise {
+ const filePath = path.join(
+ resolveApplicationDataPath(args.dataDir, args.applicationId),
+ ...args.dataPath.split("/"),
+ );
+ let bytes: Buffer;
+ let metadata: Stats;
+ try {
+ [bytes, metadata] = await Promise.all([readFile(filePath), stat(filePath)]);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", `App data not found: ${args.dataPath}`);
+ }
+ throw error;
+ }
+ let value: JsonValue;
+ try {
+ value = jsonValueSchema.parse(JSON.parse(bytes.toString("utf8")));
+ } catch {
+ throw new ApiError(
+ 422,
+ "invalid_json",
+ `App data path ${args.dataPath} does not contain valid JSON`,
+ );
+ }
+ return {
+ path: args.dataPath,
+ value,
+ version: sha256(bytes),
+ sizeBytes: metadata.size,
+ modifiedAtMs: metadata.mtimeMs,
+ };
+}
+
+async function listAppDataFilePaths(
+ appDataRoot: string,
+ currentDirectory: string,
+): Promise {
+ const entries = await readdir(currentDirectory, { withFileTypes: true });
+ const paths: AppDataPath[] = [];
+ for (const entry of entries) {
+ if (entry.name.startsWith(".")) {
+ continue;
+ }
+ const entryPath = path.join(currentDirectory, entry.name);
+ if (entry.isDirectory()) {
+ paths.push(...(await listAppDataFilePaths(appDataRoot, entryPath)));
+ continue;
+ }
+ if (!entry.isFile()) {
+ continue;
+ }
+ const relativePath = path
+ .relative(appDataRoot, entryPath)
+ .split(path.sep)
+ .join("/");
+ const parsed = appDataPathSchema.safeParse(relativePath);
+ if (parsed.success) {
+ paths.push(parsed.data);
+ }
+ }
+ return paths.sort((left, right) => left.localeCompare(right));
+}
+
+function shouldListAppDataPrefixAfterReadError(error: Error): boolean {
+ return error instanceof ApiError && error.body.code === "ENOENT";
+}
+
+async function listApplicationDataEntries(
+ args: ListApplicationDataEntriesArgs,
+): Promise {
+ if (args.dataPath !== "") {
+ try {
+ return [
+ await readApplicationDataEntry({
+ applicationId: args.applicationId,
+ dataPath: args.dataPath,
+ dataDir: args.dataDir,
+ }),
+ ];
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ !shouldListAppDataPrefixAfterReadError(error)
+ ) {
+ throw error;
+ }
+ }
+ }
+
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const listRoot = args.dataPath
+ ? path.join(appDataRoot, args.dataPath)
+ : appDataRoot;
+ let dataPaths: AppDataPath[];
+ try {
+ dataPaths = await listAppDataFilePaths(appDataRoot, listRoot);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ return Promise.all(
+ dataPaths.map((dataPath) =>
+ readApplicationDataEntry({
+ applicationId: args.applicationId,
+ dataDir: args.dataDir,
+ dataPath,
+ }),
+ ),
+ );
+}
+
+async function writeApplicationDataEntry(
+ args: WriteApplicationDataEntryArgs,
+): Promise {
+ const content = canonicalizeJson(args.value);
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const filePath = path.join(appDataRoot, ...args.dataPath.split("/"));
+ const relativePath = path.relative(appDataRoot, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, content, "utf8");
+ return readApplicationDataEntry(args);
+}
+
+async function deleteApplicationDataEntry(
+ args: DeleteApplicationDataEntryArgs,
+): Promise {
+ const appDataRoot = resolveApplicationDataPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ const filePath = path.join(appDataRoot, ...args.dataPath.split("/"));
+ const relativePath = path.relative(appDataRoot, filePath);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+ throw new ApiError(400, "invalid_request", "Invalid app data path");
+ }
+ try {
+ await rm(filePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "ENOENT", `App data not found: ${args.dataPath}`);
+ }
+ throw error;
+ }
+}
+
+function createTempNonce(): string {
+ return randomUUID().replaceAll("-", "").slice(0, 16);
+}
+
+function deriveCreateApplicationId(payload: CreateAppRequest): ApplicationId {
+ if (payload.applicationId !== undefined) {
+ return payload.applicationId;
+ }
+ if (payload.name !== undefined) {
+ try {
+ return deriveApplicationIdFromName(payload.name);
+ } catch {
+ throw new ApiError(
+ 400,
+ "invalid_request",
+ "App name cannot be converted to a valid applicationId",
+ );
+ }
+ }
+ throw new ApiError(400, "invalid_request", "Provide applicationId or name");
+}
+
+function resolveCreateApplicationName(
+ payload: CreateAppRequest,
+ applicationId: ApplicationId,
+): string {
+ return payload.name === undefined || payload.name.trim().length === 0
+ ? applicationId
+ : payload.name;
+}
+
+async function createApplicationTempRoot(
+ args: CreateApplicationTempRootArgs,
+): Promise {
+ const tempRootPath = path.join(
+ args.appsRootPath,
+ `.tmp-${args.applicationId}-${createTempNonce()}`,
+ );
+ await mkdir(tempRootPath, { recursive: false });
+ return tempRootPath;
+}
+
+async function writeInitialApplicationFiles(
+ tempRootPath: string,
+ applicationId: ApplicationId,
+ name: string,
+): Promise {
+ const manifest: AppManifest = {
+ manifestVersion: 1,
+ id: applicationId,
+ name,
+ entry: "index.html",
+ capabilities: ["data", "message"],
+ };
+ await mkdir(path.join(tempRootPath, PUBLIC_DIRECTORY_NAME), {
+ recursive: true,
+ });
+ await mkdir(path.join(tempRootPath, DATA_DIRECTORY_NAME), {
+ recursive: true,
+ });
+ await writeFile(
+ path.join(tempRootPath, MANIFEST_FILE_NAME),
+ canonicalizeJson(manifest),
+ "utf8",
+ );
+ await writeFile(
+ path.join(tempRootPath, PUBLIC_DIRECTORY_NAME, "index.html"),
+ buildBlankAppIndexHtml({ name }),
+ "utf8",
+ );
+ await writeFile(
+ path.join(tempRootPath, DATA_DIRECTORY_NAME, "state.json"),
+ canonicalizeJson({}),
+ "utf8",
+ );
+ appManifestSchema.parse(
+ JSON.parse(
+ await readFile(path.join(tempRootPath, MANIFEST_FILE_NAME), "utf8"),
+ ),
+ );
+}
+
+async function publishApplicationTempRoot(
+ args: PublishApplicationTempRootArgs,
+): Promise<"published" | "collision"> {
+ try {
+ await stat(args.applicationPath);
+ return "collision";
+ } catch (error) {
+ if (!(error instanceof Error) || !isFsErrorWithCode(error, "ENOENT")) {
+ throw error;
+ }
+ }
+ try {
+ await rename(args.tempRootPath, args.applicationPath);
+ return "published";
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ (isFsErrorWithCode(error, "EEXIST") ||
+ isFsErrorWithCode(error, "ENOTEMPTY"))
+ ) {
+ return "collision";
+ }
+ throw error;
+ }
+}
+
+export async function createGlobalApplication(
+ args: CreateGlobalApplicationArgs,
+): Promise {
+ const appsRootPath = resolveAppsRootPath(args.dataDir);
+ await mkdir(appsRootPath, { recursive: true });
+
+ const applicationPath = resolveApplicationPath(
+ args.dataDir,
+ args.applicationId,
+ );
+ let tempRootPath: string | null = null;
+ try {
+ tempRootPath = await createApplicationTempRoot({
+ appsRootPath,
+ applicationId: args.applicationId,
+ });
+ await writeInitialApplicationFiles(
+ tempRootPath,
+ args.applicationId,
+ args.name,
+ );
+ const result = await publishApplicationTempRoot({
+ applicationPath,
+ tempRootPath,
+ });
+ if (result === "collision") {
+ await rm(tempRootPath, { recursive: true, force: true });
+ tempRootPath = null;
+ throw new ApiError(
+ 409,
+ "app_exists",
+ `an app with id "${args.applicationId}" already exists`,
+ );
+ }
+ return args.applicationId;
+ } catch (error) {
+ if (tempRootPath !== null) {
+ await rm(tempRootPath, { recursive: true, force: true }).catch(
+ () => undefined,
+ );
+ }
+ if (error instanceof ApiError) {
+ throw error;
+ }
+ throw new ApiError(
+ 500,
+ "scaffold_failed",
+ error instanceof Error ? error.message : "Failed to scaffold app",
+ );
+ }
+}
+
+async function deleteGlobalApplication(
+ dataDir: string,
+ applicationId: ApplicationId,
+): Promise<"deleted" | "partial"> {
+ const applicationPath = resolveApplicationPath(dataDir, applicationId);
+ try {
+ await stat(applicationPath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ throw new ApiError(404, "app_missing", "App not found");
+ }
+ throw error;
+ }
+ const tombstonePath = path.join(
+ resolveAppsRootPath(dataDir),
+ `.delete-${applicationId}-${createTempNonce()}`,
+ );
+ try {
+ await rename(applicationPath, tombstonePath);
+ } catch (error) {
+ throw new ApiError(
+ 500,
+ "delete_failed",
+ error instanceof Error ? error.message : "Failed to delete app",
+ );
+ }
+ try {
+ await rm(tombstonePath, { recursive: true, force: true });
+ return "deleted";
+ } catch {
+ return "partial";
+ }
+}
+
+function formatAppMessagePayload(payload: JsonValue): string {
+ return typeof payload === "string"
+ ? payload
+ : JSON.stringify(payload, null, 2);
+}
+
+function resolveAppMessageTarget(
+ deps: AppDeps,
+ args: ResolveAppMessageTargetArgs,
+): AppSession {
+ const token =
+ args.payload.appSessionToken === undefined
+ ? null
+ : args.sessions.get(args.payload.appSessionToken);
+ if (args.payload.appSessionToken !== undefined && token === null) {
+ throw new ApiError(403, "forbidden", "Invalid app session token");
+ }
+ if (token !== null && token.applicationId !== args.applicationId) {
+ throw new ApiError(
+ 403,
+ "forbidden",
+ "App session token does not match app",
+ );
+ }
+ if (args.payload.targetThreadId !== undefined) {
+ const thread = requirePublicThread(deps.db, args.payload.targetThreadId);
+ if (token !== null && token.threadId !== thread.id) {
+ throw new ApiError(403, "forbidden", "App message target mismatch");
+ }
+ return {
+ applicationId: args.applicationId,
+ threadId: thread.id,
+ projectId: thread.projectId,
+ };
+ }
+ if (token !== null) {
+ return token;
+ }
+ throw new ApiError(
+ 400,
+ "message_target_required",
+ "App message requires an app session token or targetThreadId",
+ );
+}
+
+export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void {
+ const { get, post, put, del } = typedRoutes(app, {
+ onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
+ });
+ const appSessions = createAppSessionStore();
+
+ get("/apps", async (context) =>
+ context.json(await refreshGlobalAppListSignature(deps)),
+ );
+
+ post("/apps", createAppRequestSchema, async (context, payload) => {
+ const applicationId = deriveCreateApplicationId(payload);
+ const createdApplicationId = await createGlobalApplication({
+ applicationId,
+ dataDir: deps.config.dataDir,
+ name: resolveCreateApplicationName(payload, applicationId),
+ });
+ const detail = await buildApplicationDetail(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId: createdApplicationId,
+ });
+ await notifyGlobalAppsChanged(deps);
+ return context.json(detail, 201);
+ });
+
+ get("/apps/:applicationId", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ return context.json(
+ await buildApplicationDetail(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ }),
+ );
+ });
+
+ del("/apps/:applicationId", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const result = await deleteGlobalApplication(
+ deps.config.dataDir,
+ applicationId,
+ );
+ await notifyGlobalAppsChanged(deps);
+ if (result === "partial") {
+ throw new ApiError(
+ 500,
+ "delete_partial",
+ "App was removed from normal lists, but tombstone cleanup failed",
+ );
+ }
+ return context.json({ ok: true });
+ });
+
+ get(
+ "/apps/:applicationId/data",
+ appDataListQuerySchema,
+ async (context, query) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseOptionalAppDataPrefix(query.prefix);
+ return context.json({
+ entries: await listApplicationDataEntries({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ }),
+ });
+ },
+ );
+
+ post(
+ "/apps/:applicationId/message",
+ appMessageRequestSchema,
+ async (context, payload) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "message");
+ const target = resolveAppMessageTarget(deps, {
+ applicationId,
+ payload,
+ sessions: appSessions,
+ });
+ const thread = requirePublicThread(deps.db, target.threadId);
+ const environment = await requireThreadCommandEnvironment(deps, {
+ thread,
+ });
+ await sendThreadMessage(deps, {
+ environment,
+ thread,
+ trigger: "user",
+ payload: {
+ input: [
+ {
+ type: "text",
+ text: formatAppMessagePayload(payload.payload),
+ },
+ ],
+ mode: "auto",
+ },
+ });
+ return context.json({ ok: true }, 202);
+ },
+ );
+
+ app.get("/apps/:applicationId/", async (context) =>
+ serveApplicationEntry(
+ deps,
+ appSessions,
+ context.req.param("applicationId"),
+ context.req.url,
+ context.req.query("targetThreadId"),
+ ),
+ );
+
+ app.get("/apps/:applicationId/icon", async (context) =>
+ serveApplicationIcon(deps, context.req.param("applicationId")),
+ );
+
+ get("/apps/:applicationId/data/*", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ const entry = await readApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ });
+ const response = context.json(entry);
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ response.headers.set("etag", `"${entry.version}"`);
+ return response;
+ });
+
+ put(
+ "/apps/:applicationId/data/*",
+ appDataWriteRequestSchema,
+ async (context, payload) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ const entry = await writeApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ value: payload.value,
+ });
+ const response = context.json(entry);
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ response.headers.set("etag", `"${entry.version}"`);
+ return response;
+ },
+ );
+
+ del("/apps/:applicationId/data/*", async (context) => {
+ const applicationId = parseApplicationId(
+ context.req.param("applicationId"),
+ );
+ const manifest = await readApplicationManifestForRequest(deps, {
+ dataDir: deps.config.dataDir,
+ applicationId,
+ });
+ assertAppCapability(manifest, "data");
+ const dataPath = parseAppDataRoutePath(
+ extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationDataRouteSegment({ applicationId }),
+ }),
+ );
+ await deleteApplicationDataEntry({
+ dataDir: deps.config.dataDir,
+ applicationId,
+ dataPath,
+ });
+ const response = context.json({ ok: true });
+ response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
+ return response;
+ });
+
+ app.get("/apps/:applicationId/*", async (context) => {
+ const applicationId = context.req.param("applicationId");
+ return serveApplicationStaticFile({
+ deps,
+ rawApplicationId: applicationId,
+ rawPath: extractRoutePath({
+ requestUrl: context.req.url,
+ routeSegment: applicationRouteSegment({ applicationId }),
+ }),
+ });
+ });
+}
diff --git a/apps/server/src/routes/threads/apps.ts b/apps/server/src/routes/threads/apps.ts
deleted file mode 100644
index a884b8ee1..000000000
--- a/apps/server/src/routes/threads/apps.ts
+++ /dev/null
@@ -1,1320 +0,0 @@
-import { Buffer } from "node:buffer";
-import { createHash } from "node:crypto";
-import path from "node:path";
-import mimeTypes from "mime-types";
-import type { Hono } from "hono";
-import type { ZodIssue } from "zod";
-import {
- FILE_LIST_LIMIT_MAX,
- type HostDaemonCommand,
- type HostDaemonCommandResult,
- type HostDaemonDurableCommandType,
- type HostDaemonOnlineRpcResult,
- type HostDaemonRetryableOnlineRpcCommand,
- type HostDaemonRetryableOnlineRpcCommandType,
- type HostDaemonRetryableOnlineRpcResult,
-} from "@bb/host-daemon-contract";
-import {
- appDataPathSchema,
- appIdSchema,
- jsonValueSchema,
-} from "@bb/domain";
-import type { AppDataPath, AppId, JsonValue } from "@bb/domain";
-import {
- appDataListQuerySchema,
- appDataWriteRequestSchema,
- appManifestSchema,
- appMessageRequestSchema,
- createThreadAppRequestSchema,
- typedRoutes,
- type AppCapability,
- type AppDataEntry,
- type AppDetail,
- type AppEntry,
- type AppIcon,
- type AppManifest,
- type AppSummary,
- type AppTemplate,
- type CreateThreadAppRequest,
- type PublicApiSchema,
-} from "@bb/server-contract";
-import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js";
-import { COMMAND_TIMEOUT_MS } from "../../constants.js";
-import { ApiError } from "../../errors.js";
-import { queueCommandAndWait } from "../../services/hosts/command-wait.js";
-import { callHostRetryableOnlineRpc } from "../../services/hosts/online-rpc.js";
-import {
- createDaemonFileContentResponse,
- decodeDaemonFileContent,
- type DaemonFileReadResult,
- remapDaemonFileRouteError,
-} from "../../services/hosts/daemon-file-response.js";
-import { requirePublicThread } from "../../services/lib/entity-lookup.js";
-import { requireThreadCommandEnvironment } from "../../services/threads/thread-command-environment.js";
-import { sendThreadMessage } from "../../services/threads/thread-send.js";
-import { injectAppClientScript } from "../../services/threads/app-client-script.js";
-import { buildBlankAppIndexHtml } from "../../services/threads/blank-app-scaffold.js";
-import {
- extractRoutePath,
- parseSafeRelativeRoutePath,
- type SafeRelativeRoutePath,
-} from "../relative-route-path.js";
-import { requireThreadStorageTarget } from "./data.js";
-
-interface ThreadAppsTarget {
- hostId: string;
- storagePath: string;
-}
-
-interface AppManifestReadArgs {
- appId: AppId;
- target: ThreadAppsTarget;
-}
-
-interface InvalidAppManifestErrorArgs extends AppManifestReadArgs {
- issues: AppManifestValidationIssues;
-}
-
-interface LogInvalidAppManifestArgs {
- error: InvalidAppManifestError;
- message: string;
-}
-
-interface AppSummaryArgs extends AppManifestReadArgs {
- manifest: AppManifest;
- requestThreadId: string;
-}
-
-interface AppDetailArgs extends AppManifestReadArgs {
- requestThreadId: string;
-}
-
-interface AppRootArgs {
- appId: AppId;
- target: ThreadAppsTarget;
-}
-
-interface AppDataRootArgs extends AppRootArgs {}
-
-interface ReadAppRelativeFileArgs {
- appId: AppId;
- dotfiles: "allow" | "deny";
- path: string;
- rootKind: "app" | "assets" | "data";
- target: ThreadAppsTarget;
-}
-
-interface ReadAppFileMetadataArgs {
- appId: AppId;
- path: string;
- rootKind: "app" | "assets" | "data";
- target: ThreadAppsTarget;
-}
-
-interface AppRootForKindArgs {
- appId: AppId;
- rootKind: "app" | "assets" | "data";
- target: ThreadAppsTarget;
-}
-
-interface ReadAppDataEntryArgs {
- appId: AppId;
- dataPath: AppDataPath;
- target: ThreadAppsTarget;
-}
-
-interface ListAppDataEntriesArgs {
- appId: AppId;
- dataPath: AppDataPath | "";
- target: ThreadAppsTarget;
-}
-
-interface WriteAppDataEntryArgs extends ReadAppDataEntryArgs {
- value: JsonValue;
-}
-
-interface DeleteAppDataEntryArgs extends ReadAppDataEntryArgs {}
-
-interface CreateInjectedAppHtmlResponseArgs {
- appId: AppId;
- capabilities: AppCapability[];
- html: string;
- requestUrl: string;
- threadId: string;
-}
-
-interface AppAssetRouteSegmentArgs {
- appId: string;
- threadId: string;
-}
-
-type AppAssetPath = SafeRelativeRoutePath;
-
-interface LogoResolution {
- extension: string;
-}
-
-interface TemplateFile {
- content: string;
- path: string;
-}
-
-interface ScaffoldAppArgs {
- request: CreateThreadAppRequest;
- target: ThreadAppsTarget;
- threadId: string;
-}
-
-type AppManifestValidationIssues = readonly ZodIssue[];
-type AppManifestValidationLoggerDeps = Pick;
-
-interface ReadHostCommandArgs<
- TType extends HostDaemonRetryableOnlineRpcCommandType,
-> {
- command: Extract;
- hostId: string;
-}
-
-interface QueueHostCommandArgs {
- command: Extract;
- hostId: string;
-}
-
-const APPS_DIRECTORY_NAME = "apps";
-const ASSETS_DIRECTORY_NAME = "assets";
-const DATA_DIRECTORY_NAME = "data";
-const MANIFEST_FILE_NAME = "manifest.json";
-const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
-const NO_STORE_CACHE_CONTROL = "no-store";
-const CONTENT_TYPE_OPTIONS = "nosniff";
-const HTML_ENTRY_MAX_BYTES = 5 * 1024 * 1024;
-const LOGO_MAX_BYTES = 1024 * 1024;
-const LOGO_EXTENSIONS = ["svg", "png", "jpg", "jpeg"] as const;
-const APP_ROUTE_DATA_SEGMENT = "/data/";
-const INVALID_APP_MANIFEST_MESSAGE =
- "App manifest failed validation. Inspect manifest.json or rebuild the app.";
-
-class InvalidAppManifestError extends ApiError {
- readonly appId: AppId;
- readonly manifestPath: string;
- readonly issues: AppManifestValidationIssues;
-
- constructor(args: InvalidAppManifestErrorArgs) {
- super(422, "invalid_manifest", INVALID_APP_MANIFEST_MESSAGE);
- this.name = "InvalidAppManifestError";
- this.appId = args.appId;
- this.manifestPath = path.join(appRootPath(args), MANIFEST_FILE_NAME);
- this.issues = args.issues;
- }
-}
-
-function sha256(bytes: Buffer): string {
- return createHash("sha256").update(bytes).digest("hex");
-}
-
-function canonicalizeJson(value: JsonValue): string {
- return `${JSON.stringify(value, null, 2)}\n`;
-}
-
-function parseAppId(rawAppId: string): AppId {
- const parsed = appIdSchema.safeParse(rawAppId);
- if (!parsed.success) {
- throw new ApiError(400, "invalid_request", "Invalid app id");
- }
- return parsed.data;
-}
-
-function parseAppDataPath(rawPath: string): AppDataPath {
- const parsed = appDataPathSchema.safeParse(rawPath);
- if (!parsed.success) {
- throw new ApiError(400, "invalid_request", "Invalid app data path");
- }
- return parsed.data;
-}
-
-function parseAppDataRoutePath(rawPath: string): AppDataPath {
- return parseAppDataPath(
- parseSafeRelativeRoutePath({
- rawPath,
- dotfileSegmentPolicy: "allow",
- invalidPathMessage: "Invalid app data path",
- }).relativePath,
- );
-}
-
-function appAssetRouteSegment(args: AppAssetRouteSegmentArgs): string {
- return `/threads/${encodeURIComponent(args.threadId)}/apps/${encodeURIComponent(args.appId)}/`;
-}
-
-function parseOptionalAppDataPrefix(
- rawPrefix: string | undefined,
-): AppDataPath | "" {
- if (rawPrefix === undefined || rawPrefix === "") {
- return "";
- }
- return parseAppDataPath(rawPrefix);
-}
-
-function appRootPath(args: AppRootArgs): string {
- return path.join(args.target.storagePath, APPS_DIRECTORY_NAME, args.appId);
-}
-
-function appAssetsRootPath(args: AppRootArgs): string {
- return path.join(appRootPath(args), ASSETS_DIRECTORY_NAME);
-}
-
-function appDataRootPath(args: AppDataRootArgs): string {
- return path.join(appRootPath(args), DATA_DIRECTORY_NAME);
-}
-
-function appRootForKind(args: AppRootForKindArgs): string {
- if (args.rootKind === "app") {
- return appRootPath(args);
- }
- if (args.rootKind === "assets") {
- return appAssetsRootPath(args);
- }
- return appDataRootPath(args);
-}
-
-function parseAppAssetPath(rawPath: string): AppAssetPath {
- return parseSafeRelativeRoutePath({
- rawPath,
- directoryIndexPath: "index.html",
- dotfileSegmentPolicy: "not-found",
- invalidPathMessage: "Invalid app asset path",
- });
-}
-
-function decodeDaemonTextFile(result: DaemonFileReadResult): string {
- return Buffer.from(decodeDaemonFileContent(result)).toString("utf8");
-}
-
-function summarizeAppManifestValidationIssues(
- issues: AppManifestValidationIssues,
-): string {
- return issues
- .map((issue) => {
- const issuePath = issue.path.map(String).join(".");
- return `${issuePath || ""}: ${issue.message}`;
- })
- .join("; ");
-}
-
-function logInvalidAppManifest(
- deps: AppManifestValidationLoggerDeps,
- args: LogInvalidAppManifestArgs,
-): void {
- deps.logger.warn(
- {
- appId: args.error.appId,
- manifestPath: args.error.manifestPath,
- issueSummary: summarizeAppManifestValidationIssues(args.error.issues),
- issues: args.error.issues,
- },
- args.message,
- );
-}
-
-function remapAppCommandError(error: unknown): never {
- if (!(error instanceof ApiError)) {
- throw error;
- }
- if (error.body.code === "ENOENT") {
- throw new ApiError(
- 404,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- if (error.body.code === "invalid_path") {
- throw new ApiError(
- 400,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- if (error.body.code === "invalid_json") {
- throw new ApiError(
- 422,
- error.body.code,
- error.body.message,
- error.body.retryable,
- );
- }
- throw error;
-}
-
-async function readHostCommand<
- TType extends HostDaemonRetryableOnlineRpcCommandType,
->(
- deps: AppDeps,
- args: ReadHostCommandArgs,
-): Promise> {
- try {
- return await callHostRetryableOnlineRpc(deps, {
- hostId: args.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: args.command,
- });
- } catch (error) {
- remapAppCommandError(error);
- }
-}
-
-async function queueHostCommand(
- deps: AppDeps,
- args: QueueHostCommandArgs,
-): Promise> {
- try {
- return await queueCommandAndWait(deps, {
- hostId: args.hostId,
- timeoutMs: COMMAND_TIMEOUT_MS,
- command: args.command,
- });
- } catch (error) {
- remapAppCommandError(error);
- }
-}
-
-async function requireThreadAppsTarget(
- deps: LoggedWorkSessionDeps,
- threadId: string,
-): Promise {
- const target = await requireThreadStorageTarget(deps, { threadId });
- return {
- hostId: target.hostId,
- storagePath: target.storagePath,
- };
-}
-
-async function readAppRelativeFile(
- deps: AppDeps,
- args: ReadAppRelativeFileArgs,
-): Promise {
- return readHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.read_file_relative",
- rootPath: appRootForKind(args),
- path: args.path,
- dotfiles: args.dotfiles,
- },
- });
-}
-
-async function readAppFileMetadata(
- deps: AppDeps,
- args: ReadAppFileMetadataArgs,
-): Promise> {
- return readHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.file_metadata",
- path: path.join(appRootForKind(args), args.path),
- rootPath: appRootForKind(args),
- },
- });
-}
-
-async function readAppManifest(
- deps: AppDeps,
- args: AppManifestReadArgs,
-): Promise {
- const result = await readAppRelativeFile(deps, {
- appId: args.appId,
- target: args.target,
- rootKind: "app",
- path: MANIFEST_FILE_NAME,
- dotfiles: "deny",
- });
- let parsedJson: unknown;
- try {
- parsedJson = JSON.parse(decodeDaemonTextFile(result));
- } catch {
- throw new ApiError(422, "invalid_json", "App manifest is not valid JSON");
- }
- const manifest = appManifestSchema.safeParse(parsedJson);
- if (!manifest.success) {
- throw new InvalidAppManifestError({
- appId: args.appId,
- target: args.target,
- issues: manifest.error.issues,
- });
- }
- if (manifest.data.id !== args.appId) {
- throw new ApiError(
- 422,
- "invalid_request",
- "App manifest id must match its directory name",
- );
- }
- return manifest.data;
-}
-
-async function readAppManifestForRequest(
- deps: AppDeps,
- args: AppManifestReadArgs,
-): Promise {
- try {
- return await readAppManifest(deps, args);
- } catch (error) {
- if (error instanceof InvalidAppManifestError) {
- logInvalidAppManifest(deps, {
- error,
- message: "Rejected invalid thread app manifest",
- });
- }
- throw error;
- }
-}
-
-function entryKindForPath(entryPath: string): AppEntry["kind"] {
- const normalized = entryPath.toLowerCase();
- if (normalized.endsWith(".html")) {
- return "html";
- }
- if (normalized.endsWith(".md")) {
- return "md";
- }
- throw new ApiError(
- 422,
- "invalid_request",
- "App entry must end in .html or .md",
- );
-}
-
-async function resolveAppEntry(
- deps: AppDeps,
- args: AppManifestReadArgs & { manifest: AppManifest },
-): Promise {
- if (args.manifest.entry !== undefined) {
- return {
- path: args.manifest.entry,
- kind: entryKindForPath(args.manifest.entry),
- };
- }
-
- for (const candidate of ["index.html", "index.md"]) {
- try {
- await readAppFileMetadata(deps, {
- appId: args.appId,
- target: args.target,
- path: candidate,
- rootKind: "assets",
- });
- return {
- path: candidate,
- kind: entryKindForPath(candidate),
- };
- } catch (error) {
- if (error instanceof ApiError && error.body.code === "ENOENT") {
- continue;
- }
- throw error;
- }
- }
-
- throw new ApiError(
- 404,
- "ENOENT",
- "App entry not found: index.html or index.md",
- );
-}
-
-async function tryResolveLogo(
- deps: AppDeps,
- args: AppManifestReadArgs,
-): Promise {
- let listResult: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.list_paths",
- path: appRootPath(args),
- limit: LOGO_EXTENSIONS.length,
- includeFiles: true,
- includeDirectories: false,
- },
- });
- } catch (error) {
- if (error instanceof ApiError && error.body.code === "ENOENT") {
- return null;
- }
- throw error;
- }
-
- const topLevelFiles = new Set(
- listResult.paths
- .filter((entry) => entry.kind === "file" && !entry.path.includes("/"))
- .map((entry) => entry.path),
- );
- for (const extension of LOGO_EXTENSIONS) {
- if (topLevelFiles.has(`logo.${extension}`)) {
- return { extension };
- }
- }
- return null;
-}
-
-async function resolveAppIcon(
- deps: AppDeps,
- args: AppSummaryArgs,
-): Promise {
- if (args.manifest.icon !== undefined) {
- return { kind: "builtin", name: args.manifest.icon };
- }
- const logo = await tryResolveLogo(deps, args);
- if (logo) {
- return {
- kind: "logo",
- url: `/api/v1/threads/${encodeURIComponent(
- args.requestThreadId,
- )}/apps/${encodeURIComponent(args.appId)}/icon`,
- };
- }
- return { kind: "builtin", name: "GridView" };
-}
-
-async function buildAppSummary(
- deps: AppDeps,
- args: AppSummaryArgs,
-): Promise {
- const [entry, icon] = await Promise.all([
- resolveAppEntry(deps, args),
- resolveAppIcon(deps, args),
- ]);
- return {
- id: args.manifest.id,
- name: args.manifest.name,
- entry,
- capabilities: args.manifest.capabilities,
- icon,
- };
-}
-
-async function buildAppDetail(
- deps: AppDeps,
- args: AppDetailArgs,
-): Promise {
- let manifest: AppManifest;
- try {
- manifest = await readAppManifestForRequest(deps, args);
- } catch (error) {
- if (
- error instanceof ApiError &&
- error.status === 404 &&
- error.body.code === "ENOENT" &&
- error.body.message.includes(MANIFEST_FILE_NAME)
- ) {
- throw new ApiError(
- 404,
- "app_not_provisioned",
- `App "${args.appId}" is not provisioned yet; missing ${MANIFEST_FILE_NAME}. Restart bb from a current build if this manager was created before app seeding was available.`,
- );
- }
- throw error;
- }
- return buildAppSummary(deps, {
- ...args,
- manifest,
- });
-}
-
-function assertAppCapability(
- manifest: AppManifest,
- capability: AppCapability,
-): void {
- if (!manifest.capabilities.includes(capability)) {
- throw new ApiError(
- 403,
- "invalid_request",
- `App does not have the ${capability} capability`,
- );
- }
-}
-
-async function listThreadApps(
- deps: AppDeps,
- threadId: string,
-): Promise {
- const target = await requireThreadAppsTarget(deps, threadId);
- let listResult: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.list_paths",
- path: path.join(target.storagePath, APPS_DIRECTORY_NAME),
- limit: FILE_LIST_LIMIT_MAX,
- includeFiles: false,
- includeDirectories: true,
- },
- });
- } catch (error) {
- if (error instanceof ApiError && error.body.code === "ENOENT") {
- return [];
- }
- throw error;
- }
-
- const appIds = listResult.paths
- .filter((entry) => entry.kind === "directory" && !entry.path.includes("/"))
- .map((entry) => appIdSchema.safeParse(entry.path))
- .filter((entry) => entry.success)
- .map((entry) => entry.data)
- .sort((left, right) => left.localeCompare(right));
-
- const summaries: AppSummary[] = [];
- for (const appId of appIds) {
- try {
- const manifest = await readAppManifest(deps, { appId, target });
- summaries.push(
- await buildAppSummary(deps, {
- appId,
- target,
- manifest,
- requestThreadId: threadId,
- }),
- );
- } catch (error) {
- if (error instanceof InvalidAppManifestError) {
- logInvalidAppManifest(deps, {
- error,
- message: "Skipping invalid thread app manifest",
- });
- continue;
- }
- throw error;
- }
- }
- return summaries;
-}
-
-async function validateAppManifestForServe(
- deps: AppDeps,
- args: AppManifestReadArgs,
-): Promise {
- await readAppManifestForRequest(deps, args);
-}
-
-function createHtmlResponse(html: string): Response {
- return new Response(html, {
- status: 200,
- headers: {
- "cache-control": NO_STORE_CACHE_CONTROL,
- "content-type": HTML_CONTENT_TYPE,
- "x-content-type-options": CONTENT_TYPE_OPTIONS,
- },
- });
-}
-
-function buildAppWebSocketUrl(
- deps: LoggedWorkSessionDeps,
- requestUrl: string,
-): string {
- if (deps.config.isDevelopment) {
- return `ws://localhost:${deps.config.serverPort}/ws`;
- }
- const url = new URL(requestUrl);
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
- url.pathname = "/ws";
- url.search = "";
- url.hash = "";
- return url.toString();
-}
-
-function createInjectedAppHtmlResponse(
- deps: LoggedWorkSessionDeps,
- args: CreateInjectedAppHtmlResponseArgs,
-): Response {
- // Phase 1 app capabilities are advisory because app HTML is still served from
- // bb's same origin. We gate the injected bridge and app-owned routes by the
- // manifest-declared list, but real isolation waits for the third-party
- // extensions phase: sandboxed/opaque-origin iframes with a postMessage bridge.
- return createHtmlResponse(
- injectAppClientScript(args.html, {
- appId: args.appId,
- capabilities: args.capabilities,
- threadId: args.threadId,
- dataUrl: `/api/v1/threads/${encodeURIComponent(
- args.threadId,
- )}/apps/${encodeURIComponent(args.appId)}/data`,
- messageUrl: `/api/v1/threads/${encodeURIComponent(
- args.threadId,
- )}/apps/${encodeURIComponent(args.appId)}/message`,
- wsUrl: buildAppWebSocketUrl(deps, args.requestUrl),
- }),
- );
-}
-
-async function serveAppEntry(
- deps: AppDeps,
- threadId: string,
- rawAppId: string,
- requestUrl: string,
-): Promise {
- const appId = parseAppId(rawAppId);
- const target = await requireThreadAppsTarget(deps, threadId);
- const manifest = await readAppManifestForRequest(deps, { appId, target });
- const entry = await resolveAppEntry(deps, { appId, target, manifest });
- if (entry.kind !== "html") {
- throw new ApiError(404, "invalid_request", "App entry is not HTML");
- }
- const metadata = await readAppFileMetadata(deps, {
- appId,
- target,
- rootKind: "assets",
- path: entry.path,
- });
- if (metadata.sizeBytes > HTML_ENTRY_MAX_BYTES) {
- throw new ApiError(
- 413,
- "file_too_large",
- "App HTML entry exceeds the 5 MB limit",
- false,
- );
- }
- const result = await readAppRelativeFile(deps, {
- appId,
- target,
- rootKind: "assets",
- path: entry.path,
- dotfiles: "deny",
- });
- return createInjectedAppHtmlResponse(deps, {
- appId,
- requestUrl,
- threadId,
- html: decodeDaemonTextFile(result),
- capabilities: manifest.capabilities,
- });
-}
-
-async function serveAppAsset(
- deps: AppDeps,
- threadId: string,
- rawAppId: string,
- rawPath: string,
-): Promise {
- const appId = parseAppId(rawAppId);
- const assetPath = parseAppAssetPath(rawPath);
- const target = await requireThreadAppsTarget(deps, threadId);
- await validateAppManifestForServe(deps, { appId, target });
- try {
- const result = await readAppRelativeFile(deps, {
- appId,
- target,
- rootKind: "assets",
- path: assetPath.relativePath,
- dotfiles: "deny",
- });
- return createDaemonFileContentResponse(result, {
- headers: {
- "cache-control": NO_STORE_CACHE_CONTROL,
- "x-content-type-options": CONTENT_TYPE_OPTIONS,
- },
- });
- } catch (error) {
- return remapDaemonFileRouteError(error);
- }
-}
-
-async function serveAppIcon(
- deps: AppDeps,
- threadId: string,
- rawAppId: string,
-): Promise {
- const appId = parseAppId(rawAppId);
- const target = await requireThreadAppsTarget(deps, threadId);
- const manifest = await readAppManifestForRequest(deps, { appId, target });
- if (manifest.icon !== undefined) {
- throw new ApiError(404, "ENOENT", "App uses a built-in icon");
- }
- const logo = await tryResolveLogo(deps, { appId, target });
- if (!logo) {
- throw new ApiError(404, "ENOENT", "App logo not found");
- }
- const logoPath = `logo.${logo.extension}`;
- const metadata = await readAppFileMetadata(deps, {
- appId,
- target,
- rootKind: "app",
- path: logoPath,
- });
- if (metadata.sizeBytes > LOGO_MAX_BYTES) {
- throw new ApiError(
- 413,
- "file_too_large",
- "App logo exceeds the 1 MB limit",
- false,
- );
- }
- const result = await readAppRelativeFile(deps, {
- appId,
- target,
- rootKind: "app",
- path: logoPath,
- dotfiles: "deny",
- });
- const contentType = mimeTypes.lookup(logoPath) || "application/octet-stream";
- return new Response(decodeDaemonFileContent(result), {
- status: 200,
- headers: {
- "cache-control": NO_STORE_CACHE_CONTROL,
- "content-type": contentType,
- "x-content-type-options": CONTENT_TYPE_OPTIONS,
- },
- });
-}
-
-async function readAppDataEntry(
- deps: AppDeps,
- args: ReadAppDataEntryArgs,
-): Promise {
- const file = await readAppRelativeFile(deps, {
- appId: args.appId,
- target: args.target,
- rootKind: "data",
- path: args.dataPath,
- dotfiles: "deny",
- });
- let value: JsonValue;
- const bytes = Buffer.from(decodeDaemonFileContent(file));
- try {
- value = jsonValueSchema.parse(JSON.parse(bytes.toString("utf8")));
- } catch {
- throw new ApiError(
- 422,
- "invalid_json",
- `App data path ${args.dataPath} does not contain valid JSON`,
- );
- }
- const modifiedAtMs =
- file.modifiedAtMs ??
- (
- await readAppFileMetadata(deps, {
- appId: args.appId,
- target: args.target,
- rootKind: "data",
- path: args.dataPath,
- })
- ).modifiedAtMs;
- return {
- path: args.dataPath,
- value,
- version: sha256(bytes),
- sizeBytes: file.sizeBytes,
- modifiedAtMs,
- };
-}
-
-function shouldListAppDataPrefixAfterReadError(error: Error): boolean {
- return (
- error instanceof ApiError &&
- (error.body.code === "ENOENT" ||
- (error.body.code === "invalid_path" &&
- error.body.message.includes("Path is a directory")))
- );
-}
-
-async function listAppDataEntries(
- deps: AppDeps,
- args: ListAppDataEntriesArgs,
-): Promise {
- if (args.dataPath !== "") {
- try {
- return [
- await readAppDataEntry(deps, {
- appId: args.appId,
- dataPath: args.dataPath,
- target: args.target,
- }),
- ];
- } catch (error) {
- if (
- !(error instanceof Error) ||
- !shouldListAppDataPrefixAfterReadError(error)
- ) {
- throw error;
- }
- }
- }
-
- const listRoot = args.dataPath
- ? path.join(appDataRootPath(args), args.dataPath)
- : appDataRootPath(args);
- let listResult: HostDaemonOnlineRpcResult<"host.list_paths">;
- try {
- listResult = await readHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.list_paths",
- path: listRoot,
- limit: FILE_LIST_LIMIT_MAX,
- includeFiles: true,
- includeDirectories: false,
- },
- });
- } catch (error) {
- if (error instanceof ApiError && error.body.code === "ENOENT") {
- return [];
- }
- throw error;
- }
-
- const dataPaths = listResult.paths
- .filter((entry) => entry.kind === "file")
- .map((entry) =>
- args.dataPath ? `${args.dataPath}/${entry.path}` : entry.path,
- )
- .map((entryPath) => appDataPathSchema.safeParse(entryPath))
- .filter((entryPath) => entryPath.success)
- .map((entryPath) => entryPath.data)
- .sort((left, right) => left.localeCompare(right));
-
- return Promise.all(
- dataPaths.map((dataPath) =>
- readAppDataEntry(deps, {
- appId: args.appId,
- dataPath,
- target: args.target,
- }),
- ),
- );
-}
-
-async function writeAppDataEntry(
- deps: AppDeps,
- args: WriteAppDataEntryArgs,
-): Promise {
- const content = canonicalizeJson(args.value);
- const result = await queueHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.write_file_relative",
- rootPath: appDataRootPath(args),
- path: args.dataPath,
- dotfiles: "deny",
- content,
- contentEncoding: "utf8",
- },
- });
- return {
- path: args.dataPath,
- value: args.value,
- version: result.hash,
- sizeBytes: result.sizeBytes,
- modifiedAtMs: result.modifiedAtMs,
- };
-}
-
-async function deleteAppDataEntry(
- deps: AppDeps,
- args: DeleteAppDataEntryArgs,
-): Promise {
- await queueHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.delete_file_relative",
- rootPath: appDataRootPath(args),
- path: args.dataPath,
- dotfiles: "deny",
- },
- });
-}
-
-function createTemplateManifest(
- request: CreateThreadAppRequest,
- template: AppTemplate,
-): AppManifest {
- const manifest: AppManifest = {
- manifestVersion: 1,
- id: request.id,
- name: request.name,
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- };
- if (template === "status") {
- manifest.icon = "ListTodo";
- }
- return manifest;
-}
-
-function templateFilesForRequest(
- request: CreateThreadAppRequest,
-): TemplateFile[] {
- const manifest = createTemplateManifest(request, request.template);
- return [
- {
- path: MANIFEST_FILE_NAME,
- content: canonicalizeJson(manifest),
- },
- {
- path: "assets/index.html",
- content: buildBlankAppIndexHtml({ name: request.name }),
- },
- {
- path: "data/state.json",
- content: canonicalizeJson({}),
- },
- ];
-}
-
-async function scaffoldApp(
- deps: AppDeps,
- args: ScaffoldAppArgs,
-): Promise {
- try {
- await readAppManifestForRequest(deps, {
- appId: args.request.id,
- target: args.target,
- });
- throw new ApiError(409, "invalid_request", "App already exists");
- } catch (error) {
- if (!(error instanceof ApiError) || error.status !== 404) {
- throw error;
- }
- }
-
- const files = templateFilesForRequest(args.request);
- for (const file of files) {
- await queueHostCommand(deps, {
- hostId: args.target.hostId,
- command: {
- type: "host.write_file_relative",
- rootPath: appRootPath({
- appId: args.request.id,
- target: args.target,
- }),
- path: file.path,
- dotfiles: "deny",
- content: file.content,
- contentEncoding: "utf8",
- },
- });
- }
- return buildAppDetail(deps, {
- appId: args.request.id,
- target: args.target,
- requestThreadId: args.threadId,
- });
-}
-
-export function registerThreadAppRoutes(app: Hono, deps: AppDeps): void {
- const { get, post, put, del } = typedRoutes(app, {
- onValidationError: (msg) => new ApiError(400, "invalid_request", msg),
- });
-
- get("/threads/:id/apps", async (context) =>
- context.json(await listThreadApps(deps, context.req.param("id"))),
- );
-
- post(
- "/threads/:id/apps",
- createThreadAppRequestSchema,
- async (context, payload) => {
- const threadId = context.req.param("id");
- const target = await requireThreadAppsTarget(deps, threadId);
- const detail = await scaffoldApp(deps, {
- target,
- request: payload,
- threadId,
- });
- return context.json(detail, 201);
- },
- );
-
- get("/threads/:id/apps/:appId", async (context) => {
- const threadId = context.req.param("id");
- const appId = parseAppId(context.req.param("appId"));
- const target = await requireThreadAppsTarget(deps, threadId);
- return context.json(
- await buildAppDetail(deps, {
- appId,
- target,
- requestThreadId: threadId,
- }),
- );
- });
-
- del("/threads/:id/apps/:appId", async (context) => {
- const threadId = context.req.param("id");
- const appId = parseAppId(context.req.param("appId"));
- const target = await requireThreadAppsTarget(deps, threadId);
- const result = await queueHostCommand(deps, {
- hostId: target.hostId,
- command: {
- type: "host.delete_path_relative",
- rootPath: path.join(target.storagePath, APPS_DIRECTORY_NAME),
- path: appId,
- dotfiles: "deny",
- },
- });
- if (!result.deleted) {
- throw new ApiError(404, "ENOENT", `App not found: ${appId}`);
- }
- return context.json({ ok: true });
- });
-
- get(
- "/threads/:id/apps/:appId/data",
- appDataListQuerySchema,
- async (context, query) => {
- const target = await requireThreadAppsTarget(
- deps,
- context.req.param("id"),
- );
- const appId = parseAppId(context.req.param("appId"));
- assertAppCapability(
- await readAppManifestForRequest(deps, { appId, target }),
- "data",
- );
- const dataPath = parseOptionalAppDataPrefix(query.prefix);
- return context.json({
- entries: await listAppDataEntries(deps, {
- appId,
- target,
- dataPath,
- }),
- });
- },
- );
-
- post(
- "/threads/:id/apps/:appId/message",
- appMessageRequestSchema,
- async (context, payload) => {
- const thread = requirePublicThread(deps.db, context.req.param("id"));
- const manifest = await readAppManifestForRequest(deps, {
- appId: parseAppId(context.req.param("appId")),
- target: await requireThreadAppsTarget(deps, thread.id),
- });
- assertAppCapability(manifest, "message");
- const environment = await requireThreadCommandEnvironment(deps, {
- thread,
- });
- await sendThreadMessage(deps, {
- environment,
- thread,
- trigger: "user",
- payload: {
- input: [{ type: "text", text: payload.text }],
- mode: "auto",
- },
- });
- return context.json({ ok: true });
- },
- );
-
- app.get("/threads/:id/apps/:appId/", async (context) =>
- serveAppEntry(
- deps,
- context.req.param("id"),
- context.req.param("appId"),
- context.req.url,
- ),
- );
-
- app.get("/threads/:id/apps/:appId/icon", async (context) =>
- serveAppIcon(deps, context.req.param("id"), context.req.param("appId")),
- );
-
- get("/threads/:id/apps/:appId/data/*", async (context) => {
- const target = await requireThreadAppsTarget(deps, context.req.param("id"));
- const appId = parseAppId(context.req.param("appId"));
- assertAppCapability(
- await readAppManifestForRequest(deps, { appId, target }),
- "data",
- );
- const dataPath = parseAppDataRoutePath(
- extractRoutePath({
- requestUrl: context.req.url,
- routeSegment: APP_ROUTE_DATA_SEGMENT,
- }),
- );
- const entry = await readAppDataEntry(deps, {
- appId,
- target,
- dataPath,
- });
- const response = context.json(entry);
- response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
- response.headers.set("etag", `"${entry.version}"`);
- return response;
- });
-
- put(
- "/threads/:id/apps/:appId/data/*",
- appDataWriteRequestSchema,
- async (context, payload) => {
- const target = await requireThreadAppsTarget(
- deps,
- context.req.param("id"),
- );
- const appId = parseAppId(context.req.param("appId"));
- const manifest = await readAppManifestForRequest(deps, {
- appId,
- target,
- });
- assertAppCapability(manifest, "data");
- const dataPath = parseAppDataRoutePath(
- extractRoutePath({
- requestUrl: context.req.url,
- routeSegment: APP_ROUTE_DATA_SEGMENT,
- }),
- );
- const entry = await writeAppDataEntry(deps, {
- appId,
- target,
- dataPath,
- value: payload.value,
- });
- const response = context.json(entry);
- response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
- response.headers.set("etag", `"${entry.version}"`);
- return response;
- },
- );
-
- del("/threads/:id/apps/:appId/data/*", async (context) => {
- const target = await requireThreadAppsTarget(deps, context.req.param("id"));
- const appId = parseAppId(context.req.param("appId"));
- const manifest = await readAppManifestForRequest(deps, { appId, target });
- assertAppCapability(manifest, "data");
- const dataPath = parseAppDataRoutePath(
- extractRoutePath({
- requestUrl: context.req.url,
- routeSegment: APP_ROUTE_DATA_SEGMENT,
- }),
- );
- await deleteAppDataEntry(deps, {
- appId,
- target,
- dataPath,
- });
- const response = context.json({ ok: true });
- response.headers.set("cache-control", NO_STORE_CACHE_CONTROL);
- return response;
- });
-
- // Keep this flat asset wildcard last among GET app routes so typed app
- // endpoints such as /data/* and /icon keep their route ownership.
- app.get("/threads/:id/apps/:appId/*", async (context) => {
- const threadId = context.req.param("id");
- const appId = context.req.param("appId");
- return serveAppAsset(
- deps,
- threadId,
- appId,
- extractRoutePath({
- requestUrl: context.req.url,
- routeSegment: appAssetRouteSegment({ threadId, appId }),
- }),
- );
- });
-}
diff --git a/apps/server/src/routes/threads/index.ts b/apps/server/src/routes/threads/index.ts
index 73f8159de..d5c0519a0 100644
--- a/apps/server/src/routes/threads/index.ts
+++ b/apps/server/src/routes/threads/index.ts
@@ -1,7 +1,6 @@
import type { Hono } from "hono";
import type { AppDeps } from "../../types.js";
import { registerThreadActionRoutes } from "./actions.js";
-import { registerThreadAppRoutes } from "./apps.js";
import { registerThreadBaseRoutes } from "./base.js";
import { registerThreadDataRoutes } from "./data.js";
import { registerThreadInteractionRoutes } from "./interactions.js";
@@ -13,7 +12,6 @@ export function registerThreadRoutes(app: Hono, deps: AppDeps): void {
if (deps.config.featureFlags.terminals) {
registerThreadTerminalRoutes(app, deps);
}
- registerThreadAppRoutes(app, deps);
registerThreadDataRoutes(app, deps);
registerThreadInteractionRoutes(app, deps);
}
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 888ee7d66..38d4c6eb0 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -11,6 +11,7 @@ import {
import type { AppDeps, ServerAppDeps } from "./types.js";
import { ApiError, errorToResponse } from "./errors.js";
import { registerAutomationRoutes } from "./routes/automations.js";
+import { registerGlobalAppRoutes } from "./routes/apps.js";
import { registerEnvironmentRoutes } from "./routes/environments.js";
import { registerFileRoutes } from "./routes/files.js";
import { registerHostRoutes } from "./routes/hosts.js";
@@ -244,6 +245,7 @@ export function createApp(
return next();
});
const publicApi = new Hono();
+ registerGlobalAppRoutes(publicApi, deps);
registerProjectRoutes(publicApi, deps);
registerAutomationRoutes(publicApi, deps);
registerFileRoutes(publicApi, deps);
diff --git a/apps/server/src/services/apps/tracked-application-data-targets.ts b/apps/server/src/services/apps/tracked-application-data-targets.ts
new file mode 100644
index 000000000..97f7ad53c
--- /dev/null
+++ b/apps/server/src/services/apps/tracked-application-data-targets.ts
@@ -0,0 +1,79 @@
+import { readdir, readFile } from "node:fs/promises";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import { applicationIdSchema, type ApplicationId } from "@bb/domain";
+import { appManifestSchema } from "@bb/server-contract";
+
+export interface TrackedApplicationDataTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
+interface ListTrackedApplicationDataTargetsArgs {
+ dataDir: string;
+}
+
+function isIgnoredApplicationStorageEntry(entryName: string): boolean {
+ return (
+ entryName.startsWith(".tmp-app_") || entryName.startsWith(".delete-app_")
+ );
+}
+
+async function isValidApplicationManifest(
+ dataDir: string,
+ applicationId: ApplicationId,
+): Promise {
+ try {
+ const manifest = appManifestSchema.parse(
+ JSON.parse(
+ await readFile(
+ resolveApplicationManifestPath(dataDir, applicationId),
+ "utf8",
+ ),
+ ),
+ );
+ return manifest.id === applicationId;
+ } catch {
+ return false;
+ }
+}
+
+export async function listTrackedApplicationDataTargets(
+ args: ListTrackedApplicationDataTargetsArgs,
+): Promise {
+ let entries;
+ try {
+ entries = await readdir(resolveAppsRootPath(args.dataDir), {
+ withFileTypes: true,
+ });
+ } catch {
+ return [];
+ }
+
+ const targets: TrackedApplicationDataTarget[] = [];
+ for (const entry of entries) {
+ if (
+ !entry.isDirectory() ||
+ isIgnoredApplicationStorageEntry(entry.name)
+ ) {
+ continue;
+ }
+ const parsed = applicationIdSchema.safeParse(entry.name);
+ if (!parsed.success) {
+ continue;
+ }
+ if (!(await isValidApplicationManifest(args.dataDir, parsed.data))) {
+ continue;
+ }
+ targets.push({
+ applicationId: parsed.data,
+ appDataPath: resolveApplicationDataPath(args.dataDir, parsed.data),
+ });
+ }
+ return targets.sort((left, right) =>
+ left.applicationId.localeCompare(right.applicationId),
+ );
+}
diff --git a/apps/server/src/services/skills/injected-skills.ts b/apps/server/src/services/skills/injected-skills.ts
new file mode 100644
index 000000000..db0739ab6
--- /dev/null
+++ b/apps/server/src/services/skills/injected-skills.ts
@@ -0,0 +1,467 @@
+import type { Dirent } from "node:fs";
+import fs from "node:fs";
+import path from "node:path";
+import matter from "gray-matter";
+import {
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveAppsRootPath,
+ resolveDataDirSkillsRootPath,
+} from "@bb/config/app-storage-paths";
+import { applicationIdSchema, type ApplicationId } from "@bb/domain";
+import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract";
+import { appManifestSchema } from "@bb/server-contract";
+import { z } from "zod";
+import type { ServerLogger } from "../../types.js";
+
+const SKILL_FILE_NAME = "SKILL.md";
+const SKILL_NAME_PATTERN =
+ /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u;
+const SKILL_FRONTMATTER_DELIMITER = "---";
+
+const skillFrontmatterSchema = z
+ .object({
+ name: z
+ .string()
+ .max(64)
+ .regex(
+ SKILL_NAME_PATTERN,
+ "Skill name must use lowercase letters, numbers, and single hyphens",
+ ),
+ description: z
+ .string()
+ .max(1024)
+ .refine((value) => value.trim().length > 0, {
+ message: "Skill description must be non-empty",
+ }),
+ })
+ .passthrough();
+
+export interface ResolveInjectedSkillSourcesArgs {
+ dataDir: string;
+}
+
+interface SkillCandidateSource {
+ applicationId: ApplicationId | null;
+ sourceType: HostDaemonInjectedSkillSource["sourceType"];
+}
+
+interface SkillRootScanArgs extends SkillCandidateSource {
+ logger: ServerLogger;
+ skillsRootPath: string;
+}
+
+interface SkillCandidateArgs extends SkillCandidateSource {
+ candidatePath: string;
+ directoryName: string;
+ logger: ServerLogger;
+}
+
+interface InvalidSkillLogArgs extends SkillCandidateSource {
+ candidatePath: string;
+ logger: ServerLogger;
+ reason: string;
+}
+
+interface SkillCollisionLogArgs {
+ colliding: readonly HostDaemonInjectedSkillSource[];
+ logger: ServerLogger;
+ name: string;
+}
+
+interface ReadValidApplicationIdsArgs {
+ appsRootPath: string;
+ dataDir: string;
+ logger: ServerLogger;
+}
+
+interface ApplicationManifestReadArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+ logger: ServerLogger;
+}
+
+function isFsErrorWithCode(error: Error, code: string): boolean {
+ return "code" in error && error.code === code;
+}
+
+function compactZodIssues(issues: z.ZodIssue[]): string {
+ return issues
+ .map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
+ .join("; ");
+}
+
+function hasSupportedFrontmatterDelimiter(content: string): boolean {
+ const trimmed = content.trimStart();
+ return (
+ trimmed.startsWith(`${SKILL_FRONTMATTER_DELIMITER}\n`) ||
+ trimmed.startsWith(`${SKILL_FRONTMATTER_DELIMITER}\r\n`)
+ );
+}
+
+function logInvalidSkill(args: InvalidSkillLogArgs): void {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ candidatePath: args.candidatePath,
+ reason: args.reason,
+ sourceType: args.sourceType,
+ },
+ "Skipping invalid injected skill",
+ );
+}
+
+function logSkillCollision(args: SkillCollisionLogArgs): void {
+ for (const source of args.colliding) {
+ args.logger.warn(
+ {
+ applicationId: source.applicationId,
+ name: args.name,
+ sourceRootPath: source.sourceRootPath,
+ sourceType: source.sourceType,
+ },
+ "Skipping colliding injected skill",
+ );
+ }
+}
+
+function sortDirentsByName(left: Dirent, right: Dirent): number {
+ return left.name.localeCompare(right.name);
+}
+
+function toSkillFilePath(candidatePath: string): string {
+ return path.join(candidatePath, SKILL_FILE_NAME);
+}
+
+function readSkillCandidate(
+ args: SkillCandidateArgs,
+): HostDaemonInjectedSkillSource | null {
+ const skillFilePath = toSkillFilePath(args.candidatePath);
+ let skillFileStat;
+ try {
+ skillFileStat = fs.lstatSync(skillFilePath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ logInvalidSkill({
+ ...args,
+ reason: "Missing SKILL.md",
+ });
+ return null;
+ }
+ throw error;
+ }
+
+ if (skillFileStat.isSymbolicLink()) {
+ logInvalidSkill({
+ ...args,
+ reason: "SKILL.md is a symlink",
+ });
+ return null;
+ }
+ if (!skillFileStat.isFile()) {
+ logInvalidSkill({
+ ...args,
+ reason: "SKILL.md is not a regular file",
+ });
+ return null;
+ }
+
+ const content = fs.readFileSync(skillFilePath, "utf8");
+ if (!hasSupportedFrontmatterDelimiter(content)) {
+ logInvalidSkill({
+ ...args,
+ reason: "SKILL.md frontmatter must start with a plain --- delimiter",
+ });
+ return null;
+ }
+
+ let parsed;
+ try {
+ parsed = matter(content);
+ } catch (error) {
+ logInvalidSkill({
+ ...args,
+ reason:
+ error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "Invalid SKILL.md frontmatter",
+ });
+ return null;
+ }
+
+ const frontmatter = skillFrontmatterSchema.safeParse(parsed.data);
+ if (!frontmatter.success) {
+ logInvalidSkill({
+ ...args,
+ reason: compactZodIssues(frontmatter.error.issues),
+ });
+ return null;
+ }
+
+ if (frontmatter.data.name !== args.directoryName) {
+ logInvalidSkill({
+ ...args,
+ reason: "Frontmatter name must match the skill directory name",
+ });
+ return null;
+ }
+
+ if (args.sourceType === "data-dir") {
+ return {
+ sourceType: "data-dir",
+ applicationId: null,
+ name: frontmatter.data.name,
+ description: frontmatter.data.description,
+ sourceRootPath: args.candidatePath,
+ skillFilePath,
+ };
+ }
+
+ if (args.applicationId === null) {
+ throw new Error("Global app skill source requires an applicationId");
+ }
+ return {
+ sourceType: "global-app",
+ applicationId: args.applicationId,
+ name: frontmatter.data.name,
+ description: frontmatter.data.description,
+ sourceRootPath: args.candidatePath,
+ skillFilePath,
+ };
+}
+
+function readSkillsRoot(
+ args: SkillRootScanArgs,
+): HostDaemonInjectedSkillSource[] {
+ let rootStat;
+ try {
+ rootStat = fs.lstatSync(args.skillsRootPath);
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ if (rootStat.isSymbolicLink()) {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ skillsRootPath: args.skillsRootPath,
+ sourceType: args.sourceType,
+ },
+ "Skipping symlinked injected skills root",
+ );
+ return [];
+ }
+ if (!rootStat.isDirectory()) {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ skillsRootPath: args.skillsRootPath,
+ sourceType: args.sourceType,
+ },
+ "Skipping non-directory injected skills root",
+ );
+ return [];
+ }
+
+ const entries = fs.readdirSync(args.skillsRootPath, {
+ withFileTypes: true,
+ }).sort(sortDirentsByName);
+ const sources: HostDaemonInjectedSkillSource[] = [];
+
+ for (const entry of entries) {
+ const candidatePath = path.join(args.skillsRootPath, entry.name);
+ if (entry.isSymbolicLink()) {
+ logInvalidSkill({
+ ...args,
+ candidatePath,
+ reason: "Skill directory is a symlink",
+ });
+ continue;
+ }
+ if (!entry.isDirectory()) {
+ logInvalidSkill({
+ ...args,
+ candidatePath,
+ reason: "Skill candidate is not a directory",
+ });
+ continue;
+ }
+ const source = readSkillCandidate({
+ applicationId: args.applicationId,
+ candidatePath,
+ directoryName: entry.name,
+ logger: args.logger,
+ sourceType: args.sourceType,
+ });
+ if (source) {
+ sources.push(source);
+ }
+ }
+
+ return sources;
+}
+
+function hasValidApplicationManifest(
+ args: ApplicationManifestReadArgs,
+): boolean {
+ let parsedJson;
+ try {
+ parsedJson = JSON.parse(
+ fs.readFileSync(
+ resolveApplicationManifestPath(args.dataDir, args.applicationId),
+ "utf8",
+ ),
+ );
+ } catch (error) {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ manifestPath: resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ ),
+ reason:
+ error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "Invalid app manifest",
+ },
+ "Skipping invalid global app manifest for injected skills",
+ );
+ return false;
+ }
+
+ const manifest = appManifestSchema.safeParse(parsedJson);
+ if (!manifest.success) {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ issues: compactZodIssues(manifest.error.issues),
+ manifestPath: resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ ),
+ },
+ "Skipping invalid global app manifest for injected skills",
+ );
+ return false;
+ }
+
+ if (manifest.data.id !== args.applicationId) {
+ args.logger.warn(
+ {
+ applicationId: args.applicationId,
+ manifestId: manifest.data.id,
+ manifestPath: resolveApplicationManifestPath(
+ args.dataDir,
+ args.applicationId,
+ ),
+ },
+ "Skipping global app skills because manifest id does not match directory",
+ );
+ return false;
+ }
+
+ return true;
+}
+
+function readValidApplicationIds(
+ args: ReadValidApplicationIdsArgs,
+): ApplicationId[] {
+ let entries: Dirent[];
+ try {
+ entries = fs.readdirSync(args.appsRootPath, { withFileTypes: true });
+ } catch (error) {
+ if (error instanceof Error && isFsErrorWithCode(error, "ENOENT")) {
+ return [];
+ }
+ throw error;
+ }
+
+ const applicationIds: ApplicationId[] = [];
+ for (const entry of entries.sort(sortDirentsByName)) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ const parsed = applicationIdSchema.safeParse(entry.name);
+ if (!parsed.success) {
+ continue;
+ }
+ const isValid = hasValidApplicationManifest({
+ applicationId: parsed.data,
+ dataDir: args.dataDir,
+ logger: args.logger,
+ });
+ if (isValid) {
+ applicationIds.push(parsed.data);
+ }
+ }
+ return applicationIds;
+}
+
+function excludeCollisions(
+ logger: ServerLogger,
+ sources: readonly HostDaemonInjectedSkillSource[],
+): HostDaemonInjectedSkillSource[] {
+ const byName = new Map();
+ for (const source of sources) {
+ const existing = byName.get(source.name) ?? [];
+ existing.push(source);
+ byName.set(source.name, existing);
+ }
+
+ const resolved: HostDaemonInjectedSkillSource[] = [];
+ for (const [name, entries] of byName) {
+ if (entries.length === 1) {
+ const source = entries[0];
+ if (source) {
+ resolved.push(source);
+ }
+ continue;
+ }
+ logSkillCollision({
+ colliding: entries,
+ logger,
+ name,
+ });
+ }
+
+ return resolved.sort((left, right) => left.name.localeCompare(right.name));
+}
+
+export function resolveInjectedSkillSources(
+ logger: ServerLogger,
+ args: ResolveInjectedSkillSourcesArgs,
+): HostDaemonInjectedSkillSource[] {
+ const dataDirSources = readSkillsRoot({
+ applicationId: null,
+ logger,
+ skillsRootPath: resolveDataDirSkillsRootPath(args.dataDir),
+ sourceType: "data-dir",
+ });
+
+ const appSources: HostDaemonInjectedSkillSource[] = [];
+ const appsRootPath = resolveAppsRootPath(args.dataDir);
+ const applicationIds = readValidApplicationIds({
+ appsRootPath,
+ dataDir: args.dataDir,
+ logger,
+ });
+ for (const applicationId of applicationIds) {
+ appSources.push(
+ ...readSkillsRoot({
+ applicationId,
+ logger,
+ skillsRootPath: path.join(
+ resolveApplicationPath(args.dataDir, applicationId),
+ "skills",
+ ),
+ sourceType: "global-app",
+ }),
+ );
+ }
+
+ return excludeCollisions(logger, [...dataDirSources, ...appSources]);
+}
diff --git a/apps/server/src/services/threads/app-client-script.ts b/apps/server/src/services/threads/app-client-script.ts
index 262e3708d..90e315d63 100644
--- a/apps/server/src/services/threads/app-client-script.ts
+++ b/apps/server/src/services/threads/app-client-script.ts
@@ -1,12 +1,14 @@
-import type { AppId } from "@bb/domain";
+import type { ApplicationId } from "@bb/domain";
import type { AppCapability } from "@bb/server-contract";
export interface AppClientBootstrap {
- appId: AppId;
+ appId: ApplicationId;
+ applicationId: ApplicationId;
+ appSessionToken: string | null;
capabilities: AppCapability[];
dataUrl: string;
messageUrl: string;
- threadId: string;
+ targetThreadId: string | null;
wsUrl: string;
}
@@ -241,7 +243,7 @@ function buildAppClientJavascript(bootstrapJson: string): string {
var socketSubscribed = false;
var subscriptionReady = null;
var resolveSubscriptionReady = null;
- var subscriptionEntityId = bootstrap.threadId + ":app:" + bootstrap.appId + ":data";
+ var subscriptionEntityId = bootstrap.applicationId + ":data";
function pathMatchesPrefix(path, prefix) {
return prefix === "" || path === prefix || path.indexOf(prefix + "/") === 0;
@@ -274,8 +276,7 @@ function buildAppClientJavascript(bootstrapJson: string): string {
function handleBroadcast(message) {
if (
!message ||
- message.threadId !== bootstrap.threadId ||
- message.appId !== bootstrap.appId
+ message.applicationId !== bootstrap.applicationId
) {
return;
}
@@ -449,8 +450,8 @@ function buildAppClientJavascript(bootstrapJson: string): string {
}
function message(text) {
- if (typeof text !== "string") {
- throw new TypeError("window.bb.message(text) requires a string");
+ if (text === undefined || !isJsonValue(text)) {
+ throw new TypeError("window.bb.message(payload) requires a JSON value");
}
return fetch(bootstrap.messageUrl, {
method: "POST",
@@ -459,14 +460,17 @@ function buildAppClientJavascript(bootstrapJson: string): string {
"Accept": "application/json",
"Content-Type": "application/json"
},
- body: JSON.stringify({ text: text })
+ body: JSON.stringify({
+ payload: text,
+ appSessionToken: bootstrap.appSessionToken || undefined
+ })
}).then(function (response) {
if (response.ok) return undefined;
return rejectResponse(response);
});
}
- var bb = { appId: bootstrap.appId };
+ var bb = { appId: bootstrap.appId, applicationId: bootstrap.applicationId };
if (hasCapability("data")) {
bb.data = {
read: read,
diff --git a/apps/server/src/services/threads/blank-app-scaffold.ts b/apps/server/src/services/threads/blank-app-scaffold.ts
index 489cdd965..c0fa9fad5 100644
--- a/apps/server/src/services/threads/blank-app-scaffold.ts
+++ b/apps/server/src/services/threads/blank-app-scaffold.ts
@@ -159,12 +159,9 @@ const BLANK_APP_SCAFFOLD_EXTRA_STYLES = ``;
-// Blank-template scaffold rendered into apps//assets/index.html. Composes
-// the documented bb default styling head with a scaffold-only style block and
-// the task-list visual vocabulary borrowed from the bundled status app, so new
-// apps start out looking bb-native rather than like bare HTML. The same
-// scaffold powers both `bb app new` (any name) and the bundled default status
-// app seeded into every manager thread (name="Status").
+// Blank-template scaffold rendered into an app's public/index.html. Composes
+// the documented bb default styling head with a scaffold-only style block so
+// new apps start out looking bb-native rather than like bare HTML.
const BLANK_APP_INDEX_HTML_TEMPLATE = `
@@ -183,7 +180,7 @@ const BLANK_APP_INDEX_HTML_TEMPLATE = `
- Customize this static app in assets/index.html. No web server or build step needed.
+ Customize this static app in public/index.html. No web server or build step needed.
@@ -202,8 +199,8 @@ const BLANK_APP_INDEX_HTML_TEMPLATE = `
diff --git a/apps/server/src/services/threads/default-template/apps/status/manifest.json b/apps/server/src/services/threads/default-template/apps/status/manifest.json
deleted file mode 100644
index 8f83d9dc3..000000000
--- a/apps/server/src/services/threads/default-template/apps/status/manifest.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "manifestVersion": 1,
- "id": "status",
- "name": "Status",
- "icon": "ListTodo",
- "entry": "index.html",
- "contributions": ["thread.app"],
- "capabilities": ["data", "message"]
-}
diff --git a/apps/server/src/services/threads/manager-storage-templates.ts b/apps/server/src/services/threads/manager-storage-templates.ts
index fb7718590..23a148ecf 100644
--- a/apps/server/src/services/threads/manager-storage-templates.ts
+++ b/apps/server/src/services/threads/manager-storage-templates.ts
@@ -1,56 +1,25 @@
-import { constants as fsConstants, readFileSync } from "node:fs";
+import { constants as fsConstants } from "node:fs";
import type { Dirent } from "node:fs";
-import {
- copyFile,
- mkdir,
- readdir,
- readFile,
- writeFile,
-} from "node:fs/promises";
+import { copyFile, mkdir, readdir, readFile } from "node:fs/promises";
import path from "node:path";
-import { fileURLToPath } from "node:url";
import {
managerTemplateNameSchema,
type ManagerTemplateName,
} from "@bb/domain";
import type { LoggedWorkSessionDeps, ServerLogger } from "../../types.js";
import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js";
-import { buildBlankAppIndexHtml } from "./blank-app-scaffold.js";
export const MANAGER_TEMPLATE_DIR_NAME = "manager-templates";
export const ACTIVE_MANAGER_TEMPLATE_FILE_NAME = "active";
export const DEFAULT_MANAGER_TEMPLATE_NAME: ManagerTemplateName = "default";
-const BUNDLED_STATUS_APP_NAME = "Status";
-const BUNDLED_STATUS_APP_INDEX_HTML = buildBlankAppIndexHtml({
- name: BUNDLED_STATUS_APP_NAME,
-});
-const BUNDLED_STATUS_APP_STATE_JSON = "{}\n";
-
-const moduleDir = path.dirname(fileURLToPath(import.meta.url));
-const defaultTemplateAssetDir = path.join(moduleDir, "default-template");
-
-function loadDefaultTemplateAsset(fileName: string): string {
- return readFileSync(path.join(defaultTemplateAssetDir, fileName), "utf8");
-}
-
type ManagerTemplateLogger = Pick;
-interface BuiltInManagerTemplateFile {
- content: string;
- fileName: string;
-}
-
interface TemplateFileToCopy {
relativePath: string;
sourcePath: string;
}
-interface BuiltInManagerTemplateSet {
- files: readonly BuiltInManagerTemplateFile[];
- name: ManagerTemplateName;
-}
-
interface ResolveManagerTemplateNameArgs {
dataDir: string;
explicitTemplateName: ManagerTemplateName | null;
@@ -72,14 +41,6 @@ interface CopyTemplateFilesArgs {
threadStoragePath: string;
}
-interface CopyBuiltInTemplateFilesArgs {
- files: readonly BuiltInManagerTemplateFile[];
- logger: ManagerTemplateLogger;
- templateName: ManagerTemplateName;
- threadId: string;
- threadStoragePath: string;
-}
-
interface FsErrorWithCodeArgs {
code: string;
error: unknown;
@@ -95,34 +56,6 @@ interface ManagerTemplateSetPathArgs extends ManagerTemplateRootPathArgs {
type CopyTemplateFilesResult = "copied" | "missing";
-// Built-in defaults stay in the server bundle. They are always overlaid on top
-// of any user-authored template copy so newly-provisioned threads have a
-// working status surface even if the user's template omits these files.
-// User-authored files win because they are copied first and the overlay uses
-// the `wx` flag, which refuses to overwrite existing destinations.
-//
-// The bundled status app shares its index.html with the `bb app new` blank
-// scaffold (via buildBlankAppIndexHtml) so new users open a bb-styled
-// starting-point dashboard they can ask their agent to customize, rather than
-// inheriting any one workflow-specific UI.
-const BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET: BuiltInManagerTemplateSet = {
- name: DEFAULT_MANAGER_TEMPLATE_NAME,
- files: [
- {
- fileName: "apps/status/manifest.json",
- content: loadDefaultTemplateAsset("apps/status/manifest.json"),
- },
- {
- fileName: "apps/status/assets/index.html",
- content: BUNDLED_STATUS_APP_INDEX_HTML,
- },
- {
- fileName: "apps/status/data/state.json",
- content: BUNDLED_STATUS_APP_STATE_JSON,
- },
- ],
-};
-
function isFsErrorWithCode(args: FsErrorWithCodeArgs): boolean {
return (
typeof args.error === "object" &&
@@ -277,37 +210,6 @@ async function collectTemplateFiles(
return files;
}
-async function copyBuiltInTemplateFiles(
- args: CopyBuiltInTemplateFilesArgs,
-): Promise {
- await mkdir(args.threadStoragePath, { recursive: true });
-
- for (const file of args.files) {
- const destinationPath = path.join(args.threadStoragePath, file.fileName);
- try {
- await mkdir(path.dirname(destinationPath), { recursive: true });
- await writeFile(destinationPath, file.content, {
- encoding: "utf8",
- flag: "wx",
- });
- } catch (error) {
- if (isFsErrorWithCode({ error, code: "EEXIST" })) {
- continue;
- }
- throw error;
- }
- }
-
- args.logger.debug(
- {
- templateName: args.templateName,
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- },
- "Overlaid bundled apps/status seed onto manager storage",
- );
-}
-
export async function seedManagerThreadStorage(
deps: LoggedWorkSessionDeps,
args: SeedManagerThreadStorageArgs,
@@ -342,15 +244,7 @@ export async function seedManagerThreadStorage(
templateDirPath,
threadId: args.threadId,
},
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
}
-
- await copyBuiltInTemplateFiles({
- files: BUILT_IN_DEFAULT_MANAGER_TEMPLATE_SET.files,
- logger: deps.logger,
- templateName,
- threadId: args.threadId,
- threadStoragePath: args.threadStoragePath,
- });
}
diff --git a/apps/server/src/services/threads/thread-commands.ts b/apps/server/src/services/threads/thread-commands.ts
index 9de7cd578..b0f0e355c 100644
--- a/apps/server/src/services/threads/thread-commands.ts
+++ b/apps/server/src/services/threads/thread-commands.ts
@@ -284,6 +284,7 @@ export async function buildThreadStartCommand(
options: toRuntimeExecutionOptions(args),
instructions: runtimeContext.instructions,
dynamicTools: runtimeContext.dynamicTools,
+ injectedSkillSources: runtimeContext.injectedSkillSources,
...(runtimeContext.disallowedTools?.length
? { disallowedTools: [...runtimeContext.disallowedTools] }
: {}),
@@ -312,6 +313,7 @@ function buildPreparedTurnSubmitCommandPayload(
providerThreadId: args.providerThreadId,
instructions: args.runtimeContext.instructions,
dynamicTools: args.runtimeContext.dynamicTools,
+ injectedSkillSources: args.runtimeContext.injectedSkillSources,
...(args.runtimeContext.disallowedTools?.length
? { disallowedTools: [...args.runtimeContext.disallowedTools] }
: {}),
diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts
index 3790c5e0e..3608dbcb9 100644
--- a/apps/server/src/services/threads/thread-lifecycle.ts
+++ b/apps/server/src/services/threads/thread-lifecycle.ts
@@ -957,6 +957,21 @@ async function advanceActiveThreadStartIfPresent(
return true;
}
+function hasQueuedActiveThreadStart(
+ deps: WorkSessionDeps,
+ args: QueueThreadStartCommandArgs,
+): boolean {
+ const operation = getThreadOperation(deps.db, {
+ threadId: args.thread.id,
+ kind: "start",
+ });
+ return (
+ operation !== null &&
+ isActiveLifecycleOperationState(operation.state) &&
+ hasQueuedThreadOperationCommand(deps, operation.commandId)
+ );
+}
+
/**
* Makes the provision-to-start durability boundary atomic: after a crash, the
* thread should have either an active provision op to retry or an active start
@@ -1032,7 +1047,7 @@ async function requestThreadStartOnce(
const baseCommand = await buildThreadStartCommand(deps, {
...args,
});
- if (await advanceActiveThreadStartIfPresent(deps, args)) {
+ if (hasQueuedActiveThreadStart(deps, args)) {
return;
}
diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts
index 36565fc62..7ef6480b7 100644
--- a/apps/server/src/services/threads/thread-runtime-config.ts
+++ b/apps/server/src/services/threads/thread-runtime-config.ts
@@ -12,6 +12,7 @@ import type {
WorkspaceProvisionType,
EnvironmentStatus,
} from "@bb/domain";
+import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract";
import { renderTemplate } from "@bb/templates";
import { ApiError } from "../../errors.js";
import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js";
@@ -21,6 +22,7 @@ import {
buildExistingThreadExecutionInput,
resolveExistingThreadExecutionPlan,
} from "./thread-execution-plan.js";
+import { resolveInjectedSkillSources } from "../skills/injected-skills.js";
export { getSupportedReasoningLevelsForProvider } from "./thread-reasoning-policy.js";
const STANDARD_AGENT_INSTRUCTIONS = renderTemplate(
@@ -89,6 +91,7 @@ export interface ResolvePermissionEscalationArgs {
export interface ResolvedThreadRuntimeCommandConfig {
dynamicTools: DynamicTool[];
disallowedTools?: readonly string[];
+ injectedSkillSources: HostDaemonInjectedSkillSource[];
instructionMode: InstructionMode;
instructions: string;
projectId: string;
@@ -152,10 +155,14 @@ export async function resolveThreadRuntimeCommandConfig(
const projectRootPath =
defaultSource?.type === "local_path" ? defaultSource.path : workspacePath;
const { workspaceProvisionType } = args.environment;
+ const injectedSkillSources = resolveInjectedSkillSources(deps.logger, {
+ dataDir: deps.config.dataDir,
+ });
if (args.thread.type !== "manager") {
return {
dynamicTools: [],
+ injectedSkillSources,
instructionMode: "append",
instructions: STANDARD_AGENT_INSTRUCTIONS,
projectId: args.thread.projectId,
@@ -175,6 +182,7 @@ export async function resolveThreadRuntimeCommandConfig(
return {
dynamicTools: MANAGER_DYNAMIC_TOOLS,
disallowedTools: MANAGER_DISALLOWED_TOOLS,
+ injectedSkillSources,
instructionMode: "replace",
instructions: renderTemplate("managerAgentInstructions", {
hostId: args.environment.hostId,
diff --git a/apps/server/src/services/threads/thread-storage.ts b/apps/server/src/services/threads/thread-storage.ts
index e90ef9293..5db7161c7 100644
--- a/apps/server/src/services/threads/thread-storage.ts
+++ b/apps/server/src/services/threads/thread-storage.ts
@@ -53,6 +53,7 @@ export async function requireThreadStorageContext(
threadStoragePath: resolveThreadStoragePathFromRoot({
threadStorageRootPath: resolveThreadStorageRootPath({
dataDir: session.dataDir,
+ env: {},
}),
threadId: args.threadId,
}),
diff --git a/apps/server/src/ws/daemon-protocol.ts b/apps/server/src/ws/daemon-protocol.ts
index d435074ed..facc709b7 100644
--- a/apps/server/src/ws/daemon-protocol.ts
+++ b/apps/server/src/ws/daemon-protocol.ts
@@ -10,6 +10,7 @@ import { runtimeErrorLogFields } from "../services/lib/error-log-fields.js";
import { requireAuthorizedActiveSession } from "../internal/session-state.js";
import { handleDaemonSocketClosed } from "../internal/session-owner-side-effects.js";
import { notifyDaemonEnvironmentChange } from "../internal/environment-changes.js";
+import { notifyGlobalAppsChangedIfListChanged } from "../routes/apps.js";
import { decodeSocketPayload } from "./decode-payload.js";
interface DaemonSocket {
@@ -99,7 +100,11 @@ export function onDaemonSocketMessage(
hostId: args.hostId,
sessionId: args.sessionId,
});
- heartbeatSession(deps.db, session.id, Date.now() + session.leaseTimeoutMs);
+ heartbeatSession(
+ deps.db,
+ session.id,
+ Math.max(Date.now() + session.leaseTimeoutMs, session.leaseExpiresAt + 1),
+ );
if (result.data.type === "environment-change") {
notifyDaemonEnvironmentChange(deps, {
hostId: args.hostId,
@@ -108,6 +113,17 @@ export function onDaemonSocketMessage(
});
return;
}
+ if (result.data.type === "application-storage-changed") {
+ void notifyGlobalAppsChangedIfListChanged(deps).catch((error) => {
+ deps.logger.warn(
+ {
+ ...runtimeErrorLogFields(deps.config, error),
+ },
+ "Failed to refresh global app list after daemon storage change",
+ );
+ });
+ return;
+ }
if (result.data.type === "host-rpc.response") {
const disposition = deps.hub.recordHostOnlineRpcResponse({
message: result.data,
diff --git a/apps/server/src/ws/hub.ts b/apps/server/src/ws/hub.ts
index 435b66282..4850281e5 100644
--- a/apps/server/src/ws/hub.ts
+++ b/apps/server/src/ws/hub.ts
@@ -505,9 +505,9 @@ export class NotificationHub implements DbNotifier {
}
}
- notifyThreadAppData(message: AppDataBroadcastMessage): void {
+ notifyAppData(message: AppDataBroadcastMessage): void {
this.notifyClientsByKey(
- subKey("thread", `${message.threadId}:app:${message.appId}:data`),
+ subKey("app", `${message.applicationId}:data`),
JSON.stringify(appDataBroadcastMessageSchema.parse(message)),
);
}
diff --git a/apps/server/test/app/hub.test.ts b/apps/server/test/app/hub.test.ts
index eb35ca35f..790151151 100644
--- a/apps/server/test/app/hub.test.ts
+++ b/apps/server/test/app/hub.test.ts
@@ -91,11 +91,10 @@ describe("NotificationHub", () => {
const hub = new NotificationHub();
const socket = createMockSocket();
- hub.subscribe(socket, "thread", "thread-1:app:status:data");
- hub.notifyThreadAppData({
+ hub.subscribe(socket, "app", "status:data");
+ hub.notifyAppData({
type: "app-data.changed",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -105,8 +104,7 @@ describe("NotificationHub", () => {
expect(socket.messages).toHaveLength(1);
expect(JSON.parse(socket.messages[0])).toEqual({
type: "app-data.changed",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -118,18 +116,16 @@ describe("NotificationHub", () => {
const hub = new NotificationHub();
const socket = createMockSocket();
- hub.subscribe(socket, "thread", "thread-1:app:status:data");
- hub.notifyThreadAppData({
+ hub.subscribe(socket, "app", "status:data");
+ hub.notifyAppData({
type: "app-data.resync",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "status",
});
expect(socket.messages).toHaveLength(1);
expect(JSON.parse(socket.messages[0])).toEqual({
type: "app-data.resync",
- threadId: "thread-1",
- appId: "status",
+ applicationId: "status",
});
});
diff --git a/apps/server/test/hosts/expired-commands.test.ts b/apps/server/test/hosts/expired-commands.test.ts
index 4f71b6043..179a27973 100644
--- a/apps/server/test/hosts/expired-commands.test.ts
+++ b/apps/server/test/hosts/expired-commands.test.ts
@@ -145,6 +145,7 @@ describe("expired commands", () => {
},
instructions: "instructions",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append" as const,
}),
},
@@ -409,6 +410,7 @@ describe("expired commands", () => {
},
instructions: "instructions",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
});
@@ -603,6 +605,7 @@ describe("expired commands", () => {
providerThreadId: "provider-expired-turn-submit",
instructions: "instructions",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
}),
diff --git a/apps/server/test/internal/internal-app-data-change.test.ts b/apps/server/test/internal/internal-app-data-change.test.ts
index 39b10a2f1..9608d5009 100644
--- a/apps/server/test/internal/internal-app-data-change.test.ts
+++ b/apps/server/test/internal/internal-app-data-change.test.ts
@@ -1,38 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { internalAuthHeaders } from "../helpers/commands.js";
import { readJson } from "../helpers/json.js";
-import {
- seedEnvironment,
- seedHostSession,
- seedProjectWithSource,
- seedThread,
-} from "../helpers/seed.js";
+import { seedHostSession } from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
describe("internal app-data change route", () => {
- it("broadcasts daemon-reported app data changes for session-owned threads", async () => {
+ it("broadcasts daemon-reported app data changes", async () => {
await withTestHarness(async (harness) => {
- const { host, session } = seedHostSession(harness.deps, {
+ const { session } = seedHostSession(harness.deps, {
id: "host-app-data-change",
});
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: host.id,
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: host.id,
- projectId: project.id,
- path: "/tmp/app-data-change",
- status: "ready",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- const notifyThreadAppDataSpy = vi.spyOn(
- harness.hub,
- "notifyThreadAppData",
- );
+ const notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-change",
@@ -41,8 +19,7 @@ describe("internal app-data change route", () => {
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: session.id,
- threadId: thread.id,
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -52,10 +29,9 @@ describe("internal app-data change route", () => {
);
expect(response.status).toBe(200);
- expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ expect(notifyAppDataSpy).toHaveBeenCalledWith({
type: "app-data.changed",
- threadId: thread.id,
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -64,32 +40,9 @@ describe("internal app-data change route", () => {
});
});
- it("rejects daemon-reported app data changes for threads owned by another host", async () => {
+ it("rejects daemon-reported app data changes for unknown sessions", async () => {
await withTestHarness(async (harness) => {
- const hostA = seedHostSession(harness.deps, {
- id: "host-app-data-change-a",
- });
- const hostB = seedHostSession(harness.deps, {
- id: "host-app-data-change-b",
- });
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: hostB.host.id,
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: hostB.host.id,
- projectId: project.id,
- path: "/tmp/app-data-change-other",
- status: "ready",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- const notifyThreadAppDataSpy = vi.spyOn(
- harness.hub,
- "notifyThreadAppData",
- );
+ const notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-change",
@@ -97,9 +50,8 @@ describe("internal app-data change route", () => {
method: "POST",
headers: internalAuthHeaders(harness),
body: JSON.stringify({
- sessionId: hostA.session.id,
- threadId: thread.id,
- appId: "status",
+ sessionId: "missing-session",
+ applicationId: "status",
path: "state.json",
value: null,
deleted: true,
@@ -108,37 +60,20 @@ describe("internal app-data change route", () => {
},
);
- expect(response.status).toBe(403);
+ expect(response.status).toBe(401);
await expect(readJson(response)).resolves.toMatchObject({
- code: "invalid_request",
+ code: "inactive_session",
});
- expect(notifyThreadAppDataSpy).not.toHaveBeenCalled();
+ expect(notifyAppDataSpy).not.toHaveBeenCalled();
});
});
it("broadcasts daemon-requested app data resync hints", async () => {
await withTestHarness(async (harness) => {
- const { host, session } = seedHostSession(harness.deps, {
+ const { session } = seedHostSession(harness.deps, {
id: "host-app-data-resync",
});
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: host.id,
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: host.id,
- projectId: project.id,
- path: "/tmp/app-data-resync",
- status: "ready",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- const notifyThreadAppDataSpy = vi.spyOn(
- harness.hub,
- "notifyThreadAppData",
- );
+ const notifyAppDataSpy = vi.spyOn(harness.hub, "notifyAppData");
const response = await harness.app.request(
"/internal/session/app-data-resync",
@@ -147,17 +82,15 @@ describe("internal app-data change route", () => {
headers: internalAuthHeaders(harness),
body: JSON.stringify({
sessionId: session.id,
- threadId: thread.id,
- appId: "status",
+ applicationId: "status",
}),
},
);
expect(response.status).toBe(200);
- expect(notifyThreadAppDataSpy).toHaveBeenCalledWith({
+ expect(notifyAppDataSpy).toHaveBeenCalledWith({
type: "app-data.resync",
- threadId: thread.id,
- appId: "status",
+ applicationId: "status",
});
});
});
diff --git a/apps/server/test/internal/internal-command-result-idempotency.test.ts b/apps/server/test/internal/internal-command-result-idempotency.test.ts
index 0a7090bd1..ee2b934dd 100644
--- a/apps/server/test/internal/internal-command-result-idempotency.test.ts
+++ b/apps/server/test/internal/internal-command-result-idempotency.test.ts
@@ -334,6 +334,7 @@ describe("internal command result idempotency", () => {
},
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
});
diff --git a/apps/server/test/internal/internal-command-result-thread-failure.test.ts b/apps/server/test/internal/internal-command-result-thread-failure.test.ts
index d460ac203..b03bc0970 100644
--- a/apps/server/test/internal/internal-command-result-thread-failure.test.ts
+++ b/apps/server/test/internal/internal-command-result-thread-failure.test.ts
@@ -192,6 +192,7 @@ async function queueManagedTurnSubmitScenario(
providerThreadId: args.providerThreadId,
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: args.turnId },
@@ -256,6 +257,7 @@ describe("thread command failure side effects", () => {
},
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
}),
});
@@ -340,6 +342,7 @@ describe("thread command failure side effects", () => {
},
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
}),
});
@@ -457,6 +460,7 @@ describe("thread command failure side effects", () => {
},
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
}),
});
@@ -539,6 +543,7 @@ describe("thread command failure side effects", () => {
providerThreadId: "provider-thread-1",
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: "turn-active" },
@@ -642,6 +647,7 @@ describe("thread command failure side effects", () => {
providerThreadId: "provider-thread-stop-race",
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: "turn-stop-race" },
@@ -1021,6 +1027,7 @@ describe("thread command failure side effects", () => {
providerThreadId: "provider-thread-1",
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1096,6 +1103,7 @@ describe("thread command failure side effects", () => {
},
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
}),
});
diff --git a/apps/server/test/internal/internal-environment-change.test.ts b/apps/server/test/internal/internal-environment-change.test.ts
index 86dab9f8c..e7e8e4c0e 100644
--- a/apps/server/test/internal/internal-environment-change.test.ts
+++ b/apps/server/test/internal/internal-environment-change.test.ts
@@ -1,4 +1,12 @@
+import { mkdir, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import {
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveApplicationPublicPath,
+} from "@bb/config/app-storage-paths";
import { getEnvironment } from "@bb/db";
+import type { AppManifest } from "@bb/server-contract";
import { describe, expect, it, vi } from "vitest";
import { onDaemonSocketMessage } from "../../src/ws/daemon-protocol.js";
import {
@@ -20,6 +28,38 @@ function createTestDaemonSocket(): TestDaemonSocket {
};
}
+async function waitFor(
+ predicate: () => boolean,
+ timeoutMs = 1_000,
+): Promise {
+ const startedAt = Date.now();
+ while (!predicate()) {
+ if (Date.now() - startedAt > timeoutMs) {
+ throw new Error("Timed out waiting for condition");
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
+
+async function writeApplication(
+ dataDir: string,
+ manifest: AppManifest,
+): Promise {
+ await mkdir(resolveApplicationPublicPath(dataDir, manifest.id), {
+ recursive: true,
+ });
+ await writeFile(
+ resolveApplicationManifestPath(dataDir, manifest.id),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ "utf8",
+ );
+ await writeFile(
+ path.join(resolveApplicationPublicPath(dataDir, manifest.id), "index.html"),
+ "External App ",
+ "utf8",
+ );
+}
+
describe("internal environment change websocket hints", () => {
it("does not resolve host RPC waiters from a different daemon session", async () => {
await withTestHarness(async (harness) => {
@@ -220,4 +260,79 @@ describe("internal environment change websocket hints", () => {
expect(notifyEnvironmentSpy).not.toHaveBeenCalled();
});
});
+
+ it("notifies app list clients when a daemon watcher reports an externally added app", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps, {
+ id: "host-app-added",
+ });
+ const notifySystemSpy = vi.spyOn(harness.hub, "notifySystem");
+ const socket = createTestDaemonSocket();
+
+ const initialListResponse = await harness.app.request("/api/v1/apps");
+ expect(initialListResponse.status).toBe(200);
+ await writeApplication(harness.config.dataDir, {
+ manifestVersion: 1,
+ id: "external-added",
+ name: "External Added",
+ entry: "index.html",
+ capabilities: [],
+ });
+
+ onDaemonSocketMessage(harness.deps, {
+ hostId: host.id,
+ sessionId: session.id,
+ socket,
+ raw: JSON.stringify({
+ type: "application-storage-changed",
+ }),
+ });
+
+ await waitFor(() =>
+ notifySystemSpy.mock.calls.some(([changes]) =>
+ changes.includes("apps-changed"),
+ ),
+ );
+ expect(socket.close).not.toHaveBeenCalled();
+ });
+ });
+
+ it("notifies app list clients when a daemon watcher reports an externally removed app", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps, {
+ id: "host-app-removed",
+ });
+ await writeApplication(harness.config.dataDir, {
+ manifestVersion: 1,
+ id: "external-removed",
+ name: "External Removed",
+ entry: "index.html",
+ capabilities: [],
+ });
+ const initialListResponse = await harness.app.request("/api/v1/apps");
+ expect(initialListResponse.status).toBe(200);
+ const notifySystemSpy = vi.spyOn(harness.hub, "notifySystem");
+ const socket = createTestDaemonSocket();
+
+ await rm(
+ resolveApplicationPath(harness.config.dataDir, "external-removed"),
+ { recursive: true, force: true },
+ );
+ onDaemonSocketMessage(harness.deps, {
+ hostId: host.id,
+ sessionId: session.id,
+ socket,
+ raw: JSON.stringify({
+ type: "application-storage-changed",
+ }),
+ });
+
+ await waitFor(() =>
+ notifySystemSpy.mock.calls.some(([changes]) =>
+ changes.includes("apps-changed"),
+ ),
+ );
+ expect(socket.close).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/apps/server/test/internal/internal-event-side-effects.test.ts b/apps/server/test/internal/internal-event-side-effects.test.ts
index f9525a000..677081dad 100644
--- a/apps/server/test/internal/internal-event-side-effects.test.ts
+++ b/apps/server/test/internal/internal-event-side-effects.test.ts
@@ -1196,6 +1196,7 @@ describe("internal event side effects", () => {
providerThreadId: "provider-child-command-event-dedupe",
instructions: "You are a helpful assistant.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "steer", expectedTurnId: turnId },
diff --git a/apps/server/test/public/public-apps.test.ts b/apps/server/test/public/public-apps.test.ts
new file mode 100644
index 000000000..43b6137c1
--- /dev/null
+++ b/apps/server/test/public/public-apps.test.ts
@@ -0,0 +1,579 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationManifestPath,
+ resolveApplicationPath,
+ resolveApplicationPublicPath,
+ resolveAppsRootPath,
+} from "@bb/config/app-storage-paths";
+import {
+ appDetailSchema,
+ appSummarySchema,
+ type AppManifest,
+} from "@bb/server-contract";
+import { describe, expect, it, vi } from "vitest";
+import { readJson } from "../helpers/json.js";
+import {
+ seedEnvironment,
+ seedHostSession,
+ seedProjectWithSource,
+ seedThread,
+ seedThreadRuntimeState,
+} from "../helpers/seed.js";
+import { withTestHarness } from "../helpers/test-app.js";
+
+const VALID_APP_ID = "status";
+const VALID_MANIFEST: AppManifest = {
+ manifestVersion: 1,
+ id: VALID_APP_ID,
+ name: "Valid App",
+ icon: "ListTodo",
+ entry: "index.html",
+ capabilities: ["data", "message"],
+};
+
+async function writeApplication(
+ dataDir: string,
+ manifest: AppManifest,
+): Promise {
+ await mkdir(resolveApplicationPublicPath(dataDir, manifest.id), {
+ recursive: true,
+ });
+ await mkdir(resolveApplicationDataPath(dataDir, manifest.id), {
+ recursive: true,
+ });
+ await writeFile(
+ resolveApplicationManifestPath(dataDir, manifest.id),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ "utf8",
+ );
+ await writeFile(
+ path.join(resolveApplicationPublicPath(dataDir, manifest.id), "index.html"),
+ "Valid App ",
+ "utf8",
+ );
+}
+
+async function writeRawApplicationManifest(
+ dataDir: string,
+ applicationId: string,
+ manifestContent: string,
+): Promise {
+ await mkdir(resolveApplicationPublicPath(dataDir, applicationId), {
+ recursive: true,
+ });
+ await writeFile(
+ resolveApplicationManifestPath(dataDir, applicationId),
+ manifestContent,
+ "utf8",
+ );
+ await writeFile(
+ path.join(
+ resolveApplicationPublicPath(dataDir, applicationId),
+ "index.html",
+ ),
+ "Test App ",
+ "utf8",
+ );
+}
+
+async function writeApplicationPublicFile(
+ dataDir: string,
+ applicationId: string,
+ relativePath: string,
+ content: string,
+): Promise {
+ const filePath = path.join(
+ resolveApplicationPublicPath(dataDir, applicationId),
+ relativePath,
+ );
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, content, "utf8");
+}
+
+function appRequestPath(resolvedUrl: URL): string {
+ return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
+}
+
+describe("public global app routes", () => {
+ it("lists and gets valid global apps by application id", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+ await mkdir(
+ resolveApplicationPath(harness.config.dataDir, "invalid-app"),
+ { recursive: true },
+ );
+ await writeFile(
+ resolveApplicationManifestPath(harness.config.dataDir, "invalid-app"),
+ JSON.stringify({
+ ...VALID_MANIFEST,
+ id: "other",
+ name: "Invalid",
+ }),
+ "utf8",
+ );
+
+ const listResponse = await harness.app.request("/api/v1/apps");
+ expect(listResponse.status).toBe(200);
+ const apps = appSummarySchema.array().parse(await readJson(listResponse));
+ expect(apps.map((app) => app.applicationId)).toEqual([VALID_APP_ID]);
+
+ const getResponse = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}`,
+ );
+ expect(getResponse.status).toBe(200);
+ const detail = appDetailSchema.parse(await readJson(getResponse));
+ expect(detail).toMatchObject({
+ applicationId: VALID_APP_ID,
+ name: "Valid App",
+ appRootPath: resolveApplicationPath(
+ harness.config.dataDir,
+ VALID_APP_ID,
+ ),
+ appDataPath: resolveApplicationDataPath(
+ harness.config.dataDir,
+ VALID_APP_ID,
+ ),
+ });
+ });
+ });
+
+ it("uses manifest name for display with a slug fallback", async () => {
+ await withTestHarness(async (harness) => {
+ await writeRawApplicationManifest(
+ harness.config.dataDir,
+ "named-app",
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "named-app",
+ name: "Named App",
+ entry: "index.html",
+ }),
+ );
+ await writeRawApplicationManifest(
+ harness.config.dataDir,
+ "missing-name",
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "missing-name",
+ entry: "index.html",
+ }),
+ );
+ await writeRawApplicationManifest(
+ harness.config.dataDir,
+ "empty-name",
+ JSON.stringify({
+ manifestVersion: 1,
+ id: "empty-name",
+ name: "",
+ entry: "index.html",
+ }),
+ );
+
+ const listResponse = await harness.app.request("/api/v1/apps");
+ expect(listResponse.status).toBe(200);
+ const apps = appSummarySchema.array().parse(await readJson(listResponse));
+ expect(
+ apps.map((app) => ({
+ applicationId: app.applicationId,
+ name: app.name,
+ })),
+ ).toEqual([
+ { applicationId: "empty-name", name: "empty-name" },
+ { applicationId: "missing-name", name: "missing-name" },
+ { applicationId: "named-app", name: "Named App" },
+ ]);
+ });
+ });
+
+ it("returns app_missing and invalid_manifest distinctly", async () => {
+ await withTestHarness(async (harness) => {
+ const missingResponse = await harness.app.request("/api/v1/apps/missing");
+ expect(missingResponse.status).toBe(404);
+ await expect(readJson(missingResponse)).resolves.toMatchObject({
+ code: "app_missing",
+ });
+
+ await mkdir(
+ resolveApplicationPath(harness.config.dataDir, "invalid-app"),
+ { recursive: true },
+ );
+ await writeFile(
+ resolveApplicationManifestPath(harness.config.dataDir, "invalid-app"),
+ JSON.stringify({ ...VALID_MANIFEST, id: "other" }),
+ "utf8",
+ );
+
+ const invalidResponse = await harness.app.request(
+ "/api/v1/apps/invalid-app",
+ );
+ expect(invalidResponse.status).toBe(422);
+ await expect(readJson(invalidResponse)).resolves.toMatchObject({
+ code: "invalid_manifest",
+ });
+ });
+ });
+
+ it("serves public web-root files from the app route", async () => {
+ await withTestHarness(async (harness) => {
+ const applicationId = "vite-spa";
+ await writeApplication(harness.config.dataDir, {
+ manifestVersion: 1,
+ id: applicationId,
+ name: "Vite SPA",
+ entry: "index.html",
+ capabilities: ["data"],
+ });
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "index.html",
+ [
+ "",
+ "",
+ '',
+ '',
+ ' ',
+ "",
+ ].join(""),
+ );
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "index-flat.js",
+ 'import("./chunk.js"); window.flatAsset = true;',
+ );
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "chunk.js",
+ "window.dynamicChunk = true;",
+ );
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "assets/index-relative.js",
+ "window.relativeAsset = true;",
+ );
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "assets/index.css",
+ 'body { background-image: url("./logo.svg"); }',
+ );
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "assets/logo.svg",
+ " ",
+ );
+
+ const entryResponse = await harness.app.request(
+ `/api/v1/apps/${applicationId}/`,
+ );
+ expect(entryResponse.status).toBe(200);
+ const html = await entryResponse.text();
+ expect(html).not.toContain(" ");
+ });
+ });
+
+ it("serves markdown entries from the public web root", async () => {
+ await withTestHarness(async (harness) => {
+ const applicationId = "readme";
+ await writeApplication(harness.config.dataDir, {
+ manifestVersion: 1,
+ id: applicationId,
+ name: "Readme",
+ entry: "docs/index.md",
+ capabilities: [],
+ });
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "docs/index.md",
+ "# App Notes\n",
+ );
+
+ const detailResponse = await harness.app.request(
+ `/api/v1/apps/${applicationId}`,
+ );
+ expect(detailResponse.status).toBe(200);
+ const detail = appDetailSchema.parse(await readJson(detailResponse));
+ expect(detail.entry).toEqual({
+ kind: "md",
+ path: "docs/index.md",
+ });
+
+ const markdownResponse = await harness.app.request(
+ `/api/v1/apps/${applicationId}/docs/index.md`,
+ );
+ expect(markdownResponse.status).toBe(200);
+ await expect(markdownResponse.text()).resolves.toBe("# App Notes\n");
+ });
+ });
+
+ it("rejects traversal attempts for public static files", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+
+ const response = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}/..%2Fmanifest.json`,
+ );
+
+ expect(response.status).toBe(400);
+ await expect(readJson(response)).resolves.toMatchObject({
+ code: "invalid_path",
+ });
+ });
+ });
+
+ it("does not expose manifest or data files through the public static path", async () => {
+ await withTestHarness(async (harness) => {
+ const applicationId = "private-files";
+ await writeApplication(harness.config.dataDir, {
+ manifestVersion: 1,
+ id: applicationId,
+ name: "Private Files",
+ entry: "index.html",
+ capabilities: [],
+ });
+ await writeApplicationPublicFile(
+ harness.config.dataDir,
+ applicationId,
+ "manifest.json",
+ '{"leak":true}\n',
+ );
+ await writeFile(
+ path.join(
+ resolveApplicationDataPath(harness.config.dataDir, applicationId),
+ "state.json",
+ ),
+ '{"secret":true}\n',
+ "utf8",
+ );
+
+ const manifestResponse = await harness.app.request(
+ `/api/v1/apps/${applicationId}/manifest.json`,
+ );
+ expect(manifestResponse.status).toBe(404);
+
+ const dataResponse = await harness.app.request(
+ `/api/v1/apps/${applicationId}/data/state.json`,
+ );
+ expect(dataResponse.status).toBe(403);
+ await expect(readJson(dataResponse)).resolves.toMatchObject({
+ code: "invalid_request",
+ });
+ });
+ });
+
+ it("creates and deletes apps atomically on the filesystem", async () => {
+ await withTestHarness(async (harness) => {
+ const notifySystemSpy = vi.spyOn(harness.hub, "notifySystem");
+ const createResponse = await harness.app.request("/api/v1/apps", {
+ method: "POST",
+ body: JSON.stringify({ name: "Created App" }),
+ });
+ expect(createResponse.status).toBe(201);
+ expect(notifySystemSpy).toHaveBeenCalledWith(["apps-changed"]);
+ const created = appDetailSchema.parse(await readJson(createResponse));
+ expect(created.applicationId).toBe("created-app");
+ expect(created.name).toBe("Created App");
+
+ const manifestText = await readFile(
+ resolveApplicationManifestPath(
+ harness.config.dataDir,
+ created.applicationId,
+ ),
+ "utf8",
+ );
+ expect(JSON.parse(manifestText)).toMatchObject({
+ id: "created-app",
+ name: "Created App",
+ });
+ await expect(
+ readFile(
+ path.join(
+ resolveApplicationPublicPath(
+ harness.config.dataDir,
+ created.applicationId,
+ ),
+ "index.html",
+ ),
+ "utf8",
+ ),
+ ).resolves.toContain("Created App");
+ const appRootEntries = await readdir(
+ resolveAppsRootPath(harness.config.dataDir),
+ );
+ expect(appRootEntries.some((entry) => entry.startsWith(".tmp-"))).toBe(
+ false,
+ );
+
+ notifySystemSpy.mockClear();
+ const deleteResponse = await harness.app.request(
+ `/api/v1/apps/${created.applicationId}`,
+ { method: "DELETE" },
+ );
+ expect(deleteResponse.status).toBe(200);
+ expect(notifySystemSpy).toHaveBeenCalledWith(["apps-changed"]);
+ const deletedGetResponse = await harness.app.request(
+ `/api/v1/apps/${created.applicationId}`,
+ );
+ expect(deletedGetResponse.status).toBe(404);
+ });
+ });
+
+ it("creates explicit slug apps and defaults manifest name to the slug", async () => {
+ await withTestHarness(async (harness) => {
+ const createResponse = await harness.app.request("/api/v1/apps", {
+ method: "POST",
+ body: JSON.stringify({ applicationId: "review-board" }),
+ });
+ expect(createResponse.status).toBe(201);
+ const created = appDetailSchema.parse(await readJson(createResponse));
+ expect(created).toMatchObject({
+ applicationId: "review-board",
+ name: "review-board",
+ appRootPath: resolveApplicationPath(
+ harness.config.dataDir,
+ "review-board",
+ ),
+ appDataPath: resolveApplicationDataPath(
+ harness.config.dataDir,
+ "review-board",
+ ),
+ });
+ const manifestText = await readFile(
+ resolveApplicationManifestPath(harness.config.dataDir, "review-board"),
+ "utf8",
+ );
+ expect(JSON.parse(manifestText)).toMatchObject({
+ id: "review-board",
+ name: "review-board",
+ });
+ });
+ });
+
+ it("rejects duplicate app slugs", async () => {
+ await withTestHarness(async (harness) => {
+ const firstResponse = await harness.app.request("/api/v1/apps", {
+ method: "POST",
+ body: JSON.stringify({ applicationId: "status", name: "Status" }),
+ });
+ expect(firstResponse.status).toBe(201);
+
+ const duplicateResponse = await harness.app.request("/api/v1/apps", {
+ method: "POST",
+ body: JSON.stringify({ applicationId: "status", name: "Status" }),
+ });
+ expect(duplicateResponse.status).toBe(409);
+ await expect(readJson(duplicateResponse)).resolves.toMatchObject({
+ code: "app_exists",
+ message: 'an app with id "status" already exists',
+ });
+ });
+ });
+
+ it("requires an explicit message target outside an iframe session", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+
+ const response = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}/message`,
+ {
+ method: "POST",
+ body: JSON.stringify({ payload: "hello" }),
+ },
+ );
+
+ expect(response.status).toBe(400);
+ await expect(readJson(response)).resolves.toMatchObject({
+ code: "message_target_required",
+ });
+ });
+ });
+
+ it("accepts explicit targetThreadId for non-iframe app messages", async () => {
+ await withTestHarness(async (harness) => {
+ await writeApplication(harness.config.dataDir, VALID_MANIFEST);
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-app-message",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ path: "/tmp/app-message",
+ status: "ready",
+ });
+ const thread = seedThread(harness.deps, {
+ projectId: project.id,
+ environmentId: environment.id,
+ });
+ seedThreadRuntimeState(harness.deps, {
+ environmentId: environment.id,
+ providerThreadId: "provider-app-message",
+ threadId: thread.id,
+ });
+
+ const response = await harness.app.request(
+ `/api/v1/apps/${VALID_APP_ID}/message`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ payload: "hello",
+ targetThreadId: thread.id,
+ }),
+ },
+ );
+
+ expect(response.status).toBe(202);
+ });
+ });
+});
diff --git a/apps/server/test/public/public-thread-apps.test.ts b/apps/server/test/public/public-thread-apps.test.ts
deleted file mode 100644
index e47e37014..000000000
--- a/apps/server/test/public/public-thread-apps.test.ts
+++ /dev/null
@@ -1,1375 +0,0 @@
-import { createHash } from "node:crypto";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import path from "node:path";
-import type { JsonValue } from "@bb/domain";
-import type { HostDaemonCommand } from "@bb/host-daemon-contract";
-import {
- appDataListResponseSchema,
- appDataReadResponseSchema,
- appDetailSchema,
- appSummarySchema,
- type AppManifest,
-} from "@bb/server-contract";
-import { describe, expect, it, vi } from "vitest";
-import {
- reportQueuedCommandError,
- reportQueuedCommandSuccess,
- waitForQueuedCommand,
- waitForQueuedCommandAfter,
- type QueuedCommand,
-} from "../helpers/commands.js";
-import { readJson } from "../helpers/json.js";
-import { createApp } from "../../src/server.js";
-import type { TestAppHarness } from "../helpers/test-app.js";
-import {
- seedEnvironment,
- seedHostSession,
- seedProjectWithSource,
- seedThread,
-} from "../helpers/seed.js";
-import { createTestAppHarness, withTestHarness } from "../helpers/test-app.js";
-
-interface ManagerThreadStorageFixture {
- hostId: string;
- threadId: string;
- storageRootPath: string;
-}
-
-interface ReadFileResultArgs {
- content: string;
- mimeType?: string;
- path: string;
-}
-
-interface PathEntryArgs {
- kind: "directory" | "file";
- path: string;
-}
-
-interface ManifestReadArgs {
- appId: string;
- afterCursor?: number;
- fixture: ManagerThreadStorageFixture;
- harness: TestAppHarness;
- manifest: JsonValue;
-}
-
-type WriteFileRelativeQueuedCommand = QueuedCommand<
- Extract
->;
-
-const STATUS_MANIFEST: AppManifest = {
- manifestVersion: 1,
- id: "status",
- name: "Status",
- icon: "ListTodo",
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
-};
-
-function seedManagerThreadStorage(
- harness: TestAppHarness,
-): ManagerThreadStorageFixture {
- const { host } = seedHostSession(harness.deps, {
- id: "host-thread-apps",
- });
- const { project } = seedProjectWithSource(harness.deps, {
- hostId: host.id,
- path: "/tmp/project-source",
- });
- const environment = seedEnvironment(harness.deps, {
- hostId: host.id,
- projectId: project.id,
- path: "/tmp/project-source",
- });
- const thread = seedThread(harness.deps, {
- projectId: project.id,
- environmentId: environment.id,
- type: "manager",
- });
- return {
- hostId: host.id,
- threadId: thread.id,
- storageRootPath: `/tmp/bb-host-data/${host.id}/thread-storage/${thread.id}`,
- };
-}
-
-function appRoot(fixture: ManagerThreadStorageFixture, appId: string): string {
- return `${fixture.storageRootPath}/apps/${appId}`;
-}
-
-function appAssetsRoot(
- fixture: ManagerThreadStorageFixture,
- appId: string,
-): string {
- return `${appRoot(fixture, appId)}/assets`;
-}
-
-function appDataRoot(
- fixture: ManagerThreadStorageFixture,
- appId: string,
-): string {
- return `${appRoot(fixture, appId)}/data`;
-}
-
-function sha256Text(content: string): string {
- return createHash("sha256").update(content).digest("hex");
-}
-
-function readFileResult(args: ReadFileResultArgs) {
- return {
- path: args.path,
- content: args.content,
- contentEncoding: "utf8" as const,
- ...(args.mimeType ? { mimeType: args.mimeType } : {}),
- sizeBytes: Buffer.byteLength(args.content),
- };
-}
-
-function pathEntry(args: PathEntryArgs) {
- return {
- kind: args.kind,
- path: args.path,
- name: args.path.split("/").at(-1) ?? args.path,
- score: 1,
- positions: [],
- };
-}
-
-function requireWriteFileRelativeCommand(
- queued: QueuedCommand,
-): WriteFileRelativeQueuedCommand {
- if (queued.command.type === "host.write_file_relative") {
- return {
- command: queued.command,
- row: queued.row,
- };
- }
- throw new Error("Expected host.write_file_relative command");
-}
-
-async function reportManifestRead(
- args: ManifestReadArgs,
-): Promise {
- const queued = args.afterCursor
- ? await waitForQueuedCommandAfter(
- args.harness,
- args.afterCursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(args.fixture, args.appId) &&
- command.path === "manifest.json",
- )
- : await waitForQueuedCommand(
- args.harness,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(args.fixture, args.appId) &&
- command.path === "manifest.json",
- );
- const content = `${JSON.stringify(args.manifest, null, 2)}\n`;
- await reportQueuedCommandSuccess(
- args.harness,
- queued,
- readFileResult({
- path: "manifest.json",
- content,
- mimeType: "application/json",
- }),
- );
- return queued;
-}
-
-describe("public thread app routes", () => {
- it("lists app summaries from daemon-owned manifests", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps`,
- );
- const listCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === `${fixture.storageRootPath}/apps`,
- );
- await reportQueuedCommandSuccess(harness, listCommand, {
- paths: [
- pathEntry({ kind: "directory", path: "demo" }),
- pathEntry({ kind: "directory", path: "status" }),
- ],
- truncated: false,
- });
- await reportManifestRead({
- harness,
- fixture,
- appId: "demo",
- afterCursor: listCommand.row.cursor,
- manifest: {
- ...STATUS_MANIFEST,
- id: "demo",
- name: "Demo",
- icon: "GridView",
- },
- });
- await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: listCommand.row.cursor,
- manifest: STATUS_MANIFEST,
- });
-
- const response = await request;
- expect(response.status).toBe(200);
- const apps = appSummarySchema.array().parse(await readJson(response));
- expect(apps.map((app) => app.id)).toEqual(["demo", "status"]);
- expect(apps[0]?.icon).toEqual({ kind: "builtin", name: "GridView" });
- expect(apps[1]?.icon).toEqual({ kind: "builtin", name: "ListTodo" });
- });
- });
-
- it("skips invalid app manifests when listing app summaries", async () => {
- const harness = await createTestAppHarness();
- const warn = vi.spyOn(harness.deps.logger, "warn");
- try {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps`,
- );
- const listCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === `${fixture.storageRootPath}/apps`,
- );
- await reportQueuedCommandSuccess(harness, listCommand, {
- paths: [
- pathEntry({ kind: "directory", path: "broken" }),
- pathEntry({ kind: "directory", path: "status" }),
- ],
- truncated: false,
- });
- await reportManifestRead({
- harness,
- fixture,
- appId: "broken",
- afterCursor: listCommand.row.cursor,
- manifest: {
- ...STATUS_MANIFEST,
- id: "broken",
- name: "Broken",
- icon: "NotAnIcon",
- },
- });
- await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: listCommand.row.cursor,
- manifest: STATUS_MANIFEST,
- });
-
- const response = await request;
- expect(response.status).toBe(200);
- const apps = appSummarySchema.array().parse(await readJson(response));
- expect(apps.map((app) => app.id)).toEqual(["status"]);
- expect(warn).toHaveBeenCalledWith(
- expect.objectContaining({
- appId: "broken",
- manifestPath: `${appRoot(fixture, "broken")}/manifest.json`,
- issueSummary: expect.stringContaining("icon"),
- issues: expect.any(Array),
- }),
- "Skipping invalid thread app manifest",
- );
- } finally {
- warn.mockRestore();
- await harness.cleanup();
- }
- });
-
- it("returns a provisioned-app error when app detail is missing manifest.json", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status`,
- );
- const manifestCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(fixture, "status") &&
- command.path === "manifest.json",
- );
- await reportQueuedCommandError(harness, manifestCommand, {
- errorCode: "ENOENT",
- errorMessage: "Path does not exist: manifest.json",
- });
-
- const response = await request;
- expect(response.status).toBe(404);
- await expect(readJson(response)).resolves.toMatchObject({
- code: "app_not_provisioned",
- message: expect.stringContaining("missing manifest.json"),
- });
- });
- });
-
- it("returns an invalid-manifest error when app detail manifest validation fails", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/broken`,
- );
- await reportManifestRead({
- harness,
- fixture,
- appId: "broken",
- manifest: {
- ...STATUS_MANIFEST,
- id: "broken",
- name: "Broken",
- icon: "NotAnIcon",
- },
- });
-
- const response = await request;
- expect(response.status).toBe(422);
- const body = await readJson(response);
- expect(body).toMatchObject({
- code: "invalid_manifest",
- message: expect.stringContaining("failed validation"),
- });
- expect(JSON.stringify(body)).not.toContain("NotAnIcon");
- });
- });
-
- it("returns an invalid-manifest error before serving app assets", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/broken/index.html`,
- );
- await reportManifestRead({
- harness,
- fixture,
- appId: "broken",
- manifest: {
- ...STATUS_MANIFEST,
- id: "broken",
- name: "Broken",
- icon: "NotAnIcon",
- },
- });
-
- const response = await request;
- expect(response.status).toBe(422);
- await expect(readJson(response)).resolves.toMatchObject({
- code: "invalid_manifest",
- message: expect.stringContaining("failed validation"),
- });
- });
- });
-
- it("serves HTML app entries with capability-scoped window.bb injection", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const html =
- "Status";
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const metadataCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path === `${appAssetsRoot(fixture, "status")}/index.html`,
- );
- await reportQueuedCommandSuccess(harness, metadataCommand, {
- path: `${appAssetsRoot(fixture, "status")}/index.html`,
- modifiedAtMs: 1234,
- sizeBytes: Buffer.byteLength(html),
- });
- const entryCommand = await waitForQueuedCommandAfter(
- harness,
- metadataCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appAssetsRoot(fixture, "status") &&
- command.path === "index.html",
- );
- await reportQueuedCommandSuccess(
- harness,
- entryCommand,
- readFileResult({
- path: "index.html",
- content: html,
- mimeType: "text/html",
- }),
- );
-
- const response = await request;
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toBe(
- "text/html; charset=utf-8",
- );
- const body = await response.text();
- expect(body).toContain("data-bb-app-client");
- expect(body).toContain("window.bb");
- expect(body).toContain('"capabilities":["data","message"]');
- expect(body).toContain("Status");
- });
- });
-
- it("serves flat app asset URLs from the internal assets directory", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/index-Cd7sCqsN.js`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const assetCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appAssetsRoot(fixture, "status") &&
- command.path === "index-Cd7sCqsN.js",
- );
- expect(assetCommand.command).toMatchObject({ dotfiles: "deny" });
- await reportQueuedCommandSuccess(
- harness,
- assetCommand,
- readFileResult({
- path: "index-Cd7sCqsN.js",
- content: "console.log('status');",
- mimeType: "application/javascript",
- }),
- );
-
- const response = await request;
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toBe(
- "application/javascript",
- );
- expect(response.headers.get("x-content-type-options")).toBe("nosniff");
- await expect(response.text()).resolves.toBe("console.log('status');");
- });
- });
-
- it("serves nested flat app asset URLs without collapsing path segments", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/chunks/index-Cd7sCqsN.js`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const assetCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appAssetsRoot(fixture, "status") &&
- command.path === "chunks/index-Cd7sCqsN.js",
- );
- await reportQueuedCommandSuccess(
- harness,
- assetCommand,
- readFileResult({
- path: "chunks/index-Cd7sCqsN.js",
- content: "export const status = true;",
- mimeType: "application/javascript",
- }),
- );
-
- const response = await request;
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toBe(
- "application/javascript",
- );
- await expect(response.text()).resolves.toBe(
- "export const status = true;",
- );
- });
- });
-
- it("returns JSON 404 for missing flat app assets instead of the outer SPA shell", async () => {
- const staticDir = await mkdtemp(path.join(tmpdir(), "bb-apps-static-"));
- await writeFile(
- path.join(staticDir, "index.html"),
- 'bb shell',
- "utf8",
- );
- const harness = await createTestAppHarness();
- const serverApp = createApp(harness.deps, { staticDir });
- try {
- const fixture = seedManagerThreadStorage(harness);
- const request = serverApp.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/missing.js`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const assetCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appAssetsRoot(fixture, "status") &&
- command.path === "missing.js",
- );
- await reportQueuedCommandError(harness, assetCommand, {
- errorCode: "ENOENT",
- errorMessage: "Path does not exist: missing.js",
- });
-
- const response = await request;
- const body = await response.text();
- expect(response.status).toBe(404);
- expect(response.headers.get("content-type")).toBe("application/json");
- expect(body).not.toContain("bb-app-shell-root");
- expect(JSON.parse(body)).toMatchObject({
- code: "ENOENT",
- message: "Path does not exist: missing.js",
- });
- } finally {
- await serverApp.closeWebSockets();
- await harness.cleanup();
- await rm(staticDir, { recursive: true, force: true });
- }
- });
-
- it("proxies app data list, read, write, and delete through generic daemon file commands", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const stateJson = `${JSON.stringify({ workers: [] }, null, 2)}\n`;
-
- const listRequest = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/data`,
- );
- const listManifest = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const listCommand = await waitForQueuedCommandAfter(
- harness,
- listManifest.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === appDataRoot(fixture, "status"),
- );
- await reportQueuedCommandSuccess(harness, listCommand, {
- paths: [pathEntry({ kind: "file", path: "state.json" })],
- truncated: false,
- });
- const listRead = await waitForQueuedCommandAfter(
- harness,
- listCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "state.json",
- );
- await reportQueuedCommandSuccess(
- harness,
- listRead,
- readFileResult({
- path: "state.json",
- content: stateJson,
- mimeType: "application/json",
- }),
- );
- const listMetadata = await waitForQueuedCommandAfter(
- harness,
- listCommand.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path === `${appDataRoot(fixture, "status")}/state.json`,
- );
- await reportQueuedCommandSuccess(harness, listMetadata, {
- path: `${appDataRoot(fixture, "status")}/state.json`,
- modifiedAtMs: 1234,
- sizeBytes: Buffer.byteLength(stateJson),
- });
- const listResponse = await listRequest;
- expect(listResponse.status).toBe(200);
- expect(
- appDataListResponseSchema.parse(await readJson(listResponse)),
- ).toEqual({
- entries: [
- {
- path: "state.json",
- value: { workers: [] },
- version: sha256Text(stateJson),
- sizeBytes: Buffer.byteLength(stateJson),
- modifiedAtMs: 1234,
- },
- ],
- });
-
- const readRequest = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
- );
- const readManifest = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: listMetadata.row.cursor,
- manifest: STATUS_MANIFEST,
- });
- const readCommand = await waitForQueuedCommandAfter(
- harness,
- readManifest.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "state.json",
- );
- await reportQueuedCommandSuccess(
- harness,
- readCommand,
- readFileResult({
- path: "state.json",
- content: stateJson,
- mimeType: "application/json",
- }),
- );
- const readMetadata = await waitForQueuedCommandAfter(
- harness,
- readManifest.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path === `${appDataRoot(fixture, "status")}/state.json`,
- );
- await reportQueuedCommandSuccess(harness, readMetadata, {
- path: `${appDataRoot(fixture, "status")}/state.json`,
- modifiedAtMs: 2345,
- sizeBytes: Buffer.byteLength(stateJson),
- });
- const readResponse = await readRequest;
- expect(readResponse.status).toBe(200);
- expect(
- appDataReadResponseSchema.parse(await readJson(readResponse)),
- ).toEqual({
- path: "state.json",
- value: { workers: [] },
- version: sha256Text(stateJson),
- sizeBytes: Buffer.byteLength(stateJson),
- modifiedAtMs: 2345,
- });
-
- const nextValue = { workers: [{ id: "worker-1" }] };
- const nextJson = `${JSON.stringify(nextValue, null, 2)}\n`;
- const writeRequest = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ value: nextValue }),
- },
- );
- const writeManifest = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: readMetadata.row.cursor,
- manifest: STATUS_MANIFEST,
- });
- const writeCommand = await waitForQueuedCommandAfter(
- harness,
- writeManifest.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "state.json",
- );
- expect(writeCommand.command).toMatchObject({
- dotfiles: "deny",
- content: nextJson,
- contentEncoding: "utf8",
- });
- await reportQueuedCommandSuccess(harness, writeCommand, {
- path: "state.json",
- hash: sha256Text(nextJson),
- modifiedAtMs: 3456,
- sizeBytes: Buffer.byteLength(nextJson),
- });
- const writeResponse = await writeRequest;
- expect(writeResponse.status).toBe(200);
- expect(
- appDataReadResponseSchema.parse(await readJson(writeResponse)),
- ).toEqual({
- path: "state.json",
- value: nextValue,
- version: sha256Text(nextJson),
- sizeBytes: Buffer.byteLength(nextJson),
- modifiedAtMs: 3456,
- });
-
- const deleteRequest = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/data/state.json`,
- { method: "DELETE" },
- );
- const deleteManifest = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: writeCommand.row.cursor,
- manifest: STATUS_MANIFEST,
- });
- const deleteCommand = await waitForQueuedCommandAfter(
- harness,
- deleteManifest.row.cursor,
- ({ command }) =>
- command.type === "host.delete_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "state.json",
- );
- await reportQueuedCommandSuccess(harness, deleteCommand, {
- path: "state.json",
- deleted: true,
- previousHash: sha256Text(nextJson),
- });
- const deleteResponse = await deleteRequest;
- expect(deleteResponse.status).toBe(200);
- await expect(readJson(deleteResponse)).resolves.toEqual({ ok: true });
- });
- });
-
- it("lists app data subtree prefixes when the prefix is a directory", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const oneJson = `${JSON.stringify({ title: "One" }, null, 2)}\n`;
- const twoJson = `${JSON.stringify({ title: "Two" }, null, 2)}\n`;
-
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/data?prefix=tasks`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- manifest: STATUS_MANIFEST,
- });
- const prefixRead = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "tasks",
- );
- await reportQueuedCommandError(harness, prefixRead, {
- errorCode: "invalid_path",
- errorMessage: "Path is a directory, not a file",
- });
- const listCommand = await waitForQueuedCommandAfter(
- harness,
- prefixRead.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === `${appDataRoot(fixture, "status")}/tasks`,
- );
- await reportQueuedCommandSuccess(harness, listCommand, {
- paths: [
- pathEntry({ kind: "file", path: "one.json" }),
- pathEntry({ kind: "file", path: "nested/two.json" }),
- ],
- truncated: false,
- });
-
- const oneRead = await waitForQueuedCommandAfter(
- harness,
- listCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "tasks/one.json",
- );
- await reportQueuedCommandSuccess(
- harness,
- oneRead,
- readFileResult({
- path: "tasks/one.json",
- content: oneJson,
- mimeType: "application/json",
- }),
- );
- const twoRead = await waitForQueuedCommandAfter(
- harness,
- listCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appDataRoot(fixture, "status") &&
- command.path === "tasks/nested/two.json",
- );
- await reportQueuedCommandSuccess(
- harness,
- twoRead,
- readFileResult({
- path: "tasks/nested/two.json",
- content: twoJson,
- mimeType: "application/json",
- }),
- );
- const oneMetadata = await waitForQueuedCommandAfter(
- harness,
- oneRead.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path === `${appDataRoot(fixture, "status")}/tasks/one.json`,
- );
- await reportQueuedCommandSuccess(harness, oneMetadata, {
- path: `${appDataRoot(fixture, "status")}/tasks/one.json`,
- modifiedAtMs: 1111,
- sizeBytes: Buffer.byteLength(oneJson),
- });
- const twoMetadata = await waitForQueuedCommandAfter(
- harness,
- twoRead.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path ===
- `${appDataRoot(fixture, "status")}/tasks/nested/two.json`,
- );
- await reportQueuedCommandSuccess(harness, twoMetadata, {
- path: `${appDataRoot(fixture, "status")}/tasks/nested/two.json`,
- modifiedAtMs: 2222,
- sizeBytes: Buffer.byteLength(twoJson),
- });
-
- const response = await request;
- expect(response.status).toBe(200);
- expect(appDataListResponseSchema.parse(await readJson(response))).toEqual(
- {
- entries: [
- {
- path: "tasks/nested/two.json",
- value: { title: "Two" },
- version: sha256Text(twoJson),
- sizeBytes: Buffer.byteLength(twoJson),
- modifiedAtMs: 2222,
- },
- {
- path: "tasks/one.json",
- value: { title: "One" },
- version: sha256Text(oneJson),
- sizeBytes: Buffer.byteLength(oneJson),
- modifiedAtMs: 1111,
- },
- ],
- },
- );
- });
- });
-
- it("serves top-level logo icons and 404s built-in icons", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const logoManifest: AppManifest = {
- manifestVersion: 1,
- id: "demo",
- name: "Demo",
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- };
- const logoSvg = ' ';
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/demo/icon`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "demo",
- manifest: logoManifest,
- });
- const logoListCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === appRoot(fixture, "demo"),
- );
- await reportQueuedCommandSuccess(harness, logoListCommand, {
- paths: [pathEntry({ kind: "file", path: "logo.svg" })],
- truncated: false,
- });
- const metadataCommand = await waitForQueuedCommandAfter(
- harness,
- logoListCommand.row.cursor,
- ({ command }) =>
- command.type === "host.file_metadata" &&
- command.path === `${appRoot(fixture, "demo")}/logo.svg`,
- );
- await reportQueuedCommandSuccess(harness, metadataCommand, {
- path: `${appRoot(fixture, "demo")}/logo.svg`,
- modifiedAtMs: 4567,
- sizeBytes: Buffer.byteLength(logoSvg),
- });
- const readCommand = await waitForQueuedCommandAfter(
- harness,
- metadataCommand.row.cursor,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(fixture, "demo") &&
- command.path === "logo.svg",
- );
- await reportQueuedCommandSuccess(
- harness,
- readCommand,
- readFileResult({
- path: "logo.svg",
- content: logoSvg,
- mimeType: "image/svg+xml",
- }),
- );
- const response = await request;
- expect(response.status).toBe(200);
- expect(response.headers.get("content-type")).toBe("image/svg+xml");
- await expect(response.text()).resolves.toBe(logoSvg);
-
- const builtInRequest = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/status/icon`,
- );
- await reportManifestRead({
- harness,
- fixture,
- appId: "status",
- afterCursor: readCommand.row.cursor,
- manifest: STATUS_MANIFEST,
- });
- const builtInResponse = await builtInRequest;
- expect(builtInResponse.status).toBe(404);
- });
- });
-
- it("does not serve logo symlinks omitted by the daemon path listing", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const logoManifest: AppManifest = {
- manifestVersion: 1,
- id: "demo",
- name: "Demo",
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- };
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps/demo/icon`,
- );
- const manifestCommand = await reportManifestRead({
- harness,
- fixture,
- appId: "demo",
- manifest: logoManifest,
- });
- const logoListCommand = await waitForQueuedCommandAfter(
- harness,
- manifestCommand.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === appRoot(fixture, "demo"),
- );
- await reportQueuedCommandSuccess(harness, logoListCommand, {
- paths: [pathEntry({ kind: "file", path: "manifest.json" })],
- truncated: false,
- });
-
- const response = await request;
- expect(response.status).toBe(404);
- });
- });
-
- it("scaffolds status-template apps through the server lifecycle route", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- id: "demo",
- name: "Demo",
- template: "status",
- }),
- },
- );
- const existingCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(fixture, "demo") &&
- command.path === "manifest.json",
- );
- await reportQueuedCommandError(harness, existingCommand, {
- errorCode: "ENOENT",
- errorMessage: "Path does not exist: manifest.json",
- });
-
- const manifestWrite = await waitForQueuedCommandAfter(
- harness,
- existingCommand.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "demo") &&
- command.path === "manifest.json",
- );
- expect(manifestWrite.command).toMatchObject({
- dotfiles: "deny",
- contentEncoding: "utf8",
- });
- const manifestWriteCommand =
- requireWriteFileRelativeCommand(manifestWrite);
- await reportQueuedCommandSuccess(harness, manifestWrite, {
- path: "manifest.json",
- hash: sha256Text(manifestWriteCommand.command.content),
- modifiedAtMs: 1000,
- sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
- });
-
- const htmlWrite = await waitForQueuedCommandAfter(
- harness,
- manifestWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "demo") &&
- command.path === "assets/index.html",
- );
- const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
- await reportQueuedCommandSuccess(harness, htmlWrite, {
- path: "assets/index.html",
- hash: sha256Text(htmlWriteCommand.command.content),
- modifiedAtMs: 1001,
- sizeBytes: Buffer.byteLength(htmlWriteCommand.command.content),
- });
-
- const stateWrite = await waitForQueuedCommandAfter(
- harness,
- htmlWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "demo") &&
- command.path === "data/state.json",
- );
- const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
- await reportQueuedCommandSuccess(harness, stateWrite, {
- path: "data/state.json",
- hash: sha256Text(stateWriteCommand.command.content),
- modifiedAtMs: 1002,
- sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
- });
-
- await reportManifestRead({
- harness,
- fixture,
- appId: "demo",
- afterCursor: stateWrite.row.cursor,
- manifest: {
- manifestVersion: 1,
- id: "demo",
- name: "Demo",
- icon: "ListTodo",
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- },
- });
-
- const response = await request;
- expect(response.status).toBe(201);
- expect(appDetailSchema.parse(await readJson(response))).toMatchObject({
- id: "demo",
- name: "Demo",
- entry: { kind: "html", path: "index.html" },
- icon: { kind: "builtin", name: "ListTodo" },
- capabilities: ["data", "message"],
- });
- });
- });
-
- it("scaffolds blank-template apps with the bb-styled index.html", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- id: "blank-demo",
- name: "Blank Demo",
- template: "blank",
- }),
- },
- );
- const existingCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(fixture, "blank-demo") &&
- command.path === "manifest.json",
- );
- await reportQueuedCommandError(harness, existingCommand, {
- errorCode: "ENOENT",
- errorMessage: "Path does not exist: manifest.json",
- });
-
- const manifestWrite = await waitForQueuedCommandAfter(
- harness,
- existingCommand.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "blank-demo") &&
- command.path === "manifest.json",
- );
- const manifestWriteCommand =
- requireWriteFileRelativeCommand(manifestWrite);
- await reportQueuedCommandSuccess(harness, manifestWrite, {
- path: "manifest.json",
- hash: sha256Text(manifestWriteCommand.command.content),
- modifiedAtMs: 1000,
- sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
- });
-
- const htmlWrite = await waitForQueuedCommandAfter(
- harness,
- manifestWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "blank-demo") &&
- command.path === "assets/index.html",
- );
- const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
- const html = htmlWriteCommand.command.content;
- // bb design tokens come through verbatim from `bb guide styling`.
- expect(html).toContain('--font-sans: "Inter"');
- expect(html).toContain("oklch(0.9551 0 0)");
- expect(html).toContain("@media (prefers-color-scheme: dark)");
- // Placeholder copy keeps the blank scaffold aligned with app guidance.
- expect(html).toContain(
- "No web server or build step needed.",
- );
- // Task-list row vocabulary is present so the scaffold looks bb-native.
- expect(html).toContain('class="row"');
- expect(html).toContain('class="pill"');
- // App name is interpolated into the visible title.
- expect(html).toContain("Blank Demo ");
- await reportQueuedCommandSuccess(harness, htmlWrite, {
- path: "assets/index.html",
- hash: sha256Text(html),
- modifiedAtMs: 1001,
- sizeBytes: Buffer.byteLength(html),
- });
-
- const stateWrite = await waitForQueuedCommandAfter(
- harness,
- htmlWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "blank-demo") &&
- command.path === "data/state.json",
- );
- const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
- await reportQueuedCommandSuccess(harness, stateWrite, {
- path: "data/state.json",
- hash: sha256Text(stateWriteCommand.command.content),
- modifiedAtMs: 1002,
- sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
- });
-
- const manifestRead = await reportManifestRead({
- harness,
- fixture,
- appId: "blank-demo",
- afterCursor: stateWrite.row.cursor,
- manifest: {
- manifestVersion: 1,
- id: "blank-demo",
- name: "Blank Demo",
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- },
- });
-
- // No icon in manifest -> server probes the app root for a logo file.
- const logoListCommand = await waitForQueuedCommandAfter(
- harness,
- manifestRead.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === appRoot(fixture, "blank-demo"),
- );
- await reportQueuedCommandSuccess(harness, logoListCommand, {
- paths: [],
- truncated: false,
- });
-
- const response = await request;
- expect(response.status).toBe(201);
- expect(appDetailSchema.parse(await readJson(response))).toMatchObject({
- id: "blank-demo",
- name: "Blank Demo",
- entry: { kind: "html", path: "index.html" },
- icon: { kind: "builtin", name: "GridView" },
- capabilities: ["data", "message"],
- });
- });
- });
-
- it("HTML-escapes the app name in the blank scaffold to block XSS", async () => {
- await withTestHarness(async (harness) => {
- const fixture = seedManagerThreadStorage(harness);
- const maliciousName = ` & "q" 'a'`;
- const request = harness.app.request(
- `/api/v1/threads/${fixture.threadId}/apps`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- id: "xss-demo",
- name: maliciousName,
- template: "blank",
- }),
- },
- );
- const existingCommand = await waitForQueuedCommand(
- harness,
- ({ command }) =>
- command.type === "host.read_file_relative" &&
- command.rootPath === appRoot(fixture, "xss-demo") &&
- command.path === "manifest.json",
- );
- await reportQueuedCommandError(harness, existingCommand, {
- errorCode: "ENOENT",
- errorMessage: "Path does not exist: manifest.json",
- });
-
- const manifestWrite = await waitForQueuedCommandAfter(
- harness,
- existingCommand.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "xss-demo") &&
- command.path === "manifest.json",
- );
- const manifestWriteCommand =
- requireWriteFileRelativeCommand(manifestWrite);
- await reportQueuedCommandSuccess(harness, manifestWrite, {
- path: "manifest.json",
- hash: sha256Text(manifestWriteCommand.command.content),
- modifiedAtMs: 2000,
- sizeBytes: Buffer.byteLength(manifestWriteCommand.command.content),
- });
-
- const htmlWrite = await waitForQueuedCommandAfter(
- harness,
- manifestWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "xss-demo") &&
- command.path === "assets/index.html",
- );
- const htmlWriteCommand = requireWriteFileRelativeCommand(htmlWrite);
- const html = htmlWriteCommand.command.content;
-
- // Raw special characters from the name must never reach the rendered
- // HTML where they would be interpreted as markup.
- expect(html).not.toContain("");
- expect(html).not.toContain('name & "q"');
- expect(html).not.toContain("'a'");
- // Each special char in the name is replaced with its entity form.
- expect(html).toContain("<script>alert(1)</script>");
- expect(html).toContain("&");
- expect(html).toContain(""q"");
- expect(html).toContain("'a'");
- // The escaped name shows up in both the and the visible header.
- const escapedName =
- "<script>alert(1)</script> & "q" 'a'";
- expect(html).toContain(`${escapedName} `);
- expect(html).toContain(
- `${escapedName} `,
- );
-
- await reportQueuedCommandSuccess(harness, htmlWrite, {
- path: "assets/index.html",
- hash: sha256Text(html),
- modifiedAtMs: 2001,
- sizeBytes: Buffer.byteLength(html),
- });
-
- const stateWrite = await waitForQueuedCommandAfter(
- harness,
- htmlWrite.row.cursor,
- ({ command }) =>
- command.type === "host.write_file_relative" &&
- command.rootPath === appRoot(fixture, "xss-demo") &&
- command.path === "data/state.json",
- );
- const stateWriteCommand = requireWriteFileRelativeCommand(stateWrite);
- await reportQueuedCommandSuccess(harness, stateWrite, {
- path: "data/state.json",
- hash: sha256Text(stateWriteCommand.command.content),
- modifiedAtMs: 2002,
- sizeBytes: Buffer.byteLength(stateWriteCommand.command.content),
- });
-
- const manifestRead = await reportManifestRead({
- harness,
- fixture,
- appId: "xss-demo",
- afterCursor: stateWrite.row.cursor,
- manifest: {
- manifestVersion: 1,
- id: "xss-demo",
- name: maliciousName,
- entry: "index.html",
- contributions: ["thread.app"],
- capabilities: ["data", "message"],
- },
- });
-
- const logoListCommand = await waitForQueuedCommandAfter(
- harness,
- manifestRead.row.cursor,
- ({ command }) =>
- command.type === "host.list_paths" &&
- command.path === appRoot(fixture, "xss-demo"),
- );
- await reportQueuedCommandSuccess(harness, logoListCommand, {
- paths: [],
- truncated: false,
- });
-
- const response = await request;
- expect(response.status).toBe(201);
- });
- });
-});
diff --git a/apps/server/test/public/public-threads.manager-and-ownership.test.ts b/apps/server/test/public/public-threads.manager-and-ownership.test.ts
index 05af96067..944c869c7 100644
--- a/apps/server/test/public/public-threads.manager-and-ownership.test.ts
+++ b/apps/server/test/public/public-threads.manager-and-ownership.test.ts
@@ -471,7 +471,7 @@ describe("public thread manager and ownership routes", () => {
name: "default",
files: {
"PREFERENCES.md": "default prefs\n",
- "apps/status/data/state.json": '{"prs":[]}\n',
+ "notes/state.json": '{"prs":[]}\n',
},
});
@@ -529,8 +529,11 @@ describe("public thread manager and ownership routes", () => {
readFile(path.join(storagePath, "PREFERENCES.md"), "utf8"),
).resolves.toBe("default prefs\n");
await expect(
- readFile(path.join(storagePath, "apps/status/data/state.json"), "utf8"),
+ readFile(path.join(storagePath, "notes/state.json"), "utf8"),
).resolves.toBe('{"prs":[]}\n');
+ await expect(
+ stat(path.join(storagePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
@@ -1107,17 +1110,8 @@ describe("public thread manager and ownership routes", () => {
stat(path.join(storagePath, "PREFERENCES.md")),
).rejects.toThrow();
await expect(
- readFile(path.join(storagePath, "apps/status/manifest.json"), "utf8"),
- ).resolves.toContain('"id": "status"');
- await expect(
- readFile(
- path.join(storagePath, "apps/status/assets/index.html"),
- "utf8",
- ),
- ).resolves.toContain("Status ");
- await expect(
- readFile(path.join(storagePath, "apps/status/data/state.json"), "utf8"),
- ).resolves.toBe("{}\n");
+ stat(path.join(storagePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
diff --git a/apps/server/test/scheduling/nudge-sweep.test.ts b/apps/server/test/scheduling/nudge-sweep.test.ts
index a74ae42b1..a15fdc8a0 100644
--- a/apps/server/test/scheduling/nudge-sweep.test.ts
+++ b/apps/server/test/scheduling/nudge-sweep.test.ts
@@ -321,6 +321,7 @@ describe("nudge sweep", () => {
providerThreadId: "provider-manager-thread",
instructions: "manager instructions",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
diff --git a/apps/server/test/services/threads/app-client-script.test.ts b/apps/server/test/services/threads/app-client-script.test.ts
index c2ad6b769..c5a091151 100644
--- a/apps/server/test/services/threads/app-client-script.test.ts
+++ b/apps/server/test/services/threads/app-client-script.test.ts
@@ -13,7 +13,10 @@ type OpenHandler = () => void;
type CloseHandler = () => void;
type ErrorHandler = () => void;
type MessageHandler = (event: SocketMessageEvent) => void;
-type FetchMock = (input: RequestInfo | URL, init?: RequestInit) => Promise;
+type FetchMock = (
+ input: RequestInfo | URL,
+ init?: RequestInit,
+) => Promise;
interface SocketMessageEvent {
data: string;
@@ -35,10 +38,12 @@ interface DeferredResponse {
const bootstrap: AppClientBootstrap = {
appId: "status",
+ applicationId: "status",
+ appSessionToken: "appsess_test",
capabilities: ["data", "message"],
- threadId: "thr_123",
- dataUrl: "/api/v1/threads/thr_123/apps/status/data",
- messageUrl: "/api/v1/threads/thr_123/apps/status/message",
+ dataUrl: "/api/v1/apps/status/data",
+ messageUrl: "/api/v1/apps/status/message",
+ targetThreadId: "thr_123",
wsUrl: "ws://server/ws",
};
@@ -147,10 +152,10 @@ describe("app client script", () => {
expect(JSON.parse(socket.messages[0] ?? "")).toEqual({
type: "subscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "status:data",
});
expect(fetchMock).toHaveBeenCalledWith(
- "/api/v1/threads/thr_123/apps/status/data",
+ "/api/v1/apps/status/data",
expect.objectContaining({ method: "GET" }),
);
});
@@ -194,12 +199,12 @@ describe("app client script", () => {
{
type: "subscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "status:data",
},
{
type: "unsubscribe",
entity: "thread",
- id: "thr_123:app:status:data",
+ id: "status:data",
},
]);
});
@@ -248,8 +253,7 @@ describe("app client script", () => {
await flushPromises();
socket.emit({
type: "app-data.resync",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
});
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledTimes(2);
diff --git a/apps/server/test/skills/injected-skills.test.ts b/apps/server/test/skills/injected-skills.test.ts
new file mode 100644
index 000000000..7780f8506
--- /dev/null
+++ b/apps/server/test/skills/injected-skills.test.ts
@@ -0,0 +1,221 @@
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { resolveApplicationPath } from "@bb/config/app-storage-paths";
+import { applicationIdSchema, type ApplicationId } from "@bb/domain";
+import { resolveInjectedSkillSources } from "../../src/services/skills/injected-skills.js";
+import type { ServerLogger } from "../../src/types.js";
+
+interface CapturedWarning {
+ context: object;
+ message: string;
+}
+
+interface CapturingLogger {
+ logger: ServerLogger;
+ warnings: CapturedWarning[];
+}
+
+interface WriteSkillArgs {
+ description?: string;
+ name: string;
+ rootPath: string;
+}
+
+interface WriteApplicationArgs {
+ applicationId: ApplicationId;
+ dataDir: string;
+}
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(): Promise {
+ const root = await mkdtemp(path.join(tmpdir(), "bb-injected-skills-"));
+ tempDirs.push(root);
+ return root;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
+ );
+});
+
+function createCapturingLogger(): CapturingLogger {
+ const warnings: CapturedWarning[] = [];
+ function captureWarning(...args: Parameters): void {
+ const firstArg = args[0];
+ const secondArg = args[1];
+ warnings.push({
+ context:
+ typeof firstArg === "object" && firstArg !== null ? firstArg : {},
+ message:
+ typeof secondArg === "string"
+ ? secondArg
+ : typeof firstArg === "string"
+ ? firstArg
+ : "",
+ });
+ }
+ return {
+ warnings,
+ logger: {
+ debug: () => undefined,
+ error: () => undefined,
+ info: () => undefined,
+ warn: captureWarning,
+ },
+ };
+}
+
+async function writeSkill(args: WriteSkillArgs): Promise {
+ const skillRootPath = path.join(args.rootPath, args.name);
+ await mkdir(skillRootPath, { recursive: true });
+ await writeFile(
+ path.join(skillRootPath, "SKILL.md"),
+ [
+ "---",
+ `name: ${args.name}`,
+ `description: ${args.description ?? `Use ${args.name} when tests need it.`}`,
+ "---",
+ "",
+ `# ${args.name}`,
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ return skillRootPath;
+}
+
+async function writeApplication(args: WriteApplicationArgs): Promise {
+ const appRootPath = resolveApplicationPath(args.dataDir, args.applicationId);
+ await mkdir(appRootPath, { recursive: true });
+ await writeFile(
+ path.join(appRootPath, "manifest.json"),
+ `${JSON.stringify(
+ {
+ manifestVersion: 1,
+ id: args.applicationId,
+ name: "Skill Test App",
+ capabilities: [],
+ },
+ null,
+ 2,
+ )}\n`,
+ "utf8",
+ );
+ return appRootPath;
+}
+
+describe("injected skill source discovery", () => {
+ it("aggregates valid data-dir and global app skills", async () => {
+ const dataDir = await makeTempDir();
+ const applicationId = applicationIdSchema.parse("skillstest");
+ const appRootPath = await writeApplication({ dataDir, applicationId });
+ const dataDirSkillRoot = await writeSkill({
+ rootPath: path.join(dataDir, "skills"),
+ name: "release-notes",
+ });
+ const appSkillRoot = await writeSkill({
+ rootPath: path.join(appRootPath, "skills"),
+ name: "summarize-trades",
+ });
+ const { logger } = createCapturingLogger();
+
+ const sources = await resolveInjectedSkillSources(logger, { dataDir });
+
+ expect(sources).toEqual([
+ {
+ sourceType: "data-dir",
+ applicationId: null,
+ name: "release-notes",
+ description: "Use release-notes when tests need it.",
+ sourceRootPath: dataDirSkillRoot,
+ skillFilePath: path.join(dataDirSkillRoot, "SKILL.md"),
+ },
+ {
+ sourceType: "global-app",
+ applicationId,
+ name: "summarize-trades",
+ description: "Use summarize-trades when tests need it.",
+ sourceRootPath: appSkillRoot,
+ skillFilePath: path.join(appSkillRoot, "SKILL.md"),
+ },
+ ]);
+ });
+
+ it("skips invalid skills and logs the reason", async () => {
+ const dataDir = await makeTempDir();
+ const skillRootPath = path.join(dataDir, "skills", "valid-name");
+ await mkdir(skillRootPath, { recursive: true });
+ await writeFile(
+ path.join(skillRootPath, "SKILL.md"),
+ [
+ "---",
+ "name: other-name",
+ "description: Use when the mismatch test runs.",
+ "---",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ const { logger, warnings } = createCapturingLogger();
+
+ expect(resolveInjectedSkillSources(logger, { dataDir })).toEqual([]);
+ expect(warnings).toEqual([
+ expect.objectContaining({
+ message: "Skipping invalid injected skill",
+ }),
+ ]);
+ expect(warnings[0]?.context).toMatchObject({
+ candidatePath: skillRootPath,
+ reason: "Frontmatter name must match the skill directory name",
+ sourceType: "data-dir",
+ });
+ });
+
+ it("rejects symlinked skill directories", async () => {
+ const dataDir = await makeTempDir();
+ const outsideRoot = await makeTempDir();
+ const skillsRootPath = path.join(dataDir, "skills");
+ await mkdir(skillsRootPath, { recursive: true });
+ await writeSkill({
+ rootPath: outsideRoot,
+ name: "outside-skill",
+ });
+ await symlink(
+ path.join(outsideRoot, "outside-skill"),
+ path.join(skillsRootPath, "outside-skill"),
+ );
+ const { logger, warnings } = createCapturingLogger();
+
+ expect(resolveInjectedSkillSources(logger, { dataDir })).toEqual([]);
+ expect(warnings[0]?.context).toMatchObject({
+ reason: "Skill directory is a symlink",
+ sourceType: "data-dir",
+ });
+ });
+
+ it("excludes all sources with colliding names across both roots", async () => {
+ const dataDir = await makeTempDir();
+ const applicationId = applicationIdSchema.parse("collision");
+ const appRootPath = await writeApplication({ dataDir, applicationId });
+ await writeSkill({
+ rootPath: path.join(dataDir, "skills"),
+ name: "shared-skill",
+ });
+ await writeSkill({
+ rootPath: path.join(appRootPath, "skills"),
+ name: "shared-skill",
+ });
+ const { logger, warnings } = createCapturingLogger();
+
+ expect(resolveInjectedSkillSources(logger, { dataDir })).toEqual([]);
+ expect(
+ warnings.filter(
+ (warning) => warning.message === "Skipping colliding injected skill",
+ ),
+ ).toHaveLength(2);
+ });
+});
diff --git a/apps/server/test/threads/manager-storage-templates.test.ts b/apps/server/test/threads/manager-storage-templates.test.ts
index 547d46ffb..573fa68d6 100644
--- a/apps/server/test/threads/manager-storage-templates.test.ts
+++ b/apps/server/test/threads/manager-storage-templates.test.ts
@@ -18,7 +18,6 @@ import {
managerTemplateRootPath,
seedManagerThreadStorage,
} from "../../src/services/threads/manager-storage-templates.js";
-import { buildBlankAppIndexHtml } from "../../src/services/threads/blank-app-scaffold.js";
import type { TestAppHarness } from "../helpers/test-app.js";
import { seedHost } from "../helpers/seed.js";
import { createTestAppHarness, testLogger, withTestHarness } from "../helpers/test-app.js";
@@ -76,42 +75,6 @@ async function createSeedHarness(): Promise {
};
}
-async function readBundledManifestJson(): Promise {
- return readFile(
- new URL(
- "../../src/services/threads/default-template/apps/status/manifest.json",
- import.meta.url,
- ),
- "utf8",
- );
-}
-
-const BUNDLED_STATUS_INDEX_HTML = buildBlankAppIndexHtml({ name: "Status" });
-const BUNDLED_STATUS_STATE_JSON = "{}\n";
-
-async function expectBundledStatusAppSeeded(
- threadStoragePath: string,
-): Promise {
- await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/manifest.json"),
- "utf8",
- ),
- ).resolves.toBe(await readBundledManifestJson());
- await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/assets/index.html"),
- "utf8",
- ),
- ).resolves.toBe(BUNDLED_STATUS_INDEX_HTML);
- await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/data/state.json"),
- "utf8",
- ),
- ).resolves.toBe(BUNDLED_STATUS_STATE_JSON);
-}
-
async function writeManagerTemplateSet(
args: WriteManagerTemplateSetArgs,
): Promise {
@@ -169,7 +132,7 @@ describe("manager storage templates", () => {
});
});
- it("seeds the bundled status app when default resolves and no user template directory exists", async () => {
+ it("does not seed hidden default files when no user template directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
const threadStoragePath = await seedStorage({
@@ -180,7 +143,7 @@ describe("manager storage templates", () => {
threadId: "thr-default-fallback",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
await expect(
stat(managerTemplateRootPath({ dataDir })),
).rejects.toThrow();
@@ -190,7 +153,7 @@ describe("manager storage templates", () => {
}
});
- it("overlays the bundled status app on top of user-authored default files", async () => {
+ it("copies user-authored default template files", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
@@ -198,6 +161,7 @@ describe("manager storage templates", () => {
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: {
"USER.md": "user notes\n",
+ "notes/state.json": '{"prs":[]}\n',
},
});
@@ -212,25 +176,41 @@ describe("manager storage templates", () => {
await expect(
readFile(path.join(threadStoragePath, "USER.md"), "utf8"),
).resolves.toBe("user notes\n");
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(
+ readFile(path.join(threadStoragePath, "notes/state.json"), "utf8"),
+ ).resolves.toBe('{"prs":[]}\n');
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("user-authored files win over the bundled overlay at the same path", async () => {
+ it("does not overwrite files that already exist in thread storage", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
dataDir,
name: DEFAULT_MANAGER_TEMPLATE_NAME,
files: {
- "apps/status/manifest.json": '{"user":true}\n',
+ "USER.md": "template notes\n",
},
});
+ const threadStoragePath = path.join(
+ dataDir,
+ "thread-storage",
+ "thr-user-overrides",
+ );
+ await mkdir(threadStoragePath, { recursive: true });
+ await writeFile(
+ path.join(threadStoragePath, "USER.md"),
+ "existing notes\n",
+ "utf8",
+ );
- const threadStoragePath = await seedStorage({
+ await seedStorage({
dataDir,
explicitTemplateName: null,
harness,
@@ -239,30 +219,15 @@ describe("manager storage templates", () => {
});
await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/manifest.json"),
- "utf8",
- ),
- ).resolves.toBe('{"user":true}\n');
- await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/assets/index.html"),
- "utf8",
- ),
- ).resolves.toBe(BUNDLED_STATUS_INDEX_HTML);
- await expect(
- readFile(
- path.join(threadStoragePath, "apps/status/data/state.json"),
- "utf8",
- ),
- ).resolves.toBe(BUNDLED_STATUS_STATE_JSON);
+ readFile(path.join(threadStoragePath, "USER.md"), "utf8"),
+ ).resolves.toBe("existing notes\n");
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("overlays the bundled status app even when the default template directory is empty", async () => {
+ it("creates an empty storage directory when the default template directory is empty", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeManagerTemplateSet({
@@ -279,14 +244,18 @@ describe("manager storage templates", () => {
threadId: "thr-empty-default",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ const storageStat = await stat(threadStoragePath);
+ expect(storageStat.isDirectory()).toBe(true);
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
}
});
- it("warns and still overlays bundled status when active points to a missing non-default template", async () => {
+ it("warns and seeds nothing when active points to a missing non-default template", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -308,13 +277,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-active",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-active",
}),
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
} finally {
await harness.cleanup();
@@ -322,7 +291,7 @@ describe("manager storage templates", () => {
}
});
- it("warns and still overlays bundled status when an explicit non-default template is missing", async () => {
+ it("warns and seeds nothing when an explicit non-default template is missing", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
const logger = {
...testLogger,
@@ -339,13 +308,13 @@ describe("manager storage templates", () => {
threadId: "thr-missing-explicit",
});
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(stat(threadStoragePath)).rejects.toThrow();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
templateName: MINE_MANAGER_TEMPLATE_NAME,
threadId: "thr-missing-explicit",
}),
- "Manager template directory is missing; overlaying bundled seed only",
+ "Manager template directory is missing; no template files were seeded",
);
} finally {
await harness.cleanup();
@@ -353,7 +322,7 @@ describe("manager storage templates", () => {
}
});
- it("seeds from the active non-default template and overlays bundled status when that directory exists", async () => {
+ it("seeds from the active non-default template when that directory exists", async () => {
const { dataDir, harness, hostId } = await createSeedHarness();
try {
await writeActiveManagerTemplate({
@@ -389,7 +358,9 @@ describe("manager storage templates", () => {
"utf8",
),
).resolves.toBe("{}\n");
- await expectBundledStatusAppSeeded(threadStoragePath);
+ await expect(
+ stat(path.join(threadStoragePath, "apps", "status", "manifest.json")),
+ ).rejects.toThrow();
} finally {
await harness.cleanup();
await rm(dataDir, { recursive: true, force: true });
diff --git a/apps/server/test/threads/thread-runtime-config.test.ts b/apps/server/test/threads/thread-runtime-config.test.ts
index 2e8b8fa6c..5c63e39af 100644
--- a/apps/server/test/threads/thread-runtime-config.test.ts
+++ b/apps/server/test/threads/thread-runtime-config.test.ts
@@ -1,10 +1,14 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
import { describe, expect, it } from "vitest";
import { markThreadDeleted, setThreadExecutionOverride } from "@bb/db";
+import { encodeClientTurnRequestIdNumber } from "@bb/domain";
import {
resolvePermissionEscalation,
resolveExecutionOptions,
resolveThreadRuntimeCommandConfig,
} from "../../src/services/threads/thread-runtime-config.js";
+import { buildThreadStartCommand } from "../../src/services/threads/thread-commands.js";
import {
seedEnvironment,
seedHostSession,
@@ -17,6 +21,30 @@ function resolveLocalTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
+interface WriteRuntimeSkillArgs {
+ dataDir: string;
+ name: string;
+}
+
+async function writeRuntimeSkill(args: WriteRuntimeSkillArgs): Promise {
+ const sourceRootPath = path.join(args.dataDir, "skills", args.name);
+ await mkdir(sourceRootPath, { recursive: true });
+ await writeFile(
+ path.join(sourceRootPath, "SKILL.md"),
+ [
+ "---",
+ `name: ${args.name}`,
+ `description: Use ${args.name} when server runtime tests run.`,
+ "---",
+ "",
+ "# Test Skill",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ return sourceRootPath;
+}
+
describe("thread runtime config", () => {
it.each([
{
@@ -263,6 +291,60 @@ describe("thread runtime config", () => {
});
});
+ it("serializes injected skill sources into new thread start commands", async () => {
+ await withTestHarness(async (harness) => {
+ const sourceRootPath = await writeRuntimeSkill({
+ dataDir: harness.config.dataDir,
+ name: "release-notes",
+ });
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-runtime-injected-skills",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ });
+ const thread = seedThread(harness.deps, {
+ projectId: project.id,
+ environmentId: environment.id,
+ providerId: "codex",
+ });
+ const execution = await resolveExecutionOptions(harness.deps, {
+ threadId: thread.id,
+ requestedExecution: {
+ model: "gpt-5",
+ source: "client/turn/requested",
+ },
+ });
+
+ const command = await buildThreadStartCommand(harness.deps, {
+ environment,
+ execution,
+ permissionEscalation: "ask",
+ input: [{ type: "text", text: "hello" }],
+ managerTemplateName: null,
+ projectId: project.id,
+ providerId: "codex",
+ requestId: encodeClientTurnRequestIdNumber({ value: 1 }),
+ thread,
+ });
+
+ expect(command.injectedSkillSources).toEqual([
+ {
+ sourceType: "data-dir",
+ applicationId: null,
+ name: "release-notes",
+ description: "Use release-notes when server runtime tests run.",
+ sourceRootPath,
+ skillFilePath: path.join(sourceRootPath, "SKILL.md"),
+ },
+ ]);
+ });
+ });
+
it("consumes the sticky thread execution override across turns without a request value", async () => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
diff --git a/docs/migrating-status-to-apps.md b/docs/migrating-status-to-apps.md
deleted file mode 100644
index 9ea6c3374..000000000
--- a/docs/migrating-status-to-apps.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# Migrating from STATUS to Apps
-
-The legacy **STATUS** surface (`STATUS.html` / `STATUS.md` / a `STATUS/` folder
-plus the `STATUS-data/` key–value store) has been removed. A thread now exposes
-one or more **apps** instead, and the old single status dashboard becomes a
-regular app with the id `status`.
-
-This guide is for an **existing manager** (or any thread) whose storage still
-contains the old STATUS files and needs to move to the new format. New threads
-already seed a `status` app automatically and need no migration.
-
-For the full feature reference, run `bb guide app`. This doc only covers the
-mechanical migration.
-
----
-
-## What changed
-
-| Legacy STATUS | New Apps |
-| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
-| `STATUS/index.html`, `STATUS.html`, or `STATUS.md` in the thread-storage root | `apps//assets/` on disk, served at `/api/v1/threads//apps//` |
-| One implicit status surface per thread | Any number of apps; the dashboard is just the app with id `status` |
-| `STATUS-data/.json` (flat keys, `^[A-Za-z0-9_-]{1,80}$`) | `apps//data/` (nested paths allowed; default: a single `data/state.json`) |
-| `window.bbStatusState` (`get`/`set`/`delete`/`list`/`on`) | `window.bb.data` (`read`/`write`/`delete`/`list`/`onChange`) |
-| `window.bbThreadTell(text)` | `window.bb.message(text)` |
-| Served at `/api/v1/threads//status/` | Served at `/api/v1/threads//apps//` |
-| Globals always injected | Injected only for **HTML** entries, gated by manifest `capabilities`; **Markdown** entries are static (no `window.bb`) |
-
----
-
-## Target layout
-
-```text
-/
- apps/
- status/
- manifest.json # metadata — NOT served
- assets/ # internal storage for browser files
- index.html # (or index.md)
- data/ # file-based key/value store
- state.json # default: keep all state in one blob
- logo.svg # optional; otherwise a built-in icon is used
-```
-
-`manifest.json` for the status dashboard:
-
-```json
-{
- "manifestVersion": 1,
- "id": "status",
- "name": "Status",
- "icon": "ListTodo",
- "entry": "index.html",
- "contributions": ["thread.app"],
- "capabilities": ["data", "message"]
-}
-```
-
-- `id` must match the directory name and `^[A-Za-z0-9_-]+$`.
-- `entry` is resolved relative to `assets/`. Use `index.html` for an
- interactive dashboard, or `index.md` for a static document (Markdown apps do
- **not** get `window.bb`).
-- `icon` is a built-in icon name. To use a custom image instead, drop a
- top-level `logo.svg` / `logo.png` / `logo.jpg` and omit `icon`; with neither,
- apps fall back to the `GridView` icon.
-- `capabilities`: `data` injects `window.bb.data`, `message` injects
- `window.bb.message`. List only what the app uses.
-
-Browser files under `assets/` are served from the flat app URL. For example,
-`apps/status/assets/index-Cd7sCqsN.js` is requested as
-`/api/v1/threads//apps/status/index-Cd7sCqsN.js`; do not include an
-`assets/` segment in HTML references. Apps are served directly from these
-static files, so there is no app-specific web server, npm install, or build
-step requirement.
-
----
-
-## Migration steps
-
-### 1. Create the app directory
-
-```bash
-APP="$BB_THREAD_STORAGE/apps/status"
-mkdir -p "$APP/assets" "$APP/data"
-```
-
-### 2. Move the markup into `assets/`
-
-- `STATUS.html` → `apps/status/assets/index.html`
-- `STATUS/index.html` (+ its CSS/JS/images/fonts) → `apps/status/assets/` (keep the same relative structure; everything under `assets/` is served from the flat app URL)
-- `STATUS.md` → `apps/status/assets/index.md` (set `entry` to `index.md`; remember a Markdown entry is static)
-
-Use flat relative HTML references, such as `` or ` `. If you are moving existing Vite build output, set
-`build.assetsDir = ""` so emitted CSS/JS/assets sit alongside `index.html`
-inside `assets/`; otherwise keep the app as plain static files.
-
-### 3. Migrate the state
-
-The default convention is a **single `data/state.json` blob**. If your old
-dashboard used a few `STATUS-data/*.json` keys, consolidate them:
-
-```bash
-# Example: fold STATUS-data/prs.json + STATUS-data/workers.json into one blob.
-jq -n \
- --slurpfile prs "$BB_THREAD_STORAGE/STATUS-data/prs.json" \
- --slurpfile workers "$BB_THREAD_STORAGE/STATUS-data/workers.json" \
- '{ prs: $prs[0], workers: $workers[0] }' \
- > "$BB_THREAD_STORAGE/apps/status/data/state.json"
-```
-
-Or, if you prefer to keep separate keys, each old `STATUS-data/.json`
-becomes `apps/status/data/.json` (nested paths like `data/tasks/123` are
-also allowed — see `bb guide app` for the path rules).
-
-### 4. Update the in-page JavaScript
-
-Replace the old globals (HTML entries only):
-
-| Old | New |
-| -------------------------------------- | ----------------------------------------- |
-| `window.bbStatusState.get(key)` | `await window.bb.data.read(path)` |
-| `window.bbStatusState.set(key, value)` | `await window.bb.data.write(path, value)` |
-| `window.bbStatusState.delete(key)` | `await window.bb.data.delete(path)` |
-| `window.bbStatusState.list()` | `await window.bb.data.list(prefix)` |
-| `window.bbStatusState.on(key, cb)` | `window.bb.data.onChange(prefix, cb)` |
-| `window.bbStatusState.on("*", cb)` | `window.bb.data.onChange("", cb)` |
-| `window.bbThreadTell(text)` | `await window.bb.message(text)` |
-
-Notes:
-
-- All `window.bb.data` methods are async (return Promises). `read` resolves to
- the parsed JSON value or `undefined`.
-- `onChange(prefix, cb)` does **subtree** matching: `onChange("tasks")` fires
- for `tasks` and `tasks/*` but not `tasksfoo`; `onChange("")` watches
- everything. It **replays** existing matches once on registration, then streams
- live changes. The callback receives `{ path, value, deleted }`.
-- If you consolidated into a single `state.json`, the common pattern is:
-
- ```js
- window.bb.data.read("state.json").then(render);
- window.bb.data.onChange("state.json", (event) => render(event.value));
- ```
-
-- Guard the helpers (`window.bb.data?.…`, `window.bb.message?.(…)`) — they are
- only present when the matching capability is declared.
-
-### 5. Update how the agent / maintainer writes state
-
-Agents (and any maintainer worker) write app data by writing the files
-**directly on disk** — the daemon watches `data/` and broadcasts changes to open
-clients. Use the same atomic temp-file + rename pattern as before, just to the
-new path:
-
-```bash
-DIR="$BB_THREAD_STORAGE/apps/status/data"
-mkdir -p "$DIR"
-tmp=$(mktemp "$DIR/.state.XXXXXX")
-printf '%s\n' "$NEW_STATE_JSON" > "$tmp" && mv "$tmp" "$DIR/state.json"
-```
-
-(The browser side uses `window.bb.data.write(...)`, which routes through the
-daemon to the same file. Both paths converge on the one watched directory.)
-
-If a long-running maintainer worker used to write `STATUS-data/task_*.json`,
-re-point it at `apps/status/data/…` (a single `state.json` is recommended).
-
-### 6. Remove the old STATUS files
-
-Once the app renders correctly:
-
-```bash
-rm -rf "$BB_THREAD_STORAGE"/STATUS.html \
- "$BB_THREAD_STORAGE"/STATUS.md \
- "$BB_THREAD_STORAGE"/STATUS \
- "$BB_THREAD_STORAGE"/STATUS-data
-```
-
-### 7. Verify
-
-```bash
-bb app list # should show the `status` app
-bb app open status # prints the served URL to open in the panel
-```
-
-Or open the thread's secondary panel: the pinned `Status` tab (and the `+`
-launcher) should show the app, and writing `data/state.json` should update it
-live with no reload.
-
----
-
-## Quick reference
-
-- Full feature docs: `bb guide app` (manifest, `window.bb`, data paths, icons,
- entry types, the `bb app` CLI).
-- Styling tokens for app HTML are documented there too (`bb guide styling`
- redirects to the app guide).
-- `bb app new ` scaffolds additional apps; `bb app rm ` removes one.
diff --git a/packages/config/package.json b/packages/config/package.json
index 3f53eb5a5..4e9770c48 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -97,6 +97,11 @@
"source": "./src/bb-app-managed-config.ts",
"types": "./src/bb-app-managed-config.ts",
"default": "./src/bb-app-managed-config.ts"
+ },
+ "./app-storage-paths": {
+ "source": "./src/app-storage-paths.ts",
+ "types": "./src/app-storage-paths.ts",
+ "default": "./src/app-storage-paths.ts"
}
},
"scripts": {
diff --git a/packages/config/src/app-storage-paths.ts b/packages/config/src/app-storage-paths.ts
new file mode 100644
index 000000000..232b9a5f2
--- /dev/null
+++ b/packages/config/src/app-storage-paths.ts
@@ -0,0 +1,41 @@
+import { join } from "node:path";
+import { applicationIdSchema, type ApplicationId } from "@bb/domain";
+
+export function resolveAppsRootPath(dataDir: string): string {
+ return join(dataDir, "apps");
+}
+
+export function resolveDataDirSkillsRootPath(dataDir: string): string {
+ return join(dataDir, "skills");
+}
+
+export function resolveApplicationPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(
+ resolveAppsRootPath(dataDir),
+ applicationIdSchema.parse(applicationId),
+ );
+}
+
+export function resolveApplicationManifestPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "manifest.json");
+}
+
+export function resolveApplicationPublicPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "public");
+}
+
+export function resolveApplicationDataPath(
+ dataDir: string,
+ applicationId: ApplicationId,
+): string {
+ return join(resolveApplicationPath(dataDir, applicationId), "data");
+}
diff --git a/packages/config/test/app-storage-paths.test.ts b/packages/config/test/app-storage-paths.test.ts
new file mode 100644
index 000000000..afb7b3740
--- /dev/null
+++ b/packages/config/test/app-storage-paths.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import {
+ resolveApplicationDataPath,
+ resolveApplicationPath,
+ resolveApplicationPublicPath,
+} from "../src/app-storage-paths.js";
+
+describe("app storage paths", () => {
+ it("uses validated application ids as path segments", () => {
+ expect(resolveApplicationPath("/tmp/bb-data", "review-board")).toBe(
+ "/tmp/bb-data/apps/review-board",
+ );
+ expect(resolveApplicationDataPath("/tmp/bb-data", "status")).toBe(
+ "/tmp/bb-data/apps/status/data",
+ );
+ expect(resolveApplicationPublicPath("/tmp/bb-data", "status")).toBe(
+ "/tmp/bb-data/apps/status/public",
+ );
+ });
+
+ it("rejects invalid application id path segments", () => {
+ expect(() => resolveApplicationPath("/tmp/bb-data", "a/b")).toThrow();
+ });
+});
diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts
index f606bcf63..90c8168db 100644
--- a/packages/config/test/config.test.ts
+++ b/packages/config/test/config.test.ts
@@ -23,6 +23,7 @@ import {
import { loadServerPortConfig } from "../src/server-port.js";
import { loadServerConfig } from "../src/server.js";
import { loadViteDevConfig } from "../src/vite-dev.js";
+import { resolveDataDirSkillsRootPath } from "../src/app-storage-paths.js";
async function importConfigModules(): Promise {
vi.resetModules();
@@ -199,6 +200,12 @@ describe("data-dir helpers", () => {
"/tmp/bb-data/bb.db",
);
});
+
+ it("derives the data-dir-level skills root from a resolved data dir", () => {
+ expect(resolveDataDirSkillsRootPath("/tmp/bb-data")).toBe(
+ "/tmp/bb-data/skills",
+ );
+ });
});
describe("port helpers", () => {
diff --git a/packages/domain/src/apps.ts b/packages/domain/src/apps.ts
index 5f72ec99a..25dd2b363 100644
--- a/packages/domain/src/apps.ts
+++ b/packages/domain/src/apps.ts
@@ -1,13 +1,36 @@
import { z } from "zod";
+export const APPLICATION_ID_MAX_LENGTH = 64;
+const APPLICATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
+const APPLICATION_NAME_SLUG_SEGMENT_PATTERN = /[a-z0-9]+/gu;
const APP_DATA_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]{1,80}$/u;
const APP_DATA_PATH_MAX_DEPTH = 8;
// Keep these app-data path limits in sync with the injected app client
// validator in apps/server/src/services/threads/app-client-script.ts.
const APP_DATA_PATH_MAX_LENGTH = 512;
-export const appIdSchema = z.string().regex(/^[A-Za-z0-9_-]+$/u);
-export type AppId = z.infer;
+export const applicationIdSchema = z
+ .string()
+ .min(1)
+ .max(APPLICATION_ID_MAX_LENGTH)
+ .regex(
+ APPLICATION_ID_PATTERN,
+ "Application id must be a lowercase slug containing only letters, numbers, and hyphens",
+ );
+export type ApplicationId = z.infer;
+
+export function deriveApplicationIdFromName(name: string): ApplicationId {
+ const normalizedName = name
+ .normalize("NFKD")
+ .toLowerCase()
+ .replace(/[\u0300-\u036f]/gu, "");
+ const segments = normalizedName.match(APPLICATION_NAME_SLUG_SEGMENT_PATTERN);
+ const slug = (segments ?? [])
+ .join("-")
+ .slice(0, APPLICATION_ID_MAX_LENGTH)
+ .replace(/-+$/u, "");
+ return applicationIdSchema.parse(slug);
+}
export const appDataPathSchema = z.string().superRefine((value, context) => {
if (
diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts
index c0e410d1b..bcba9a74e 100644
--- a/packages/domain/src/change-kinds.ts
+++ b/packages/domain/src/change-kinds.ts
@@ -7,6 +7,7 @@ export const REALTIME_ENTITIES = [
"environment",
"host",
"system",
+ "app",
] as const;
export type RealtimeEntity = (typeof REALTIME_ENTITIES)[number];
export const realtimeEntitySchema = z.enum(REALTIME_ENTITIES);
@@ -58,7 +59,7 @@ export const HOST_CHANGE_KINDS = [
] as const;
export type HostChangeKind = (typeof HOST_CHANGE_KINDS)[number];
-export const SYSTEM_CHANGE_KINDS = ["config-changed"] as const;
+export const SYSTEM_CHANGE_KINDS = ["config-changed", "apps-changed"] as const;
export type SystemChangeKind = (typeof SYSTEM_CHANGE_KINDS)[number];
export const subscribeMessageSchema = z.object({
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index 90b402e28..4df4078b2 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -59,8 +59,13 @@ export type {
export { managerTemplateNameSchema } from "./manager-templates.js";
export type { ManagerTemplateName } from "./manager-templates.js";
-export { appDataPathSchema, appIdSchema } from "./apps.js";
-export type { AppDataPath, AppId } from "./apps.js";
+export {
+ APPLICATION_ID_MAX_LENGTH,
+ appDataPathSchema,
+ applicationIdSchema,
+ deriveApplicationIdFromName,
+} from "./apps.js";
+export type { AppDataPath, ApplicationId } from "./apps.js";
export { threadDynamicContextFileStatusValues } from "./manager-dynamic-context.js";
export type { ThreadDynamicContextFileStatus } from "./manager-dynamic-context.js";
@@ -423,9 +428,7 @@ export {
providerTurnWatchdogReasonSchema,
providerTurnWatchdogReasonValues,
} from "./provider-turn-watchdog.js";
-export type {
- ProviderTurnWatchdogActivityEventType,
-} from "./provider-turn-watchdog.js";
+export type { ProviderTurnWatchdogActivityEventType } from "./provider-turn-watchdog.js";
export {
buildThreadEvent,
diff --git a/packages/domain/test/apps.test.ts b/packages/domain/test/apps.test.ts
new file mode 100644
index 000000000..4e59c0e44
--- /dev/null
+++ b/packages/domain/test/apps.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import {
+ applicationIdSchema,
+ deriveApplicationIdFromName,
+} from "../src/index.js";
+
+describe("application ids", () => {
+ it("accepts lowercase app slugs", () => {
+ expect(applicationIdSchema.safeParse("status").success).toBe(true);
+ expect(applicationIdSchema.safeParse("review-board").success).toBe(true);
+ });
+
+ it("rejects non-slug application ids", () => {
+ for (const value of ["Bad", "a/b", "..", "a.b", ""]) {
+ expect(applicationIdSchema.safeParse(value).success).toBe(false);
+ }
+ });
+
+ it("derives application ids from display names", () => {
+ expect(deriveApplicationIdFromName("Review Board")).toBe("review-board");
+ });
+});
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index 5b518c5b1..ecb6e40b0 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -17,6 +17,7 @@ import {
clientTurnRequestIdSchema,
gitBranchNameSchema,
jsonObjectSchema,
+ applicationIdSchema,
} from "@bb/domain";
import {
replayCaptureDaemonListResponseSchema,
@@ -24,12 +25,14 @@ import {
} from "@bb/replay-capture/schema";
import { z } from "zod";
-export const HOST_DAEMON_PROTOCOL_VERSION = 29 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 30 as const;
export const FILE_LIST_QUERY_MAX_LENGTH = 256;
export const FILE_LIST_LIMIT_MAX = 10_000;
export const BRANCH_LIST_QUERY_MAX_LENGTH = 256;
export const BRANCH_LIST_LIMIT_MAX = 1_000;
+const INJECTED_SKILL_NAME_PATTERN =
+ /^(?!.*--)[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u;
export const HOST_DAEMON_DURABLE_COMMAND_TYPES = [
"thread.start",
@@ -109,6 +112,42 @@ const hostDaemonProviderThreadTargetSchema = z.object({
threadId: z.string().min(1),
});
+export const hostDaemonInjectedSkillSourceBaseSchema = z
+ .object({
+ name: z.string().max(64).regex(INJECTED_SKILL_NAME_PATTERN),
+ description: z.string().min(1).max(1024),
+ sourceRootPath: z.string().min(1),
+ skillFilePath: z.string().min(1),
+ })
+ .strict();
+
+export const hostDaemonDataDirInjectedSkillSourceSchema =
+ hostDaemonInjectedSkillSourceBaseSchema
+ .extend({
+ sourceType: z.literal("data-dir"),
+ applicationId: z.null(),
+ })
+ .strict();
+
+export const hostDaemonGlobalAppInjectedSkillSourceSchema =
+ hostDaemonInjectedSkillSourceBaseSchema
+ .extend({
+ sourceType: z.literal("global-app"),
+ applicationId: applicationIdSchema,
+ })
+ .strict();
+
+export const hostDaemonInjectedSkillSourceSchema = z.discriminatedUnion(
+ "sourceType",
+ [
+ hostDaemonDataDirInjectedSkillSourceSchema,
+ hostDaemonGlobalAppInjectedSkillSourceSchema,
+ ],
+);
+export type HostDaemonInjectedSkillSource = z.infer<
+ typeof hostDaemonInjectedSkillSourceSchema
+>;
+
const hostDaemonThreadRuntimeContextSchema = z.object({
workspaceContext: workspaceContextSchema,
projectId: z.string().min(1),
@@ -116,6 +155,7 @@ const hostDaemonThreadRuntimeContextSchema = z.object({
options: hostDaemonExecutionOptionsSchema,
instructions: z.string().min(1),
dynamicTools: z.array(dynamicToolSchema),
+ injectedSkillSources: z.array(hostDaemonInjectedSkillSourceSchema),
disallowedTools: z.array(z.string()).optional(),
instructionMode: instructionModeSchema,
});
diff --git a/packages/host-daemon-contract/src/index.ts b/packages/host-daemon-contract/src/index.ts
index e7941f378..499a8d862 100644
--- a/packages/host-daemon-contract/src/index.ts
+++ b/packages/host-daemon-contract/src/index.ts
@@ -96,8 +96,12 @@ export {
hostDaemonCommandResultReportSchema,
hostDaemonCommandResultSchemaByType,
hostDaemonCommandSchema,
+ hostDaemonDataDirInjectedSkillSourceSchema,
hostDaemonDurableCommandTypeSchema,
hostDaemonExecutionOptionsSchema,
+ hostDaemonGlobalAppInjectedSkillSourceSchema,
+ hostDaemonInjectedSkillSourceBaseSchema,
+ hostDaemonInjectedSkillSourceSchema,
hostDaemonOnlineRpcCommandSchema,
hostDaemonOnlineRpcCommandTypeSchema,
hostDaemonOnlineRpcResultSchemaByType,
@@ -148,6 +152,7 @@ export type {
HostDaemonCommandResultReport,
HostDaemonCommandResultReportWithoutSession,
HostDaemonDurableCommandType,
+ HostDaemonInjectedSkillSource,
HostDaemonOnlineRpcCommand,
HostDaemonOnlineRpcCommandType,
HostDaemonOnlineRpcResult,
@@ -200,6 +205,7 @@ export {
hostDaemonSessionCloseReasonSchema,
hostDaemonSessionOpenRequestSchema,
hostDaemonSessionOpenResponseSchema,
+ hostDaemonTrackedApplicationDataTargetSchema,
hostDaemonTerminalOutputChunkSchema,
hostDaemonTrackedThreadTargetSchema,
hostDaemonToolCallRequestSchema,
@@ -238,6 +244,7 @@ export type {
HostDaemonSessionCloseReason,
HostDaemonSessionOpenRequest,
HostDaemonSessionOpenResponse,
+ HostDaemonTrackedApplicationDataTarget,
HostDaemonTrackedThreadTarget,
HostDaemonToolCallRequest,
HostDaemonToolCallResponse,
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index fb8542878..ecbbf5490 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -8,7 +8,7 @@ import {
pendingInteractionCreateSchema,
pendingInteractionStatusSchema,
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
terminalColsSchema,
terminalDataBase64Schema,
terminalRowsSchema,
@@ -55,6 +55,14 @@ export type HostDaemonTrackedThreadTarget = z.infer<
typeof hostDaemonTrackedThreadTargetSchema
>;
+export const hostDaemonTrackedApplicationDataTargetSchema = z.object({
+ applicationId: applicationIdSchema,
+ appDataPath: z.string().min(1),
+});
+export type HostDaemonTrackedApplicationDataTarget = z.infer<
+ typeof hostDaemonTrackedApplicationDataTargetSchema
+>;
+
export const hostDaemonSessionOpenRequestSchema = z.object({
hostId: z.string().min(1),
instanceId: z.string().min(1),
@@ -98,6 +106,9 @@ export const hostDaemonSessionOpenResponseSchema = z
heartbeatIntervalMs: z.number().int().positive(),
leaseTimeoutMs: z.number().int().positive(),
trackedThreadTargets: z.array(hostDaemonTrackedThreadTargetSchema),
+ trackedApplicationDataTargets: z.array(
+ hostDaemonTrackedApplicationDataTargetSchema,
+ ),
retiredEnvironmentIds: z.array(z.string().min(1)).default([]),
})
.strict();
@@ -213,8 +224,7 @@ export type HostDaemonEnvironmentChangePayload = z.infer<
const hostDaemonAppDataChangePayloadBaseSchema = z
.object({
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
path: appDataPathSchema,
value: jsonValueSchema.nullable(),
deleted: z.boolean(),
@@ -266,8 +276,7 @@ export type HostDaemonAppDataChangeRequest = z.infer<
export const hostDaemonAppDataResyncPayloadSchema = z
.object({
- threadId: z.string().min(1),
- appId: appIdSchema,
+ applicationId: applicationIdSchema,
})
.strict();
export type HostDaemonAppDataResyncPayload = z.infer<
@@ -517,6 +526,12 @@ const hostDaemonEnvironmentChangeMessageSchema =
})
.strict();
+const hostDaemonApplicationStorageChangedMessageSchema = z
+ .object({
+ type: z.literal("application-storage-changed"),
+ })
+ .strict();
+
const hostDaemonTerminalOpenedMessageSchema = z
.object({
type: z.literal("terminal.opened"),
@@ -571,6 +586,7 @@ const hostDaemonTerminalErrorMessageSchema = z
export const hostDaemonDaemonWsMessageSchema = z.union([
hostDaemonHeartbeatMessageSchema,
hostDaemonEnvironmentChangeMessageSchema,
+ hostDaemonApplicationStorageChangedMessageSchema,
hostDaemonTerminalOpenedMessageSchema,
hostDaemonTerminalOutputMessageSchema,
hostDaemonTerminalReplayMessageSchema,
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index 2231f010f..09f6d2d27 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -371,10 +371,9 @@ function expectHostRpcResponseRoundTrip(
hostDaemonOnlineRpcResponseMessageSchema.parse(jsonRoundTripped),
name,
).toEqual(message);
- expect(
- hostDaemonDaemonWsMessageSchema.parse(jsonRoundTripped),
- name,
- ).toEqual(message);
+ expect(hostDaemonDaemonWsMessageSchema.parse(jsonRoundTripped), name).toEqual(
+ message,
+ );
}
function terminalDataBase64(byteLength: number): string {
@@ -796,13 +795,13 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonOnlineRpcCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/demo/assets",
path: "logo.png",
dotfiles: "deny",
}),
).toMatchObject({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/demo/assets",
path: "logo.png",
dotfiles: "deny",
});
@@ -810,7 +809,7 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonCommandSchema.parse({
type: "host.write_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ rootPath: "/tmp/bb-data/apps/demo/data",
path: "state.json",
dotfiles: "deny",
content: "[1,2,3]\n",
@@ -824,7 +823,7 @@ describe("host-daemon command schemas", () => {
expect(
hostDaemonCommandSchema.parse({
type: "host.delete_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/data",
+ rootPath: "/tmp/bb-data/apps/demo/data",
path: "state.json",
dotfiles: "deny",
}),
@@ -1088,7 +1087,7 @@ describe("host-daemon command schemas", () => {
expect(() =>
hostDaemonOnlineRpcCommandSchema.parse({
type: "host.read_file_relative",
- rootPath: "/tmp/bb-data/thread-storage/thread-123/apps/status/assets",
+ rootPath: "/tmp/bb-data/apps/demo/assets",
path: "logo.png",
}),
).toThrow();
@@ -1114,6 +1113,7 @@ describe("host-daemon command schemas", () => {
},
instructions: "Be concise.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
requestId: CLIENT_REQUEST_ID,
input: [{ type: "text", text: "hello" }],
@@ -1143,6 +1143,7 @@ describe("host-daemon command schemas", () => {
providerThreadId: "prov_123",
instructions: "Be concise.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1179,6 +1180,7 @@ describe("host-daemon command schemas", () => {
inputSchema: { type: "object" },
},
],
+ injectedSkillSources: [],
instructionMode: "replace",
}),
).toMatchObject({
@@ -1241,6 +1243,7 @@ describe("host-daemon command schemas", () => {
providerThreadId: "provider_123",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1281,6 +1284,7 @@ describe("host-daemon command schemas", () => {
providerThreadId: "provider_123",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "auto", expectedTurnId: "turn_123" },
@@ -1368,6 +1372,7 @@ describe("host-daemon command schemas", () => {
},
instructions: "Be concise.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
}),
).toThrow();
@@ -1397,6 +1402,7 @@ describe("host-daemon command schemas", () => {
providerThreadId: "provider_123",
instructions: "Be a helpful coding agent.",
dynamicTools: [],
+ injectedSkillSources: [],
instructionMode: "append",
},
target: { mode: "start" },
@@ -1909,6 +1915,7 @@ describe("host-daemon session schemas", () => {
threadId: "thr_123",
},
],
+ trackedApplicationDataTargets: [],
}),
).toMatchObject({
sessionId: "session_123",
@@ -2098,11 +2105,18 @@ describe("host-daemon session schemas", () => {
change: "thread-storage-changed",
});
+ expect(
+ hostDaemonDaemonWsMessageSchema.parse({
+ type: "application-storage-changed",
+ }),
+ ).toEqual({
+ type: "application-storage-changed",
+ });
+
expect(
contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -2110,8 +2124,7 @@ describe("host-daemon session schemas", () => {
}),
).toEqual({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -2121,8 +2134,7 @@ describe("host-daemon session schemas", () => {
expect(
contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: null,
deleted: true,
@@ -2136,8 +2148,7 @@ describe("host-daemon session schemas", () => {
expect(() =>
contract.hostDaemonAppDataChangeRequestSchema.parse({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
path: "state.json",
value: { workers: [] },
deleted: false,
@@ -2148,13 +2159,11 @@ describe("host-daemon session schemas", () => {
expect(
contract.hostDaemonAppDataResyncRequestSchema.parse({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
}),
).toEqual({
sessionId: "session_123",
- threadId: "thr_123",
- appId: "status",
+ applicationId: "status",
});
expect(
diff --git a/packages/host-watcher/src/host-watcher-types.ts b/packages/host-watcher/src/host-watcher-types.ts
index f94b4ad0c..1daa236f6 100644
--- a/packages/host-watcher/src/host-watcher-types.ts
+++ b/packages/host-watcher/src/host-watcher-types.ts
@@ -1,5 +1,5 @@
import type { HostType } from "@bb/domain";
-import type { AppDataPath, AppId } from "@bb/domain";
+import type { AppDataPath, ApplicationId } from "@bb/domain";
import type { WorkspaceStatusWatchChangeKind } from "./watch-status-types.js";
export type HostObservedChange =
@@ -15,17 +15,23 @@ export type HostObservedChange =
threadId: string;
}
| {
- kind: "thread-app-data-changed";
- appId: AppId;
- environmentId: string;
+ kind: "application-storage-targets-changed";
+ }
+ | {
+ kind: "application-data-changed";
+ applicationId: ApplicationId;
+ appDataPath: string;
path: AppDataPath;
- threadId: string;
}
| {
- kind: "thread-app-data-resync";
- appId: AppId;
- environmentId: string;
- threadId: string;
+ kind: "application-data-resync";
+ applicationId: ApplicationId;
+ }
+ | {
+ kind: "injected-skills-changed";
+ applicationId: ApplicationId | null;
+ changedPaths: string[];
+ sourceType: "data-dir" | "global-app";
};
export type WorkspaceObservedChange = Extract<
@@ -34,12 +40,27 @@ export type WorkspaceObservedChange = Extract<
>;
export type ThreadStorageObservedChange = Extract<
+ HostObservedChange,
+ {
+ kind: "thread-storage-changed";
+ }
+>;
+
+export type ApplicationStorageObservedChange = Extract<
HostObservedChange,
{
kind:
- | "thread-storage-changed"
- | "thread-app-data-changed"
- | "thread-app-data-resync";
+ | "application-storage-targets-changed"
+ | "application-data-changed"
+ | "application-data-resync"
+ | "injected-skills-changed";
+ }
+>;
+
+export type InjectedSkillsObservedChange = Extract<
+ HostObservedChange,
+ {
+ kind: "injected-skills-changed";
}
>;
@@ -58,7 +79,23 @@ export interface ThreadStorageWatchError {
threadId?: string;
}
-export type HostWatchError = WorkspaceWatchError | ThreadStorageWatchError;
+export interface ApplicationStorageWatchError {
+ kind: "application-storage-watch-error";
+ rootPath: string;
+ message: string;
+}
+
+export interface DataDirSkillsWatchError {
+ kind: "data-dir-skills-watch-error";
+ rootPath: string;
+ message: string;
+}
+
+export type HostWatchError =
+ | WorkspaceWatchError
+ | ThreadStorageWatchError
+ | ApplicationStorageWatchError
+ | DataDirSkillsWatchError;
export interface ThreadStorageWatchTarget {
environmentId: string;
@@ -79,11 +116,37 @@ export interface WatchThreadStorageRootArgs {
onWatchError: (error: ThreadStorageWatchError) => void;
}
+export interface ApplicationDataWatchTarget {
+ applicationId: ApplicationId;
+ appDataPath: string;
+}
+
+export interface WatchApplicationStorageRootArgs {
+ appsRootPath: string;
+ resolveApplicationTarget: (
+ applicationId: ApplicationId,
+ ) => ApplicationDataWatchTarget | null;
+ onChange: (event: ApplicationStorageObservedChange) => void;
+ onWatchError: (error: ApplicationStorageWatchError) => void;
+}
+
+export interface WatchDataDirSkillsRootArgs {
+ dataDirSkillsRootPath: string;
+ onChange: (event: InjectedSkillsObservedChange) => void;
+ onWatchError: (error: DataDirSkillsWatchError) => void;
+}
+
export interface HostWatcher {
watchWorkspace(args: WatchWorkspaceArgs): () => void | Promise;
watchThreadStorageRoot(
args: WatchThreadStorageRootArgs,
): () => void | Promise;
+ watchApplicationStorageRoot(
+ args: WatchApplicationStorageRootArgs,
+ ): () => void | Promise;
+ watchDataDirSkillsRoot?(
+ args: WatchDataDirSkillsRootArgs,
+ ): () => void | Promise;
}
export interface CreateHostWatcherArgs {
diff --git a/packages/host-watcher/src/index.ts b/packages/host-watcher/src/index.ts
index 2eaa0f5bb..e9e1627bd 100644
--- a/packages/host-watcher/src/index.ts
+++ b/packages/host-watcher/src/index.ts
@@ -8,8 +8,15 @@ export type {
HostObservedChange,
HostWatchError,
HostWatcher,
+ ApplicationDataWatchTarget,
+ ApplicationStorageObservedChange,
+ ApplicationStorageWatchError,
+ DataDirSkillsWatchError,
ThreadStorageWatchError,
ThreadStorageWatchTarget,
+ InjectedSkillsObservedChange,
+ WatchApplicationStorageRootArgs,
+ WatchDataDirSkillsRootArgs,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
WorkspaceWatchError,
diff --git a/packages/host-watcher/src/parcel-host-watcher.ts b/packages/host-watcher/src/parcel-host-watcher.ts
index 26988d2f2..ac1899594 100644
--- a/packages/host-watcher/src/parcel-host-watcher.ts
+++ b/packages/host-watcher/src/parcel-host-watcher.ts
@@ -1,16 +1,21 @@
import path from "node:path";
import {
appDataPathSchema,
- appIdSchema,
+ applicationIdSchema,
type AppDataPath,
- type AppId,
+ type ApplicationId,
} from "@bb/domain";
import { watchPathChanges } from "./watch-path.js";
import { watchWorkspaceStatus } from "./watch-status.js";
import type {
HostWatcher,
+ ApplicationStorageObservedChange,
+ ApplicationDataWatchTarget,
+ InjectedSkillsObservedChange,
ThreadStorageObservedChange,
ThreadStorageWatchTarget,
+ WatchApplicationStorageRootArgs,
+ WatchDataDirSkillsRootArgs,
WatchThreadStorageRootArgs,
WatchWorkspaceArgs,
} from "./host-watcher-types.js";
@@ -25,15 +30,28 @@ interface ThreadStoragePath {
threadId: string;
}
-interface ThreadAppDataPath {
- appId: AppId;
+interface ApplicationStoragePathArgs {
+ appsRootPath: string;
+ changedPath: string;
+}
+
+interface ApplicationStoragePath {
+ applicationId: ApplicationId | null;
+ parts: string[];
+}
+
+interface ApplicationDataPath {
+ applicationId: ApplicationId;
path: AppDataPath;
- threadId: string;
}
-interface ThreadAppDataRootPath {
- appId: AppId;
- threadId: string;
+interface ApplicationDataRootPath {
+ applicationId: ApplicationId;
+}
+
+interface DataDirSkillsPathArgs {
+ changedPath: string;
+ dataDirSkillsRootPath: string;
}
interface CollectThreadStorageObservedChangesArgs {
@@ -42,6 +60,19 @@ interface CollectThreadStorageObservedChangesArgs {
resolveThreadTarget: (threadId: string) => ThreadStorageWatchTarget | null;
}
+interface CollectApplicationStorageObservedChangesArgs {
+ appsRootPath: string;
+ changedPaths: string[];
+ resolveApplicationTarget: (
+ applicationId: ApplicationId,
+ ) => ApplicationDataWatchTarget | null;
+}
+
+interface CollectDataDirSkillsObservedChangesArgs {
+ changedPaths: string[];
+ dataDirSkillsRootPath: string;
+}
+
function toThreadStoragePath(
args: ThreadStoragePathArgs,
): ThreadStoragePath | null {
@@ -67,41 +98,91 @@ function toThreadStoragePath(
};
}
-function isAppDataSubtreePath(path: ThreadStoragePath): boolean {
- return path.parts[1] === "apps" && path.parts[3] === "data";
+function toApplicationStoragePath(
+ args: ApplicationStoragePathArgs,
+): ApplicationStoragePath | null {
+ const relativePath = path.relative(args.appsRootPath, args.changedPath);
+ if (
+ relativePath.length === 0 ||
+ relativePath.startsWith("..") ||
+ path.isAbsolute(relativePath)
+ ) {
+ return null;
+ }
+ const parts = relativePath.split(path.sep).filter(Boolean);
+ const rawApplicationId = parts[0];
+ if (!rawApplicationId) {
+ return {
+ applicationId: null,
+ parts,
+ };
+ }
+ const parsed = applicationIdSchema.safeParse(rawApplicationId);
+ return {
+ applicationId: parsed.success ? parsed.data : null,
+ parts,
+ };
+}
+
+function isApplicationDataSubtreePath(path: ApplicationStoragePath): boolean {
+ return path.parts[1] === "data";
+}
+
+function isApplicationSkillsSubtreePath(path: ApplicationStoragePath): boolean {
+ return path.parts[1] === "skills";
+}
+
+function isApplicationManifestPath(path: ApplicationStoragePath): boolean {
+ return path.parts.length === 2 && path.parts[1] === "manifest.json";
+}
+
+function isApplicationFolderPath(path: ApplicationStoragePath): boolean {
+ return path.parts.length === 1;
}
-function toThreadAppDataPath(
- path: ThreadStoragePath,
-): ThreadAppDataPath | null {
- const rootPath = toThreadAppDataRootPath(path);
- if (!rootPath || path.parts.length < 5) {
+function isIgnoredApplicationStorageEntry(path: ApplicationStoragePath): boolean {
+ const firstPart = path.parts[0] ?? "";
+ return (
+ firstPart.startsWith(".tmp-app_") || firstPart.startsWith(".delete-app_")
+ );
+}
+
+function toApplicationDataPath(
+ path: ApplicationStoragePath,
+): ApplicationDataPath | null {
+ const rootPath = toApplicationDataRootPath(path);
+ if (!rootPath || path.parts.length < 3) {
return null;
}
- const dataPath = appDataPathSchema.safeParse(path.parts.slice(4).join("/"));
+ const dataPath = appDataPathSchema.safeParse(path.parts.slice(2).join("/"));
if (!dataPath.success) {
return null;
}
return {
- appId: rootPath.appId,
- threadId: path.threadId,
+ applicationId: rootPath.applicationId,
path: dataPath.data,
};
}
-function toThreadAppDataRootPath(
- path: ThreadStoragePath,
-): ThreadAppDataRootPath | null {
- if (!isAppDataSubtreePath(path)) {
- return null;
- }
- const appId = appIdSchema.safeParse(path.parts[2]);
- if (!appId.success) {
+function isDataDirSkillsPath(args: DataDirSkillsPathArgs): boolean {
+ const relativePath = path.relative(
+ args.dataDirSkillsRootPath,
+ args.changedPath,
+ );
+ return (
+ relativePath.length === 0 ||
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
+ );
+}
+
+function toApplicationDataRootPath(
+ path: ApplicationStoragePath,
+): ApplicationDataRootPath | null {
+ if (!path.applicationId || !isApplicationDataSubtreePath(path)) {
return null;
}
return {
- appId: appId.data,
- threadId: path.threadId,
+ applicationId: path.applicationId,
};
}
@@ -109,8 +190,6 @@ export function collectThreadStorageObservedChanges(
args: CollectThreadStorageObservedChangesArgs,
): ThreadStorageObservedChange[] {
const storageChanges = new Map();
- const appDataChanges = new Map