Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Claude Code internal config (developer tooling, not part of OSS repo)
.claude
.environments/collateral-test/

# git worktrees (local dev only — never commit worktree checkouts)
.worktrees/
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ bun run format # Biome auto-format (writes)

cd web && bun install # Web client dependencies (separate package.json)
cd web && bun run build # Web production build → web/dist/

bun run build:bundles # Rebuild every src/bundles/*/ui (vite single-file)
```

**`bun run dev` does NOT rebuild bundles.** The API serves each bundle from its pre-built `src/bundles/<name>/ui/dist/index.html`. After editing any file under `src/bundles/*/ui/src/`, run `bun run build:bundles` and restart the dev server (the API reads dist on iframe mount; it doesn't watch the file). Forgetting this means the iframe loads stale code while your changes look "live" in the source tree — a high-confusion failure mode.

**Before opening a PR, run `bun run verify`.** It is the single command that mirrors CI, enforced by construction: `.github/workflows/ci.yml` invokes only `verify:*` subscripts (plus `test:integration` and `smoke`) — no inline check steps. To add or change a check, edit the matching subscript in `package.json`; CI picks it up automatically. If CI ever catches something `verify` didn't, the fix is to update the subscript, not the checklist. Tool-level parity is the gate; discipline-level rules are not.

## Conventions
Expand Down
4 changes: 2 additions & 2 deletions src/bundles/home/ui/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/bundles/home/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@nimblebrain/synapse": "^0.4.0",
"@nimblebrain/synapse": "^0.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
28 changes: 25 additions & 3 deletions src/bundles/home/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { SynapseProvider, useDataSync, useSynapse } from "@nimblebrain/synapse/react";
import {
SynapseProvider,
useDataSync,
useHostContext,
useSynapse,
} from "@nimblebrain/synapse/react";
import { useCallback, useEffect, useState } from "react";

/* ---------- types ---------- */
Expand Down Expand Up @@ -175,6 +180,13 @@ function SectionGroup({

function Dashboard() {
const synapse = useSynapse();
// The host publishes the active workspace as `hostContext.workspace` on
// every workspace switch. Keying the briefing fetch on `workspace.id`
// refetches the (workspace-scoped) briefing without remounting this iframe.
// Narrow to both id and name even though only id drives refetch — keeps
// future briefing copy ("Switched to Acme") cheap to wire up.
const { workspace } = useHostContext<{ workspace?: { id: string; name: string } }>();
const workspaceId = workspace?.id;
const [briefing, setBriefing] = useState<BriefingOutput | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
Expand Down Expand Up @@ -202,10 +214,20 @@ function Dashboard() {
}
}, []);

// Load once on mount
// Reload whenever the active workspace lands or changes. The host bridge
// sends `host-context-changed` with the new workspace id; the SDK exposes
// it via `useHostContext`. `loadBriefing` is stable so it isn't a dep —
// `workspaceId` is the only meaningful trigger.
//
// Skip the first render where `workspaceId` is undefined: the
// `useHostContext` value lands on the next render after the handshake
// resolves. Without the guard we'd fire one wasted briefing fetch in the
// handshake-window, then immediately fire again with the real id.
// biome-ignore lint/correctness/useExhaustiveDependencies: workspaceId is the refetch trigger; loadBriefing is stable
useEffect(() => {
if (!workspaceId) return;
loadBriefing();
}, [loadBriefing]);
}, [workspaceId]);

// Show refresh banner on data changes
useDataSync(() => {
Expand Down
128 changes: 128 additions & 0 deletions test/unit/bridge-extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,131 @@ describe("Bridge — unknown message types", () => {
handle.destroy();
});
});

// ---------------------------------------------------------------------------
// ui/initialize hostContext extensions
// ---------------------------------------------------------------------------

describe("Bridge — ui/initialize hostContext extensions", () => {
/** Find the response posted in reply to a `ui/initialize` request. */
function findInitResponse(posted: unknown[]) {
return posted.find(
(m) =>
m &&
typeof m === "object" &&
(m as Record<string, unknown>).id === "init-1" &&
typeof (m as Record<string, unknown>).result === "object",
) as { result: { hostContext: Record<string, unknown> } } | undefined;
}

it("merges getHostExtensions() into hostContext alongside spec fields", () => {
const { iframe, posted } = makeFakeIframe();
const handle = createBridge(iframe, "test-app", {
getHostExtensions: () => ({ workspace: { id: "ws_a", name: "Alpha" } }),
});

simulatePostMessage(iframe, {
jsonrpc: "2.0",
id: "init-1",
method: "ui/initialize",
params: { protocolVersion: "2026-01-26", appInfo: { name: "x", version: "1" } },
});

const response = findInitResponse(posted);
expect(response?.result.hostContext).toMatchObject({
workspace: { id: "ws_a", name: "Alpha" },
theme: expect.anything(),
styles: expect.anything(),
});
handle.destroy();
});

it("invokes getHostExtensions() exactly once per ui/initialize", () => {
const { iframe } = makeFakeIframe();
let calls = 0;
const handle = createBridge(iframe, "test-app", {
getHostExtensions: () => {
calls++;
return { workspace: { id: "ws_a", name: "Alpha" } };
},
});

simulatePostMessage(iframe, {
jsonrpc: "2.0",
id: "init-1",
method: "ui/initialize",
params: { protocolVersion: "2026-01-26", appInfo: { name: "x", version: "1" } },
});

expect(calls).toBe(1);
handle.destroy();
});

it("spec fields (theme, styles) win over same-named extension keys", () => {
const { iframe, posted } = makeFakeIframe();
const handle = createBridge(iframe, "test-app", {
getHostExtensions: () => ({
// Adversarial caller tries to override a spec field — bridge must ignore.
theme: "WRONG",
styles: { variables: { "--evil": "true" } },
workspace: { id: "ws_a", name: "Alpha" },
}),
});

simulatePostMessage(iframe, {
jsonrpc: "2.0",
id: "init-1",
method: "ui/initialize",
params: { protocolVersion: "2026-01-26", appInfo: { name: "x", version: "1" } },
});

const response = findInitResponse(posted);
const ctx = response?.result.hostContext as Record<string, unknown>;
expect(ctx.theme).not.toBe("WRONG");
expect((ctx.styles as Record<string, unknown>).variables).not.toMatchObject({ "--evil": "true" });
expect(ctx.workspace).toEqual({ id: "ws_a", name: "Alpha" });
handle.destroy();
});

it("missing getHostExtensions yields a hostContext with no extensions", () => {
const { iframe, posted } = makeFakeIframe();
const handle = createBridge(iframe, "test-app");

simulatePostMessage(iframe, {
jsonrpc: "2.0",
id: "init-1",
method: "ui/initialize",
params: { protocolVersion: "2026-01-26", appInfo: { name: "x", version: "1" } },
});

const response = findInitResponse(posted);
const ctx = response?.result.hostContext as Record<string, unknown>;
expect(ctx).toMatchObject({ theme: expect.anything(), styles: expect.anything() });
expect(ctx.workspace).toBeUndefined();
handle.destroy();
});

it("a throwing getHostExtensions does not drop the ui/initialize response", () => {
const { iframe, posted } = makeFakeIframe();
const handle = createBridge(iframe, "test-app", {
getHostExtensions: () => {
throw new Error("boom");
},
});

simulatePostMessage(iframe, {
jsonrpc: "2.0",
id: "init-1",
method: "ui/initialize",
params: { protocolVersion: "2026-01-26", appInfo: { name: "x", version: "1" } },
});

const response = findInitResponse(posted);
expect(response).toBeDefined();
const ctx = response?.result.hostContext as Record<string, unknown>;
// Spec fields still present; extensions silently dropped on throw.
expect(ctx).toMatchObject({ theme: expect.anything(), styles: expect.anything() });
expect(ctx.workspace).toBeUndefined();
handle.destroy();
});
});
20 changes: 20 additions & 0 deletions web/src/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@ export function createBridge(
// NB extensions and out-of-spec tokens still flow through the iframe's
// injected `<style>` block — they just don't cross the protocol.
const extTokens = getSpecThemeTokens(extMode);
// Spec-standardized fields (theme, styles) take precedence over any
// same-named keys returned by `getHostExtensions()`, so callers can
// safely return arbitrary extension keys without colliding.
//
// Top-level extension keys (e.g. `workspace`) are spec-allowed: the
// ext-apps `McpUiHostContextSchema` is `.passthrough()`, so strict
// SDK clients (Reboot/Zod) preserve unknown keys at the hostContext
// root. The strict-key concern documented above applies only to
// `hostContext.styles.variables`, which is a typed enum of CSS
// custom properties — extensions there would tear down the connection.
//
// Wrapped in try/catch: a throwing callback would otherwise drop the
// entire `ui/initialize` response and hang the iframe at "Connecting…".
let extensions: Record<string, unknown> = {};
try {
extensions = callbacks?.getHostExtensions?.() ?? {};
} catch (err) {
console.error("getHostExtensions threw — proceeding with no extensions:", err);
}
const response: ExtAppsInitializeResponse = {
jsonrpc: "2.0",
id: msg.id,
Expand All @@ -172,6 +191,7 @@ export function createBridge(
logging: {},
},
hostContext: {
...extensions,
theme: extMode,
styles: {
variables: extTokens,
Expand Down
46 changes: 46 additions & 0 deletions web/src/bridge/host-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// ---------------------------------------------------------------------------
// Host-context extension builders
//
// NimbleBrain-specific keys we publish into the ext-apps `hostContext` bag.
// The bridge stays workspace-agnostic; this module owns what extensions get
// surfaced to apps. Used by both `SlotRenderer` (placement iframes) and
// `InlineAppView` (inline tool-result iframes) so the host-context payload
// is consistent across mount points — apps that read
// `useHostContext().workspace` see the same value regardless of how the
// iframe was mounted.
//
// Spec-standardized fields (`theme`, `styles`) are NOT defined here. The
// bridge merges them in itself and they always win over same-named keys
// returned from `buildHostExtensions`, so this layer only ever owns the
// non-spec keys.
// ---------------------------------------------------------------------------

import { getThemeTokens } from "./theme";

export type WorkspaceForHostContext = { id: string; name: string } | null;

/**
* Non-spec extension keys to merge into the `ui/initialize` hostContext
* response. Bridge merges these alongside theme/styles; spec fields win
* on key collisions.
*/
export function buildHostExtensions(workspace: WorkspaceForHostContext): Record<string, unknown> {
return workspace ? { workspace: { id: workspace.id, name: workspace.name } } : {};
}

/**
* Full hostContext payload for `host-context-changed` notifications. Spec
* fields (`theme`, `styles`) plus extensions, in one shot. Spread order
* means extensions are written first; spec fields override on collision.
*/
export function buildHostContext(
mode: "light" | "dark",
workspace: WorkspaceForHostContext,
): Record<string, unknown> {
const tokens = getThemeTokens(mode);
return {
...buildHostExtensions(workspace),
theme: mode,
styles: { variables: tokens },
};
}
11 changes: 11 additions & 0 deletions web/src/bridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,15 @@ export interface BridgeCallbacks {
onAction?: (action: string, params: Record<string, unknown>) => void;
/** Called when the iframe confirms handshake complete. */
onInitialized?: () => void;
/**
* Provide NimbleBrain-specific extensions to merge into the ext-apps
* `hostContext` at handshake time (e.g. `{ workspace: { id, name } }`).
* Called once per `ui/initialize` request, so it can read live state at
* the moment the iframe finishes loading.
*
* The bridge stays workspace-agnostic; the caller owns what extensions to
* publish. Spec-standardized fields (`theme`, `styles`) are always set by
* the bridge and override any same-named keys returned here.
*/
getHostExtensions?: () => Record<string, unknown>;
}
11 changes: 11 additions & 0 deletions web/src/components/InlineAppView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { useEffect, useRef, useState } from "react";
import { getResources, uiPathFromUri } from "../api/client";
import type { BridgeHandle } from "../bridge/bridge";
import { createBridge } from "../bridge/bridge";
import { buildHostExtensions } from "../bridge/host-extensions";
import { createAppIframe } from "../bridge/iframe";
import { useWorkspaceContext } from "../context/WorkspaceContext";

import type { ToolResultForUI } from "../hooks/useChat";

Expand Down Expand Up @@ -31,6 +33,14 @@ export function InlineAppView({ appName, resourceUri, toolResult }: InlineAppVie
// re-renders with a new object reference (e.g., during streaming text deltas).
const toolResultRef = useRef(toolResult);
toolResultRef.current = toolResult;
// Mirror SlotRenderer: publish workspace into hostContext so apps mounted
// here see the same `useHostContext().workspace` value as in placements.
// Inline previews don't push host-context-changed (they're scoped to a
// single tool result, no workspace switching mid-life), so the handshake
// is the only delivery point.
const { activeWorkspace } = useWorkspaceContext();
const workspaceRef = useRef(activeWorkspace);
workspaceRef.current = activeWorkspace;

const [height, setHeight] = useState(DEFAULT_HEIGHT);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -88,6 +98,7 @@ export function InlineAppView({ appName, resourceUri, toolResult }: InlineAppVie
bridge.sendToolResult(tr.result);
}
},
getHostExtensions: () => buildHostExtensions(workspaceRef.current),
});
bridgeRef.current = bridge;

Expand Down
Loading