From 67be987e873f1270eb3c014d97c0b09bb69c77e0 Mon Sep 17 00:00:00 2001
From: Oz Sayag
Date: Wed, 16 Sep 2026 14:33:25 +0300
Subject: [PATCH 1/4] Use builder sockets for minimal white-label chat updates
---
examples/white-label-minimal/README.md | 54 +++-
.../app/api/base44/socket-token/route.ts | 5 +
.../components/Builder.tsx | 26 +-
.../components/useBuildPolling.ts | 71 -----
.../components/useBuilderSocket.ts | 31 ++
.../white-label-minimal/lib/base44/client.ts | 3 +
.../lib/chat/build-stream.ts | 112 +++++++
.../lib/chat/builder-api.ts | 9 +-
.../lib/chat/builder-connection.ts | 20 ++
.../lib/chat/socket-messages.ts | 27 ++
.../lib/server/api-handler.ts | 7 +-
examples/white-label-minimal/lib/types.ts | 3 +-
examples/white-label-minimal/package.json | 7 +-
.../white-label-minimal/tests/browser.spec.ts | 101 +++++--
.../tests/build-stream.test.ts | 77 +++++
.../white-label-minimal/tests/server.test.ts | 16 +
package-lock.json | 278 +++++++++++++++++-
17 files changed, 710 insertions(+), 137 deletions(-)
create mode 100644 examples/white-label-minimal/app/api/base44/socket-token/route.ts
delete mode 100644 examples/white-label-minimal/components/useBuildPolling.ts
create mode 100644 examples/white-label-minimal/components/useBuilderSocket.ts
create mode 100644 examples/white-label-minimal/lib/chat/build-stream.ts
create mode 100644 examples/white-label-minimal/lib/chat/builder-connection.ts
create mode 100644 examples/white-label-minimal/lib/chat/socket-messages.ts
create mode 100644 examples/white-label-minimal/tests/build-stream.test.ts
diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md
index c0031b9..f5a8eeb 100644
--- a/examples/white-label-minimal/README.md
+++ b/examples/white-label-minimal/README.md
@@ -6,14 +6,15 @@ chat with the builder, answer its questions, preview, and publish.
This example uses the [service-user tenancy model](https://base44-docs-white-label-rewrite.mintlify.site/white-label/tenancy-and-credentials#service-users).
Each builder gets a Base44 service user that owns their apps. Your backend provisions
that identity using a workspace API key, then uses the service user's access token
-to create and manage apps. Both credentials stay on the server.
+to create and manage apps. The API key stays on the server. For this socket integration, the current service-user
+access token is sent to the signed-in browser after an app-ownership check.
## Run locally
Use Node.js 24. From the repository root:
```sh
-npm install
+npm ci
cp examples/white-label-minimal/.env.example examples/white-label-minimal/.env.local
```
@@ -67,12 +68,12 @@ on install and build; run `npm run db:generate` from the example after schema ed
Google login and Base44 connection are separate. `/api/base44/connection`
provisions a service principal using the workspace key. Builder requests use its
-stored token; they do not provision identities or send credentials to the browser.
+stored token; they do not provision identities during ordinary app operations.
The API handler validates requests and checks app ownership before app operations.
For the chat UI, copy `components/`, `lib/chat/builder-api.ts`, `lib/chat/conversation.ts`,
and `lib/chat/assistant-messages.ts`. The chat uses assistant-ui's external-store runtime
-with Base44's polled conversation as its source of truth. `Question.tsx` handles
+with an initial conversation snapshot followed by SDK builder socket updates. `Question.tsx` handles
approvals, choices, and secrets; retries preserve the original answer and request ID.
Preview URLs stay in page memory and remain stable during normal use. A timed-out
creation may still succeed, so the UI asks users to check before creating again.
@@ -132,3 +133,48 @@ build-status, runtime-auth, or heartbeat endpoints are used.
`loadPreview(appId)` calls your authenticated backend and returns `{ url }`. Reject with an error carrying `status: 401` or `403` to stop automatic recovery on authorization failures. Keep platform credentials on the backend. Changing the app or live mode starts a new preview session; changing the callback does not reload the iframe.
When copying the component, include the `preview-frame`, `preview-loading-frame`, `preview-fallback`, `preview-controls`, `widget-placeholder`, and `secondary` styles from `app/globals.css`, or provide equivalent styles and a sized parent container.
+## Live builder updates
+
+The example pins the preview of [SDK PR #286](https://github.com/base44/javascript-sdk/pull/286)
+under the `@base44/sdk` alias. Update that exact version when adopting a released SDK.
+
+```text
+Builder.tsx → useBuilderSocket.ts → lib/chat/build-stream.ts
+ → lib/chat/builder-connection.ts → @base44/sdk/platform/client
+```
+
+`builder-connection.ts` constructs the platform client and calls `client.builder.init`.
+Its `refreshToken` callback calls the same-origin `POST /api/base44/socket-token`
+endpoint on each reconnect. The endpoint requires a valid session, verifies the
+request origin and app ownership, refreshes the existing server credential if
+needed, and returns `{ serverUrl, token }` with `Cache-Control: no-store, private`.
+The browser keeps the token in memory and supplies it only through Socket.IO
+CONNECT `auth.token`. API keys and refresh tokens never go to the browser.
+
+**Temporary credential decision:** this uses the existing service-user access token,
+as requested, until the browser-specific token solution is available. This token
+can authorize HTTP operations too; the read-only socket does not narrow its powers.
+The app-ownership check protects token retrieval but does not make the token itself
+app-scoped. Replace this exchange when the dedicated browser credential lands.
+The backend must accept this credential at `/ws-whitelabel/socket.io/` and enable
+the workspace's white-label socket flag. The pending verifier in the backend PR
+still blocks deployed connections until integrated; there is no legacy-socket fallback.
+
+The client subscribes before fetching initial history. The SDK buffers ordered
+updates while that snapshot loads and resumes from applied cursors after transport
+reconnects. App/message replacements and image resolutions are applied directly.
+There are no periodic conversation or app reads. Invalidation events and completed
+HTTP mutations trigger reconciliation; ready-state updates refresh preview metadata.
+Queue/task events advance the cursor but have no separate UI in this minimal example.
+
+Question forms are not in the public socket payload. When a tool waits for input,
+the example reads its existing conversation projection through the partner backend
+and continues using the existing approval/choice/secret UI. HTTP mutations still
+use the partner backend. Those HTTP projections retain their current contract;
+this change does not introduce a new public question schema.
+
+When retained history expires or an event cannot be applied, delivery stops and
+**Reconnect live updates** starts a new session and snapshot. Snapshot recovery is
+not an atomic history API; buffered events may briefly repeat newer snapshot state.
+Switching apps/unmounting cancels reads and closes the builder session. Production
+verification with the real token verifier remains a prerequisite for rollout.
diff --git a/examples/white-label-minimal/app/api/base44/socket-token/route.ts b/examples/white-label-minimal/app/api/base44/socket-token/route.ts
new file mode 100644
index 0000000..430eabb
--- /dev/null
+++ b/examples/white-label-minimal/app/api/base44/socket-token/route.ts
@@ -0,0 +1,5 @@
+import { createHandler } from "../../../../lib/server/api-handler";
+import { getAppClient } from "../../../../lib/server/app-service";
+
+export const runtime = "nodejs";
+export const POST = createHandler(getAppClient, ["getBuilderConnection"]);
diff --git a/examples/white-label-minimal/components/Builder.tsx b/examples/white-label-minimal/components/Builder.tsx
index 640d1c0..debb3dd 100644
--- a/examples/white-label-minimal/components/Builder.tsx
+++ b/examples/white-label-minimal/components/Builder.tsx
@@ -6,7 +6,7 @@ import * as api from "../lib/chat/builder-api";
import { Loader2, Eye, Upload, ExternalLink } from "lucide-react";
import { mergeOptimisticMessages, type OptimisticMessage } from "../lib/chat/optimistic-messages";
import BuilderChat from "./BuilderChat";
-import { useBuildPolling } from "./useBuildPolling";
+import { useBuilderSocket } from "./useBuilderSocket";
export default function Builder({
initialAppId,
@@ -27,7 +27,7 @@ export default function Builder({
const [published, setPublished] = useState(null);
const [optimistic, setOptimistic] = useState([]);
const lock = useRef(false);
- const { app, messages, error: pollingError, loading, refresh, resume } = useBuildPolling(appId);
+ const { app, messages, error: socketError, loading, refresh, resume } = useBuilderSocket(appId);
const displayedMessages = useMemo(
() => mergeOptimisticMessages(messages, optimistic), [messages, optimistic],
);
@@ -38,11 +38,11 @@ export default function Builder({
m.tool_calls?.some((t) => t.status === "waiting_for_user_input"),
);
const processing = app?.status?.state === "processing";
- // A prompt/answer invalidates the previous ready state before polling catches up.
+ // A prompt/answer invalidates the previous ready state before live state catches up.
// Preview and deploy operations themselves should keep the card mounted.
const submittingBuild = busy === "Sending prompt…" || busy === "Answering question…";
const canDeliver = app?.id === appId && app?.status?.state === "ready" &&
- !waiting && !pollingError && !submittingBuild && hasCompletedBuild(messages);
+ !waiting && !socketError && !submittingBuild && hasCompletedBuild(messages);
async function send(prompt: string) {
if (
@@ -51,7 +51,7 @@ export default function Builder({
!prompt.trim() ||
waiting ||
processing ||
- pollingError ||
+ socketError ||
creationUncertain
)
return false;
@@ -132,9 +132,9 @@ export default function Builder({
the conversation and send a follow-up prompt.
)}
- {(error || pollingError) && (
+ {(error || socketError) && (
- {error || pollingError}
+ {error || socketError}
{appId && (
- Resume polling
+ Reconnect live updates
)}
@@ -190,8 +190,8 @@ export default function Builder({
busy={!!busy}
processing={processing}
waiting={waiting}
- disabled={loading || !!busy || waiting || processing || !!pollingError || creationUncertain}
- questionsDisabled={!!busy || !!pollingError}
+ disabled={loading || !!busy || waiting || processing || !!socketError || creationUncertain}
+ questionsDisabled={!!busy || !!socketError}
onSend={send}
onAnswer={answer}
>
@@ -215,7 +215,7 @@ export default function Builder({
Preview
void deploy()}
>
@@ -239,11 +239,11 @@ export default function Builder({
)}
- {(busy || waiting || processing || pollingError) && (
+ {(busy || waiting || processing || socketError) && (
{(busy || processing) &&
}
{busy ||
- (pollingError
+ (socketError
? "Connection paused"
: waiting
? "Waiting for your answer"
diff --git a/examples/white-label-minimal/components/useBuildPolling.ts b/examples/white-label-minimal/components/useBuildPolling.ts
deleted file mode 100644
index 9e64c98..0000000
--- a/examples/white-label-minimal/components/useBuildPolling.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { getApp, getConversation } from "../lib/chat/builder-api";
-import type { App, Message } from "../lib/types";
-import { refreshConversation } from "../lib/chat/conversation";
-
-export function useBuildPolling(appId: string | null) {
- const [app, setApp] = useState
(null);
- const [messages, setMessages] = useState([]);
- const [error, setError] = useState("");
- const [revision, setRevision] = useState(0);
- const [settledRequest, setSettledRequest] = useState("");
- const requestKey = `${appId}:${revision}`;
- const loading = !!appId && settledRequest !== requestKey;
- const refreshNow = useRef<() => Promise>(async () => {});
-
- useEffect(() => {
- if (!appId) return;
- let stopped = false;
- let transcript: Message[] = [];
- let timer: ReturnType;
- let flight: Promise | null = null;
- const controller = new AbortController();
- function refresh(): Promise {
- if (flight) return flight;
- clearTimeout(timer);
- flight = (async () => {
- try {
- const [appResult, conversationResult] = await Promise.allSettled([
- getApp(appId!, controller.signal),
- refreshConversation(transcript, (skip) =>
- getConversation(appId!, skip, controller.signal),
- ),
- ]);
- if (stopped) return;
- // Settle both reads even if one fails, so a retry cannot overlap the other.
- if (appResult.status === "rejected") throw appResult.reason;
- if (conversationResult.status === "rejected") throw conversationResult.reason;
- const current = appResult.value;
- const conversation = conversationResult.value;
- transcript = conversation;
- setApp(current);
- setMessages(conversation);
- setError("");
- timer = setTimeout(refresh, current.status?.state === "processing" ? 2_000 : 10_000);
- } catch (err) {
- if (!stopped) setError(err instanceof Error ? err.message : "Polling failed.");
- } finally {
- if (!stopped) setSettledRequest(`${appId}:${revision}`);
- flight = null;
- }
- })();
- return flight;
- }
- refreshNow.current = async () => {
- // A mutation must refresh from a read started after it completes.
- if (flight) await flight;
- if (!stopped) await refresh();
- };
- void refresh();
- return () => {
- stopped = true;
- clearTimeout(timer);
- controller.abort();
- };
- }, [appId, revision]);
-
- const refresh = useCallback(() => refreshNow.current(), []);
- const resume = () => setRevision((n) => n + 1);
- return { app, messages, error, loading, refresh, resume };
-}
diff --git a/examples/white-label-minimal/components/useBuilderSocket.ts b/examples/white-label-minimal/components/useBuilderSocket.ts
new file mode 100644
index 0000000..40ec39d
--- /dev/null
+++ b/examples/white-label-minimal/components/useBuilderSocket.ts
@@ -0,0 +1,31 @@
+"use client";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { watchBuild, type BuildState } from "../lib/chat/build-stream";
+
+export function useBuilderSocket(appId: string | null) {
+ const [state, setState] = useState({ app: null, messages: [] });
+ const [error, setError] = useState("");
+ const [revision, setRevision] = useState(0);
+ const [settled, setSettled] = useState("");
+ const current = useRef | null>(null);
+ const key = `${appId}:${revision}`;
+
+ useEffect(() => {
+ if (!appId) return;
+ const stream = watchBuild(appId, next => {
+ setState(next);
+ setError("");
+ setSettled(`${appId}:${revision}`);
+ }, message => {
+ setError(message);
+ setSettled(`${appId}:${revision}`);
+ });
+ current.current = stream;
+ return () => { stream.close(); current.current = null; };
+ }, [appId, revision]);
+
+ const refresh = useCallback(async () => { await current.current?.refresh(); }, []);
+ const resume = () => { setError(""); setRevision(value => value + 1); };
+ const visible = state.app?.id === appId ? state : { app: null, messages: [] };
+ return { ...visible, error, loading: !!appId && settled !== key, refresh, resume };
+}
diff --git a/examples/white-label-minimal/lib/base44/client.ts b/examples/white-label-minimal/lib/base44/client.ts
index b10dce8..1b64b57 100644
--- a/examples/white-label-minimal/lib/base44/client.ts
+++ b/examples/white-label-minimal/lib/base44/client.ts
@@ -179,6 +179,9 @@ export function createBase44Client(accessToken: string) {
}
return {
+ async getBuilderConnection() {
+ return { serverUrl: getBase44Config().host, token: accessToken };
+ },
createApp,
getApp,
getConversation,
diff --git a/examples/white-label-minimal/lib/chat/build-stream.ts b/examples/white-label-minimal/lib/chat/build-stream.ts
new file mode 100644
index 0000000..6fa40f6
--- /dev/null
+++ b/examples/white-label-minimal/lib/chat/build-stream.ts
@@ -0,0 +1,112 @@
+import type { BuilderSession, PlatformSocketError } from "@base44/sdk/platform/client";
+import type { App, Message } from "../types";
+import { getApp, getConversation } from "./builder-api";
+import { createBuilderConnection } from "./builder-connection";
+import { refreshConversation } from "./conversation";
+import { applyMessageUpdate, resolveImage } from "./socket-messages";
+
+export interface BuildState { app: App | null; messages: Message[] }
+export interface BuildStreamDependencies {
+ connect: (appId: string, onError: (error: PlatformSocketError) => void, signal: AbortSignal) => Promise;
+ readApp: typeof getApp;
+ readConversation: typeof getConversation;
+}
+
+export function watchBuild(
+ appId: string,
+ onState: (state: BuildState) => void,
+ onError: (message: string) => void,
+ dependencies: BuildStreamDependencies = {
+ connect: createBuilderConnection, readApp: getApp, readConversation: getConversation,
+ },
+) {
+ const controller = new AbortController();
+ let state: BuildState = { app: null, messages: [] };
+ let builder: BuilderSession | undefined;
+ let stopped = false;
+ let loaded = false;
+ let flight: Promise | undefined;
+
+ function publish() { if (!stopped) onState(state); }
+ function fail(error: unknown) {
+ if (stopped) return;
+ stopped = true;
+ controller.abort();
+ const code = (error as { code?: string })?.code;
+ onError(code === "resync_required"
+ ? "Live history expired. Reconnect to load the conversation again."
+ : "Live updates paused. Reconnect to continue.");
+ builder?.close();
+ }
+ function snapshot(): Promise {
+ if (flight) return flight;
+ flight = (async () => {
+ const [app, messages] = await Promise.all([
+ dependencies.readApp(appId, controller.signal),
+ refreshConversation([], skip => dependencies.readConversation(appId, skip, controller.signal)),
+ ]);
+ if (stopped) return;
+ state = { app, messages };
+ loaded = true;
+ publish();
+ })().finally(() => { flight = undefined; });
+ return flight;
+ }
+
+ async function start() {
+ builder = await dependencies.connect(appId, fail, controller.signal);
+ if (stopped) { builder.close(); return; }
+ builder.subscribe(appId, {
+ onError: fail,
+ async onJoined() {
+ // Subscribe first: the SDK buffers live events while the initial snapshot loads.
+ if (!loaded) await snapshot();
+ },
+ async onEvent(event) {
+ if (flight) await flight;
+ if (stopped) return;
+ if (event.type === "update_model") {
+ const update = event.data;
+ if (state.app && "status" in update) state = {
+ ...state, app: { ...state.app, status: update.status ?? undefined },
+ };
+ state = { ...state, messages: applyMessageUpdate(state.messages, update) };
+ // Question forms are intentionally absent from socket payloads; read them through our backend.
+ const needsQuestion = update._last_msg?.tool_calls?.some(tool => tool.status === "waiting_for_user_input");
+ if (needsQuestion) await snapshot();
+ else {
+ publish();
+ if (update.status?.state === "ready") {
+ const app = await dependencies.readApp(appId, controller.signal);
+ state = { ...state, app };
+ publish();
+ }
+ }
+ } else if (event.type === "directive") {
+ await snapshot();
+ } else if (event.type === "image_ready") {
+ state = { ...state, messages: resolveImage(state.messages, event.data) };
+ publish();
+ }
+ // Queue/task events advance the SDK cursor; this minimal UI renders progress from messages.
+ },
+ });
+ await builder.connect();
+ }
+ void start().catch(fail);
+
+ return {
+ async refresh() {
+ // Mutation-triggered reads must start after any earlier read has settled.
+ try {
+ if (flight) await flight;
+ if (!stopped) await snapshot();
+ } catch (error) { fail(error); }
+ },
+ close() {
+ stopped = true;
+ controller.abort();
+ builder?.close();
+ },
+ };
+}
diff --git a/examples/white-label-minimal/lib/chat/builder-api.ts b/examples/white-label-minimal/lib/chat/builder-api.ts
index 0eb0dbd..6e6b1e2 100644
--- a/examples/white-label-minimal/lib/chat/builder-api.ts
+++ b/examples/white-label-minimal/lib/chat/builder-api.ts
@@ -10,10 +10,10 @@ export class ApiError extends Error {
}
}
-async function call(action: string, params: object, signal?: AbortSignal): Promise {
+async function call(action: string, params: object, signal?: AbortSignal, path = "/api/base44"): Promise {
let response: Response;
try {
- response = await fetch("/api/base44", {
+ response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...params }),
@@ -58,3 +58,8 @@ export const getPublishedUrl = (appId: string) =>
export const listApps = (skip = 0) => call("listApps", { skip });
export const removeApp = (appId: string) => call("removeApp", { appId });
+
+export const getBuilderConnection = (appId: string, signal?: AbortSignal) =>
+ call<{ serverUrl: string; token: string }>(
+ "getBuilderConnection", { appId }, signal, "/api/base44/socket-token",
+ );
diff --git a/examples/white-label-minimal/lib/chat/builder-connection.ts b/examples/white-label-minimal/lib/chat/builder-connection.ts
new file mode 100644
index 0000000..dd60df9
--- /dev/null
+++ b/examples/white-label-minimal/lib/chat/builder-connection.ts
@@ -0,0 +1,20 @@
+import { Base44PlatformClient, type PlatformSocketError } from "@base44/sdk/platform/client";
+import { getBuilderConnection } from "./builder-api";
+
+export async function createBuilderConnection(
+ appId: string,
+ onError: (error: PlatformSocketError) => void,
+ signal: AbortSignal,
+) {
+ const initial = await getBuilderConnection(appId, signal);
+ let firstToken: string | undefined = initial.token;
+ const client = new Base44PlatformClient({
+ serverUrl: initial.serverUrl,
+ async refreshToken() {
+ const token = firstToken ?? (await getBuilderConnection(appId, signal)).token;
+ firstToken = undefined;
+ return token;
+ },
+ });
+ return client.builder.init({ onError });
+}
diff --git a/examples/white-label-minimal/lib/chat/socket-messages.ts b/examples/white-label-minimal/lib/chat/socket-messages.ts
new file mode 100644
index 0000000..6291807
--- /dev/null
+++ b/examples/white-label-minimal/lib/chat/socket-messages.ts
@@ -0,0 +1,27 @@
+import type { AppUpdate, ImageReady } from "@base44/sdk/platform/client";
+import type { Message } from "../types";
+
+export function applyMessageUpdate(messages: Message[], update: AppUpdate): Message[] {
+ const message = update._last_msg;
+ if (!message) return messages;
+ if (!message.id) throw new Error("A socket message has no stable ID.");
+ const replacement: Message = { ...message, id: message.id };
+ return messages.some(current => current.id === message.id)
+ ? messages.map(current => current.id === message.id ? replacement : current)
+ : [...messages, replacement];
+}
+
+export function resolveImage(messages: Message[], image: ImageReady): Message[] {
+ if (image.status !== "completed" || !image.image_url) return messages;
+ const replace = (text: string | null | undefined) =>
+ text?.split(image.placeholder_url).join(image.image_url!) ?? text;
+ return messages.map(message => ({
+ ...message,
+ content: replace(message.content),
+ tool_calls: message.tool_calls?.map(tool => ({
+ ...tool,
+ arguments_string: replace(tool.arguments_string),
+ results: replace(tool.results),
+ })),
+ }));
+}
diff --git a/examples/white-label-minimal/lib/server/api-handler.ts b/examples/white-label-minimal/lib/server/api-handler.ts
index 5f5f24e..035d390 100644
--- a/examples/white-label-minimal/lib/server/api-handler.ts
+++ b/examples/white-label-minimal/lib/server/api-handler.ts
@@ -6,7 +6,7 @@ const bad = (message: string, status = 400): never => {
throw new Base44Error(message, status);
};
-export function createHandler(resolveClient: () => Promise) {
+export function createHandler(resolveClient: () => Promise, allowedActions?: readonly string[]) {
return async function POST(request: Request) {
let dispatched = false;
try {
@@ -56,6 +56,7 @@ export function createHandler(resolveClient: () => Promise) {
);
}
if (!p || typeof p !== "object" || Array.isArray(p)) bad("Expected a JSON object.");
+ if (allowedActions && !allowedActions.includes(String(p.action))) bad("Unsupported action.");
const fields = (...allowed: string[]) => {
if (Object.keys(p).some((k) => !["action", ...allowed].includes(k)))
bad("Unexpected request field.");
@@ -91,6 +92,10 @@ export function createHandler(resolveClient: () => Promise) {
let result;
if (p.appId !== undefined) await client.authorize(id("appId"));
switch (p.action) {
+ case "getBuilderConnection":
+ fields("appId");
+ result = await execute(client.getBuilderConnection, id("appId"));
+ break;
case "listApps": {
fields("skip");
result = await execute(client.listApps, skip());
diff --git a/examples/white-label-minimal/lib/types.ts b/examples/white-label-minimal/lib/types.ts
index 8942a85..e690258 100644
--- a/examples/white-label-minimal/lib/types.ts
+++ b/examples/white-label-minimal/lib/types.ts
@@ -12,7 +12,7 @@ export type ToolCall = {
id?: string | null;
name?: string | null;
status?: string | null;
- waiting_on?: { kind?: string } | null;
+ waiting_on?: { kind?: string | null } | null;
arguments_string?: string | null;
results?: string | null;
};
@@ -34,6 +34,7 @@ export type ToolInput = {
export type AppPage = { apps: App[]; hasMore: boolean; nextSkip: number };
export interface AppClient {
+ getBuilderConnection(appId: string): Promise<{ serverUrl: string; token: string }>;
createApp(prompt: string): Promise;
getApp(appId: string): Promise;
getConversation(appId: string, skip: number): Promise<{ messages: Message[] }>;
diff --git a/examples/white-label-minimal/package.json b/examples/white-label-minimal/package.json
index e937cf2..71e97e6 100644
--- a/examples/white-label-minimal/package.json
+++ b/examples/white-label-minimal/package.json
@@ -7,7 +7,7 @@
"build": "npm run db:generate && next build",
"start": "next start --hostname 127.0.0.1 --port 3001",
"typecheck": "next typegen && tsc --noEmit",
- "test": "tsx --conditions=react-server --test tests/server.test.ts tests/identity.test.ts && tsx --test tests/conversation.test.ts tests/questions.test.tsx",
+ "test": "tsx --conditions=react-server --test tests/server.test.ts tests/identity.test.ts && tsx --test tests/build-stream.test.ts tests/conversation.test.ts tests/questions.test.tsx",
"test:browser": "playwright test",
"db:generate": "prisma generate",
"postinstall": "npm run db:generate"
@@ -15,14 +15,15 @@
"dependencies": {
"@assistant-ui/react": "^0.15.17",
"@assistant-ui/react-markdown": "^0.14.13",
+ "@base44/sdk": "npm:@base44-preview/sdk@0.8.48-pr.286.7d59d14",
"@prisma/client": "^6.19.3",
"lucide-react": "^0.475.0",
"next": "16.3.0",
"next-auth": "^5.0.0-beta.32",
+ "prisma": "^6.19.3",
"react": "19.2.8",
"react-dom": "19.2.8",
- "server-only": "0.0.1",
- "prisma": "^6.19.3"
+ "server-only": "0.0.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
diff --git a/examples/white-label-minimal/tests/browser.spec.ts b/examples/white-label-minimal/tests/browser.spec.ts
index ca3c0ba..5a632f4 100644
--- a/examples/white-label-minimal/tests/browser.spec.ts
+++ b/examples/white-label-minimal/tests/browser.spec.ts
@@ -1,6 +1,37 @@
import { test, expect, type Page } from '@playwright/test';
import type { ToolCall } from '../lib/types';
+const liveSockets = new WeakMap void>();
+function pushEvent(page: Page, event: string, data: object) {
+ const send = liveSockets.get(page);
+ if (!send) throw new Error("Builder socket has not connected");
+ send(event, data);
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.route('**/api/base44/socket-token', route => route.fulfill({
+ json: { serverUrl: 'https://socket.example', token: 'fixture-browser-token' },
+ }));
+ await page.routeWebSocket('**/ws-whitelabel/socket.io/**', socket => {
+ let room = '', seq = 0;
+ socket.send('0' + JSON.stringify({ sid: 'fixture', upgrades: [], pingInterval: 3600000, pingTimeout: 3600000 }));
+ socket.onMessage(raw => {
+ const packet = raw.toString();
+ if (packet.startsWith('40/partner,')) socket.send('40/partner,{"sid":"fixture"}');
+ if (!packet.startsWith('42/partner,')) return;
+ const [event, joinedRoom] = JSON.parse(packet.slice('42/partner,'.length));
+ if (event !== 'join') return;
+ room = joinedRoom;
+ liveSockets.set(page, (name, data) => {
+ const wrapped = ['update_model', 'task_update', 'image_ready'].includes(name);
+ const payload = wrapped ? { room, data: JSON.stringify(data) } : { room, ...data };
+ socket.send('42/partner,' + JSON.stringify([name, { ...payload, seq: String(++seq) }]));
+ });
+ socket.send('42/partner,' + JSON.stringify(['joined', { room, seq: String(++seq), max_entries: 2000, inactivity_expiry_seconds: 3600 }]));
+ });
+ });
+});
+
async function fixture(page: Page, tool?: ToolCall, failFirst = false) {
const submissions: Record[] = [];
let previews = 0, deployments = 0, creates = 0;
@@ -11,8 +42,8 @@ async function fixture(page: Page, tool?: ToolCall, failFirst = false) {
let json: unknown = {};
if (p.action === 'listApps') return route.fulfill({ json: { apps: [], hasMore: false } });
switch (p.action) {
- case 'createApp': creates++; json = { id: 'app_1' }; break;
- case 'getApp': json = { id: 'app_1', status: { state: 'ready' } }; break;
+ case 'createApp': creates++; json = { id: 'aaaaaaaaaaaaaaaaaaaaaaaa' }; break;
+ case 'getApp': json = { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', status: { state: 'ready' } }; break;
case 'getConversation': json = { messages: [{ id: 'm1', role: 'assistant', content: 'Your app is taking shape.',
tool_calls: [{ id: "built-file", name: "write_file", status: "success" }, ...(tool ? [{ ...tool, status }] : [])] }] }; break;
case 'submitToolCallInput':
@@ -94,20 +125,20 @@ test('uncertain create never retries automatically and offers existing-app recov
await expect(page.getByRole('button', { name: 'Create app', exact: true })).toBeDisabled();
expect(creates).toBe(1);
});
-test('read failure pauses polling, resume restores it', async ({ page }) => {
+test('snapshot failure pauses live updates, reconnect restores them', async ({ page }) => {
await page.clock.install();
await fixture(page);
await page.route('**/api/base44', async route => {
if (route.request().postDataJSON().action === 'getApp') return route.fulfill({ status: 503, json: { error: 'Temporary read failure' } });
return route.fallback();
});
- await page.clock.fastForward(11_000);
- await expect(page.getByText('Temporary read failure')).toBeVisible();
+ pushEvent(page, "directive", { type: "conversation_changed" });
+ await expect(page.getByText('Live updates paused. Reconnect to continue.')).toBeVisible();
await page.unroute('**/api/base44');
await page.route('**/api/base44', route => route.fulfill({ json: route.request().postDataJSON().action === 'getApp'
- ? { id: 'app_1', status: { state: 'ready' } } : { messages: [] } }));
- await page.getByRole('button', { name: 'Resume polling' }).click();
- await expect(page.getByText('Temporary read failure')).toHaveCount(0);
+ ? { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', status: { state: 'ready' } } : { messages: [] } }));
+ await page.getByRole('button', { name: 'Reconnect live updates' }).click();
+ await expect(page.getByText('Live updates paused. Reconnect to continue.')).toHaveCount(0);
});
test('live preview stays stable and recovers only from its own expiry message', async ({ page }) => {
@@ -142,7 +173,7 @@ test('real Next route requires authentication for local browser origins and reje
expect(foreign.status()).toBe(403);
});
-test('slow conversation reads do not overlap later polling intervals', async ({ page }) => {
+test('slow snapshot reads do not overlap and no polling timer runs', async ({ page }) => {
await page.clock.install();
await fixture(page);
let reads = 0;
@@ -154,7 +185,7 @@ test('slow conversation reads do not overlap later polling intervals', async ({
await pending;
return route.fulfill({ json: { messages: [] } });
});
- await page.clock.fastForward(11_000);
+ pushEvent(page, "directive", { type: "conversation_changed" });
await expect.poll(() => reads).toBe(1);
await page.clock.fastForward(40_000);
expect(reads).toBe(1);
@@ -178,7 +209,7 @@ test('My apps shows owned cards and opens the editor without marketplace feature
await page.route('**/api/base44', route => {
const { action, appId } = route.request().postDataJSON();
if (action === 'listApps') return route.fulfill({ json: { apps: [
- { id: 'reading', name: 'Reading list', user_description: 'A home for your next great read' },
+ { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Reading list', user_description: 'A home for your next great read' },
{ id: 'habits', name: 'Daily habits', user_description: 'Small steps, every day' },
{ id: 'recipes', name: 'Recipe book', user_description: 'Keep your favorites close' },
], hasMore: false } });
@@ -217,7 +248,7 @@ test('My apps shows owned cards and opens the editor without marketplace feature
test('ordinary tool activity is collapsed and assistant messages render Markdown', async ({ page }) => {
await page.route('**/api/base44', route => {
const { action } = route.request().postDataJSON();
- return route.fulfill({ json: action === 'listApps' ? { apps: [], hasMore: false, nextSkip: 0 } : action === 'getConversation' ? { messages: [{ id: 'm1', role: 'assistant', content: '## Your app is ready\n- **Hello world**', tool_calls: [{ id: 't1', name: 'find_replace', status: 'success', arguments_string: JSON.stringify({ file_path: 'src/index.css', find: 'old', replace: 'new' }) }] }] } : { id: 'app_1', name: 'Hello World', status: { state: 'ready' } } });
+ return route.fulfill({ json: action === 'listApps' ? { apps: [], hasMore: false, nextSkip: 0 } : action === 'getConversation' ? { messages: [{ id: 'm1', role: 'assistant', content: '## Your app is ready\n- **Hello world**', tool_calls: [{ id: 't1', name: 'find_replace', status: 'success', arguments_string: JSON.stringify({ file_path: 'src/index.css', find: 'old', replace: 'new' }) }] }] } : { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Hello World', status: { state: 'ready' } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill('Hello world app');
@@ -230,7 +261,7 @@ test('My apps shows owned cards and opens the editor without marketplace feature
await page.screenshot({ path: 'test-results/chat-desktop.png', fullPage: true });
});
-test('assistant-ui preserves inline tools across polling and hides internal messages', async ({ page }) => {
+test('assistant-ui preserves inline tools across live invalidation and hides internal messages', async ({ page }) => {
await page.clock.install();
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
@@ -247,7 +278,7 @@ test('assistant-ui preserves inline tools across polling and hides internal mess
{ name: 'unknown_question', status: 'waiting_for_user_input', waiting_on: { kind: 'choice' }, arguments_string: '{' },
] },
] } });
- return route.fulfill({ json: { id: 'app_1', status: { state: 'ready' } } });
+ return route.fulfill({ json: { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', status: { state: 'ready' } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill('Build a notes app');
@@ -261,7 +292,7 @@ test('assistant-ui preserves inline tools across polling and hides internal mess
await page.locator('.tool-activity summary').click();
await expect(page.locator('.tool-activity')).toContainText('Working');
complete = true;
- await page.clock.fastForward(11_000);
+ pushEvent(page, "directive", { type: "conversation_changed" });
await expect(page.locator('.tool-activity')).toContainText('File saved');
await expect(page.locator('.tool-activity')).toHaveAttribute('open', '');
expect(sent).toEqual([]);
@@ -303,7 +334,7 @@ test('preview card waits for build completion and hides during follow-up submiss
if (action === 'getConversation') return route.fulfill({ json: { messages: [
{ id: 'm1', role: 'assistant', content: 'Your scoreboard is live.', tool_calls: [{ id: 'write', name: 'write_file', status: 'success' }] },
] } });
- return route.fulfill({ json: { id: 'app_1', name: 'Scoreboard', status: { state } } });
+ return route.fulfill({ json: { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Scoreboard', status: { state } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill('Build a scoreboard');
@@ -314,7 +345,7 @@ test('preview card waits for build completion and hides during follow-up submiss
await expect(card).toHaveCount(0);
state = 'ready';
- await page.clock.fastForward(2_100);
+ pushEvent(page, "directive", { type: "conversation_changed" });
await expect(card).toBeVisible();
await page.getByLabel('What should change?').fill('Add a reset button');
await page.getByRole('button', { name: 'Send prompt', exact: true }).click();
@@ -325,11 +356,11 @@ test('preview card waits for build completion and hides during follow-up submiss
await expect(card).toHaveCount(0);
state = 'ready';
- await page.clock.fastForward(2_100);
+ pushEvent(page, "directive", { type: "conversation_changed" });
await expect(card).toBeVisible();
});
-test('first prompt stays visible through creation and empty polls, then merges once', async ({ page }) => {
+test('first prompt stays visible through creation and an empty snapshot, then merges once', async ({ page }) => {
await page.clock.install();
let releaseCreate: (() => void) | undefined;
let includeMessage = false;
@@ -340,7 +371,7 @@ test('first prompt stays visible through creation and empty polls, then merges o
if (action === 'createApp') await new Promise(resolve => { releaseCreate = resolve; });
if (action === 'getConversation') return route.fulfill({ json: { messages: includeMessage
? [{ id: 'server-user', role: 'user', content: prompt }] : [] } });
- return route.fulfill({ json: { id: 'app_1', status: { state: 'processing' } } });
+ return route.fulfill({ json: { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', status: { state: 'processing' } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill(prompt);
@@ -353,7 +384,7 @@ test('first prompt stays visible through creation and empty polls, then merges o
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
await expect(page.getByText('No messages yet.', { exact: true })).toHaveCount(0);
includeMessage = true;
- await page.clock.fastForward(2_100);
+ pushEvent(page, "directive", { type: "conversation_changed" });
await expect(page.locator('.from-user')).toHaveCount(1);
await expect(page.getByText(prompt, { exact: true })).toHaveCount(1);
});
@@ -383,7 +414,7 @@ test('failed creation removes the optimistic bubble and restores the draft', asy
const { action } = route.request().postDataJSON();
return route.fulfill({ json: action === 'listApps' ? { apps: [], hasMore: false } : action === 'getConversation'
? { messages: [{ id: 'u1', role: 'user', content: 'say hello world' }, { id: 'a1', role: 'assistant', content: 'Hello world! What would you like to build?' }] }
- : { id: 'app_1', name: 'say hello world', status: { state: 'ready' } } });
+ : { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'say hello world', status: { state: 'ready' } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill('say hello world');
@@ -401,7 +432,7 @@ test('empty state has one creation CTA and app cards render fresh previews', asy
await page.route('**/api/base44', route => {
const { action } = route.request().postDataJSON();
return route.fulfill({ json: action === 'listApps'
- ? { apps: populated ? [{ id: 'reading', name: 'Reading list', static_preview_url: 'https://preview.example/static' }] : [], hasMore: false }
+ ? { apps: populated ? [{ id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Reading list', static_preview_url: 'https://preview.example/static' }] : [], hasMore: false }
: { url: `https://preview.example/?token=${++previews}` } });
});
await page.goto('/');
@@ -433,7 +464,7 @@ test('empty state has one creation CTA and app cards render fresh previews', asy
test('list thumbnails and ready widget open the same app preview', async ({ page }) => {
const screenshot = 'https://preview.example/thumbnail.svg';
- const app = { id: 'reading', name: 'Reading list', static_preview_url: 'https://preview.example/static', preview_screenshot_url: screenshot, status: { state: 'ready' } };
+ const app = { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Reading list', static_preview_url: 'https://preview.example/static', preview_screenshot_url: screenshot, status: { state: 'ready' } };
await page.route('https://preview.example/**', route => route.fulfill({ contentType: 'text/html', body: 'Reading app ' }));
await page.route(screenshot, route => route.fulfill({ contentType: 'image/svg+xml', body: ' ' }));
await page.route('**/api/base44', route => {
@@ -458,10 +489,11 @@ test('list thumbnails and ready widget open the same app preview', async ({ page
});
test('remove persists, handles failures, and New app lives in the chat header', async ({ page }) => {
- let apps = [{ id: 'reading', name: 'Reading list', status: { state: 'ready' } }];
+ let apps = [{ id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Reading list', status: { state: 'ready' } }];
let fail = true;
await page.route('**/api/base44', route => {
const { action } = route.request().postDataJSON();
+ if (action === 'getPreviewUrl') return route.fulfill({ json: { url: 'https://preview.example/static' } });
if (action === 'removeApp') {
if (fail) return route.fulfill({ status: 500, json: { error: 'Removal failed' } });
apps = [];
@@ -520,7 +552,7 @@ test('remove persists, handles failures, and New app lives in the chat header',
await new Promise(resolve => { release = resolve; });
await route.fulfill({ contentType: 'text/html', body: 'Live build ' });
});
- const app = { id: 'reading', name: 'Reading list', static_preview_url: 'https://static.example/app', status: { state: 'ready' } };
+ const app = { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'Reading list', static_preview_url: 'https://static.example/app', status: { state: 'ready' } };
await page.route('**/api/base44', route => {
const { action } = route.request().postDataJSON();
if (action === 'getPreviewUrl') { requests++; return route.fulfill({ json: { url: 'https://live.example/app' } }); }
@@ -542,7 +574,7 @@ test('remove persists, handles failures, and New app lives in the chat header',
test('editing shows a loader until the conversation arrives', async ({ page }) => {
let release!: () => void;
const conversationReady = new Promise(resolve => { release = resolve; });
- const app = { id: 'app_1', name: 'Reading list', status: { state: 'ready' } };
+ const app = { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Reading list', status: { state: 'ready' } };
await page.route('**/api/base44', async route => {
const { action } = route.request().postDataJSON();
if (action === 'getConversation') {
@@ -561,3 +593,18 @@ test('editing shows a loader until the conversation arrives', async ({ page }) =
await expect(page.getByText('Loading conversation…', { exact: true })).toHaveCount(0);
await expect(page.getByLabel('What should change?')).toBeEnabled();
});
+
+test('streamed chat replaces a message without periodic HTTP reads', async ({ page }) => {
+ await page.clock.install();
+ await fixture(page);
+ let reads = 0;
+ await page.route('**/api/base44', route => {
+ if (['getApp', 'getConversation'].includes(route.request().postDataJSON().action)) reads++;
+ return route.fallback();
+ });
+ pushEvent(page, 'update_model', { _last_msg: { id: 'm1', role: 'assistant', content: 'This text arrived through the socket.' } });
+ await expect(page.getByText('This text arrived through the socket.')).toBeVisible();
+ await expect(page.getByText('Your app is taking shape.')).toHaveCount(0);
+ await page.clock.fastForward(45_000);
+ expect(reads).toBe(0);
+});
diff --git a/examples/white-label-minimal/tests/build-stream.test.ts b/examples/white-label-minimal/tests/build-stream.test.ts
new file mode 100644
index 0000000..eeb486a
--- /dev/null
+++ b/examples/white-label-minimal/tests/build-stream.test.ts
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { SubscriptionOptions, PlatformEvent, PlatformSocketError } from '@base44/sdk/platform/client';
+import { watchBuild } from '../lib/chat/build-stream';
+import { applyMessageUpdate, resolveImage } from '../lib/chat/socket-messages';
+
+const appId = 'a'.repeat(24);
+const turn = () => new Promise(resolve => setImmediate(resolve));
+function fixture() {
+ let subscription!: SubscriptionOptions;
+ let reads = 0, closes = 0;
+ const states: unknown[] = [], errors: string[] = [], order: string[] = [];
+ const stream = watchBuild(appId, state => states.push(state), error => errors.push(error), {
+ async connect() {
+ order.push('initialize');
+ return {
+ subscribe(_id, options) { subscription = options; order.push('subscribe'); return { appId, cursor: undefined, active: true, unsubscribe() {} }; },
+ async connect() { order.push('connect'); },
+ close() { closes++; },
+ };
+ },
+ async readApp() { reads++; order.push('snapshot'); return { id: appId, status: { state: 'processing' } }; },
+ async readConversation() { return { messages: [{ id: 'm', role: 'assistant', content: 'Initial' }] }; },
+ });
+ return { stream, states, errors, order, subscription: () => subscription, reads: () => reads, closes: () => closes };
+}
+
+test('subscribes before loading history and streamed messages require no HTTP refresh', async () => {
+ const f = fixture(); await turn();
+ assert.deepEqual(f.order, ['initialize', 'subscribe', 'connect']);
+ await f.subscription().onJoined!({ room: `/apps/${appId}`, seq: 'one', max_entries: 2000, inactivity_expiry_seconds: 3600 });
+ assert.equal(f.reads(), 1);
+ await f.subscription().onEvent({ type: 'update_model', appId, seq: 'two', data: { _last_msg: { id: 'm', role: 'assistant', content: 'Streaming' } } });
+ assert.equal(f.reads(), 1);
+ assert.deepEqual((f.states.at(-1) as any).messages, [{ id: 'm', role: 'assistant', content: 'Streaming' }]);
+ await f.subscription().onJoined!({ room: `/apps/${appId}`, seq: 'two', max_entries: 2000, inactivity_expiry_seconds: 3600 });
+ assert.equal(f.reads(), 1, 'reconnect replay must not fetch a fresh snapshot');
+ f.stream.close();
+});
+
+test('question and invalidation events fetch existing backend projections without polling', async () => {
+ const f = fixture(); await turn();
+ for (const event of [
+ { type: 'update_model', data: { _last_msg: { id: 'm', tool_calls: [{ status: 'waiting_for_user_input' }] } } },
+ { type: 'directive', data: { room: `/apps/${appId}`, type: 'conversation_changed' } },
+ ]) await f.subscription().onEvent({ ...event, appId, seq: 'event' } as PlatformEvent);
+ assert.equal(f.reads(), 2);
+ f.stream.close();
+});
+
+test('resync errors stop the session and cannot silently restart a fresh cursor', async () => {
+ const f = fixture(); await turn();
+ f.subscription().onError({ code: 'resync_required' } as PlatformSocketError);
+ assert.equal(f.closes(), 1); assert.match(f.errors[0], /expired/);
+ await f.stream.refresh(); assert.equal(f.reads(), 0);
+});
+
+test('cleanup prevents an in-flight snapshot from publishing into another app', async () => {
+ let release!: () => void;
+ let onJoined!: SubscriptionOptions['onJoined'];
+ const states: unknown[] = [];
+ const stream = watchBuild(appId, state => states.push(state), () => {}, {
+ async connect() { return { subscribe(_id, options) { onJoined = options.onJoined; return { appId, cursor: undefined, active: true, unsubscribe() {} }; }, async connect() {}, close() {} }; },
+ async readApp() { await new Promise(resolve => { release = resolve; }); return { id: appId }; },
+ async readConversation() { return { messages: [] }; },
+ });
+ await turn();
+ const pending = onJoined!({ room: `/apps/${appId}`, seq: 'start', max_entries: 2000, inactivity_expiry_seconds: 3600 });
+ stream.close(); release(); await pending; assert.deepEqual(states, []);
+});
+
+test('message replacement preserves omission/null and image completion resolves placeholders', () => {
+ const previous = [{ id: 'm', role: 'assistant', content: '/placeholder', tool_calls: [{ id: 'old' }] }];
+ assert.deepEqual(applyMessageUpdate(previous, { _last_msg: { id: 'm', content: null } }), [{ id: 'm', content: null }]);
+ assert.equal(resolveImage(previous, { placeholder_url: '/placeholder', status: 'completed', image_url: '/image' })[0].content, '/image');
+ assert.equal(previous[0].content, '/placeholder');
+});
diff --git a/examples/white-label-minimal/tests/server.test.ts b/examples/white-label-minimal/tests/server.test.ts
index c8a00f3..6e68c1d 100644
--- a/examples/white-label-minimal/tests/server.test.ts
+++ b/examples/white-label-minimal/tests/server.test.ts
@@ -167,3 +167,19 @@ test('remove requires ownership and never calls the upstream app deletion API',
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://platform.example/api/apps/app_1');
});
+
+test('builder connection returns the current token only after session and app authorization', async () => {
+ setup();
+ const request = (appId: string, origin = 'http://127.0.0.1:3001') => new Request('http://127.0.0.1:3001/api/base44/socket-token', {
+ method: 'POST', headers: { host: '127.0.0.1:3001', origin, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'getBuilderConnection', appId }),
+ });
+ const response = await POST(request('app_1'));
+ assert.equal(response.status, 200);
+ assert.match(response.headers.get('cache-control')!, /no-store/);
+ assert.equal((await response.json()).token, 'user-token-canary');
+ assert.equal((await POST(request('other_app'))).status, 404);
+ assert.equal((await POST(request('app_1', 'https://foreign.example'))).status, 403);
+ signedIn = false;
+ assert.equal((await POST(request('app_1'))).status, 401);
+});
diff --git a/package-lock.json b/package-lock.json
index cf9880d..50c064e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,7 @@
"dependencies": {
"@assistant-ui/react": "^0.15.17",
"@assistant-ui/react-markdown": "^0.14.13",
+ "@base44/sdk": "npm:@base44-preview/sdk@0.8.48-pr.286.7d59d14",
"@prisma/client": "^6.19.3",
"lucide-react": "^0.475.0",
"next": "16.3.0",
@@ -538,6 +539,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@base44/sdk": {
+ "name": "@base44-preview/sdk",
+ "version": "0.8.48-pr.286.7d59d14",
+ "resolved": "https://registry.npmjs.org/@base44-preview/sdk/-/sdk-0.8.48-pr.286.7d59d14.tgz",
+ "integrity": "sha512-bft+tE5N0ZtCRlZ30KDEJ9EwRbg7HVxwGM1oLs47FesxNiNifSv3Mz/Gtr8PXs3azNwZW6/xjg9daJvm+ZFyYw==",
+ "license": "MIT",
+ "dependencies": {
+ "axios": "^1.18.1",
+ "partysocket": "^0.0.23",
+ "socket.io-client": "^4.8.3",
+ "uuid": "^13.0.2"
+ }
+ },
"node_modules/@base44/white-label-minimal": {
"resolved": "examples/white-label-minimal",
"link": true
@@ -3667,6 +3681,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -4781,6 +4801,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -5070,6 +5102,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5096,6 +5134,18 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
+ "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5244,7 +5294,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5445,6 +5494,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -5679,6 +5740,15 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -5752,7 +5822,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -5796,6 +5865,28 @@
"node": ">=14"
}
},
+ "node_modules/engine.io-client": {
+ "version": "6.6.6",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
+ "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.21.0",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.24.5",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
@@ -5902,7 +5993,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5912,7 +6002,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5950,7 +6039,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -5963,7 +6051,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6489,6 +6576,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/event-target-shim": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz",
+ "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ }
+ },
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
@@ -6648,6 +6747,26 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6664,6 +6783,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/framer-motion": {
"version": "11.16.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.16.4.tgz",
@@ -6709,7 +6844,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -6773,7 +6907,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -6807,7 +6940,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -6912,7 +7044,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6984,7 +7115,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6997,7 +7127,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -7013,7 +7142,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -7089,6 +7217,19 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -8161,7 +8302,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8786,6 +8926,27 @@
"node": ">=8.6"
}
},
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -9282,6 +9443,15 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
+ "node_modules/partysocket": {
+ "version": "0.0.23",
+ "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-0.0.23.tgz",
+ "integrity": "sha512-S43vtjJ///wvzxf0Pw8yD4HVGUDiJSmP1tJQjEzYUG6L7Id63+1CTcgZ0FXTy5BGHe8CuKNyO6bYd4LpOcy4QQ==",
+ "license": "ISC",
+ "dependencies": {
+ "event-target-shim": "^6.0.2"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -9393,7 +9563,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -9513,6 +9682,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -10332,6 +10510,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/socket.io-client": {
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
+ "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.7",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
+ "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -11207,6 +11413,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/uuid": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
+ "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -11350,6 +11569,35 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
From 9fdff6d6f7cec81ee29da46ff87912e1cc7a8a6d Mon Sep 17 00:00:00 2001
From: Oz Sayag
Date: Wed, 16 Sep 2026 15:32:06 +0300
Subject: [PATCH 2/4] Reconnect workspace when Base44 rejects a stored token
---
.../components/Workspace.tsx | 7 +++++--
.../white-label-minimal/tests/browser.spec.ts | 20 +++++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/examples/white-label-minimal/components/Workspace.tsx b/examples/white-label-minimal/components/Workspace.tsx
index 71373a5..71d1dee 100644
--- a/examples/white-label-minimal/components/Workspace.tsx
+++ b/examples/white-label-minimal/components/Workspace.tsx
@@ -22,6 +22,7 @@ export default function Workspace({ name }: { name: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [needsConnection, setNeedsConnection] = useState(false);
+ const [needsReconnect, setNeedsReconnect] = useState(false);
const [editor, setEditor] = useState<{ app: App | null; version: number }>({
app: null,
version: 0,
@@ -69,8 +70,10 @@ export default function Workspace({ name }: { name: string }) {
setError("");
setApps(result);
setNeedsConnection(false);
+ setNeedsReconnect(false);
})
.catch((err) => {
+ setNeedsReconnect(err instanceof api.ApiError && err.status === 401 && !err.notStarted);
if (err instanceof api.ApiError && err.status === 428) setNeedsConnection(true);
else setError(err instanceof Error ? err.message : "Could not load your apps.");
})
@@ -136,8 +139,8 @@ export default function Workspace({ name }: { name: string }) {
{error && (
{error}
-
load()}>
- Try again
+ needsReconnect ? connect() : load()}>
+ {needsReconnect ? (loading ? "Reconnecting…" : "Reconnect workspace") : "Try again"}
)}
diff --git a/examples/white-label-minimal/tests/browser.spec.ts b/examples/white-label-minimal/tests/browser.spec.ts
index 5a632f4..167bce6 100644
--- a/examples/white-label-minimal/tests/browser.spec.ts
+++ b/examples/white-label-minimal/tests/browser.spec.ts
@@ -205,6 +205,26 @@ test('rejected access preserves prompt and allows retry without uncertain creati
await expect(page.getByText('Creation may have succeeded.', { exact: false })).toHaveCount(0);
});
+test('rejected Base44 token reconnects before reloading apps', async ({ page }) => {
+ let connected = false;
+ let connections = 0;
+ await page.route('**/api/base44', route => route.fulfill(connected
+ ? { json: { apps: [], hasMore: false } }
+ : { status: 401, json: { error: 'Base44 returned 401. Reconnect your workspace.', outcome: 'unknown' } }));
+ await page.route('**/api/base44/connection', route => {
+ expect(route.request().postDataJSON()).toEqual({ action: 'connect' });
+ connections++;
+ connected = true;
+ return route.fulfill({ json: { linked: true } });
+ });
+ await page.goto('/');
+ await expect(page.getByRole('main').getByRole('alert')).toContainText('Reconnect your workspace.');
+ await page.getByRole('button', { name: 'Reconnect workspace', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Make room for your first idea' })).toBeVisible();
+ await expect(page.getByRole('main').getByRole('alert')).toHaveCount(0);
+ expect(connections).toBe(1);
+});
+
test('My apps shows owned cards and opens the editor without marketplace features', async ({ page }) => {
await page.route('**/api/base44', route => {
const { action, appId } = route.request().postDataJSON();
From ec485aca6df05b6aafce6ff60eb434f18e8dbcb4 Mon Sep 17 00:00:00 2001
From: Oz Sayag
Date: Thu, 17 Sep 2026 09:59:30 +0300
Subject: [PATCH 3/4] feat: render reviewed white-label tool activity
---
examples/white-label-minimal/README.md | 12 ++++---
.../components/Question.tsx | 2 +-
.../components/ToolActivity.tsx | 32 +++++++++----------
.../white-label-minimal/lib/base44/client.ts | 4 +++
.../lib/chat/build-stream.ts | 13 +++-----
examples/white-label-minimal/lib/types.ts | 17 ++++++++++
.../white-label-minimal/tests/browser.spec.ts | 10 +++---
.../tests/build-stream.test.ts | 4 +--
.../tests/questions.test.tsx | 5 +--
9 files changed, 58 insertions(+), 41 deletions(-)
diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md
index f5a8eeb..3d74e91 100644
--- a/examples/white-label-minimal/README.md
+++ b/examples/white-label-minimal/README.md
@@ -167,11 +167,13 @@ There are no periodic conversation or app reads. Invalidation events and complet
HTTP mutations trigger reconciliation; ready-state updates refresh preview metadata.
Queue/task events advance the cursor but have no separate UI in this minimal example.
-Question forms are not in the public socket payload. When a tool waits for input,
-the example reads its existing conversation projection through the partner backend
-and continues using the existing approval/choice/secret UI. HTTP mutations still
-use the partner backend. Those HTTP projections retain their current contract;
-this change does not introduce a new public question schema.
+Reviewed question and secret-form schemas arrive in the socket update that opens the
+tool card. The browser renders those schemas directly and posts any answer through
+the existing partner-backend mutation route. Tool cards use reviewed file paths,
+activity summaries, entity counts, package names, plan fields and media labels; they
+never render source, diffs, commands, execution output, secret values or raw results.
+The partner backend remains responsible for applying the same filtering policy to
+its existing HTTP history responses.
When retained history expires or an event cannot be applied, delivery stops and
**Reconnect live updates** starts a new session and snapshot. Snapshot recovery is
diff --git a/examples/white-label-minimal/components/Question.tsx b/examples/white-label-minimal/components/Question.tsx
index c950aa7..4492c91 100644
--- a/examples/white-label-minimal/components/Question.tsx
+++ b/examples/white-label-minimal/components/Question.tsx
@@ -126,7 +126,7 @@ export default function Question({
? "Unsupported question / tool details"
: "Review proposed action"}
- {tool.arguments_string || "No arguments provided."}
+ This action needs input that this example cannot render.
)}
{waiting && !submitted && (
diff --git a/examples/white-label-minimal/components/ToolActivity.tsx b/examples/white-label-minimal/components/ToolActivity.tsx
index 574d99d..be8691a 100644
--- a/examples/white-label-minimal/components/ToolActivity.tsx
+++ b/examples/white-label-minimal/components/ToolActivity.tsx
@@ -1,13 +1,22 @@
import { CircleAlert, Loader2, ChevronDown } from "lucide-react";
import type { ToolCall } from "../lib/types";
+function activityLabel(tool: ToolCall) {
+ const activity = tool.display_projection;
+ if (activity?.file_paths?.length) {
+ const verb = tool.name === "delete_file" ? "Deleting" : "Editing";
+ return `${verb} ${activity.file_paths.join(", ")}`;
+ }
+ if (activity?.entity_name) {
+ const count = activity.record_count == null ? "" : `${activity.record_count} `;
+ return `${tool.name?.replaceAll("_", " ") || "Updating"} ${count}${activity.entity_name}`;
+ }
+ return activity?.summary || tool.name?.replaceAll("_", " ") || "Agent action";
+}
+
export default function ToolActivity({ tool }: { tool: ToolCall }) {
const pending = ["running", "pending"].includes(tool.status || "");
const failed = ["error", "stopped"].includes(tool.status || "");
- let argumentsText = tool.arguments_string || "";
- try {
- argumentsText = JSON.stringify(JSON.parse(argumentsText), null, 2);
- } catch {}
return (
@@ -18,24 +27,13 @@ export default function ToolActivity({ tool }: { tool: ToolCall }) {
) : (
)}
- {tool.name || "Agent action"}
+ {activityLabel(tool)}
{pending ? "Working" : failed ? "Failed" : "Done"}
- {argumentsText && (
-
-
Arguments
-
{argumentsText}
-
- )}
{tool.results && (
-
Result
-
- {typeof tool.results === "string"
- ? tool.results
- : JSON.stringify(tool.results, null, 2)}
-
+
{tool.results}
)}
diff --git a/examples/white-label-minimal/lib/base44/client.ts b/examples/white-label-minimal/lib/base44/client.ts
index 1b64b57..051c630 100644
--- a/examples/white-label-minimal/lib/base44/client.ts
+++ b/examples/white-label-minimal/lib/base44/client.ts
@@ -123,6 +123,10 @@ export function createBase44Client(accessToken: string) {
waiting_on: t.waiting_on,
arguments_string: t.arguments_string,
results: typeof t.results === "string" ? t.results : undefined,
+ auto_approved: t.auto_approved,
+ mutation_applied: t.mutation_applied,
+ display_projection: t.display_projection,
+ user_input: t.user_input,
})),
};
});
diff --git a/examples/white-label-minimal/lib/chat/build-stream.ts b/examples/white-label-minimal/lib/chat/build-stream.ts
index 6fa40f6..5eab0a8 100644
--- a/examples/white-label-minimal/lib/chat/build-stream.ts
+++ b/examples/white-label-minimal/lib/chat/build-stream.ts
@@ -71,16 +71,11 @@ export function watchBuild(
...state, app: { ...state.app, status: update.status ?? undefined },
};
state = { ...state, messages: applyMessageUpdate(state.messages, update) };
- // Question forms are intentionally absent from socket payloads; read them through our backend.
- const needsQuestion = update._last_msg?.tool_calls?.some(tool => tool.status === "waiting_for_user_input");
- if (needsQuestion) await snapshot();
- else {
+ publish();
+ if (update.status?.state === "ready") {
+ const app = await dependencies.readApp(appId, controller.signal);
+ state = { ...state, app };
publish();
- if (update.status?.state === "ready") {
- const app = await dependencies.readApp(appId, controller.signal);
- state = { ...state, app };
- publish();
- }
}
} else if (event.type === "directive") {
await snapshot();
diff --git a/examples/white-label-minimal/lib/types.ts b/examples/white-label-minimal/lib/types.ts
index e690258..aed9842 100644
--- a/examples/white-label-minimal/lib/types.ts
+++ b/examples/white-label-minimal/lib/types.ts
@@ -15,6 +15,23 @@ export type ToolCall = {
waiting_on?: { kind?: string | null } | null;
arguments_string?: string | null;
results?: string | null;
+ auto_approved?: boolean | null;
+ mutation_applied?: boolean | null;
+ display_projection?: {
+ file_paths?: string[];
+ content_empty?: boolean;
+ summary?: string;
+ writes_entities?: boolean;
+ entity_name?: string;
+ record_count?: number;
+ } | null;
+ user_input?: {
+ answers?: Array<{
+ question_index?: number;
+ selected_labels?: string[];
+ custom_text?: string;
+ }>;
+ } | null;
};
export type Message = {
id: string;
diff --git a/examples/white-label-minimal/tests/browser.spec.ts b/examples/white-label-minimal/tests/browser.spec.ts
index 167bce6..8e1783d 100644
--- a/examples/white-label-minimal/tests/browser.spec.ts
+++ b/examples/white-label-minimal/tests/browser.spec.ts
@@ -268,16 +268,16 @@ test('My apps shows owned cards and opens the editor without marketplace feature
test('ordinary tool activity is collapsed and assistant messages render Markdown', async ({ page }) => {
await page.route('**/api/base44', route => {
const { action } = route.request().postDataJSON();
- return route.fulfill({ json: action === 'listApps' ? { apps: [], hasMore: false, nextSkip: 0 } : action === 'getConversation' ? { messages: [{ id: 'm1', role: 'assistant', content: '## Your app is ready\n- **Hello world**', tool_calls: [{ id: 't1', name: 'find_replace', status: 'success', arguments_string: JSON.stringify({ file_path: 'src/index.css', find: 'old', replace: 'new' }) }] }] } : { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Hello World', status: { state: 'ready' } } });
+ return route.fulfill({ json: action === 'listApps' ? { apps: [], hasMore: false, nextSkip: 0 } : action === 'getConversation' ? { messages: [{ id: 'm1', role: 'assistant', content: '## Your app is ready\n- **Hello world**', tool_calls: [{ id: 't1', name: 'find_replace', status: 'success', display_projection: { file_paths: ['src/index.css'] } }] }] } : { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'Hello World', status: { state: 'ready' } } });
});
await page.goto('/');
await page.getByLabel('What would you like to build?').fill('Hello world app');
await page.getByRole('button', { name: 'Create app', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Your app is ready' })).toBeVisible();
await expect(page.getByText('Unsupported question / tool details')).toHaveCount(0);
- await expect(page.locator('.tool-activity pre')).not.toBeVisible();
+ await expect(page.getByText('Editing src/index.css')).toBeVisible();
await page.locator('.tool-activity summary').click();
- await expect(page.locator('.tool-activity pre')).toContainText('"file_path": "src/index.css"');
+ await expect(page.getByText('Editing src/index.css')).toBeVisible();
await page.screenshot({ path: 'test-results/chat-desktop.png', fullPage: true });
});
@@ -294,7 +294,7 @@ test('assistant-ui preserves inline tools across live invalidation and hides int
if (p.action === 'getConversation') return route.fulfill({ json: { messages: [
{ id: 'hidden', hidden: true, role: 'assistant', content: 'Internal instructions' },
{ id: 'm1', role: 'assistant', content: '**Working on your app**', tool_calls: [
- { id: 't1', name: 'write_file', status: complete ? 'success' : 'running', arguments_string: '{"path":"app.tsx"}', results: complete ? 'File saved' : null },
+ { id: 't1', name: 'write_file', status: complete ? 'success' : 'running', display_projection: { file_paths: ['app.tsx'] }, results: complete ? 'Plan updated.' : null },
{ name: 'unknown_question', status: 'waiting_for_user_input', waiting_on: { kind: 'choice' }, arguments_string: '{' },
] },
] } });
@@ -313,7 +313,7 @@ test('assistant-ui preserves inline tools across live invalidation and hides int
await expect(page.locator('.tool-activity')).toContainText('Working');
complete = true;
pushEvent(page, "directive", { type: "conversation_changed" });
- await expect(page.locator('.tool-activity')).toContainText('File saved');
+ await expect(page.locator('.tool-activity')).toContainText('Plan updated.');
await expect(page.locator('.tool-activity')).toHaveAttribute('open', '');
expect(sent).toEqual([]);
expect(errors).toEqual([]);
diff --git a/examples/white-label-minimal/tests/build-stream.test.ts b/examples/white-label-minimal/tests/build-stream.test.ts
index eeb486a..2000328 100644
--- a/examples/white-label-minimal/tests/build-stream.test.ts
+++ b/examples/white-label-minimal/tests/build-stream.test.ts
@@ -38,13 +38,13 @@ test('subscribes before loading history and streamed messages require no HTTP re
f.stream.close();
});
-test('question and invalidation events fetch existing backend projections without polling', async () => {
+test('question updates render from the socket while invalidations refresh through the backend', async () => {
const f = fixture(); await turn();
for (const event of [
{ type: 'update_model', data: { _last_msg: { id: 'm', tool_calls: [{ status: 'waiting_for_user_input' }] } } },
{ type: 'directive', data: { room: `/apps/${appId}`, type: 'conversation_changed' } },
]) await f.subscription().onEvent({ ...event, appId, seq: 'event' } as PlatformEvent);
- assert.equal(f.reads(), 2);
+ assert.equal(f.reads(), 1);
f.stream.close();
});
diff --git a/examples/white-label-minimal/tests/questions.test.tsx b/examples/white-label-minimal/tests/questions.test.tsx
index ec8003f..95d7b98 100644
--- a/examples/white-label-minimal/tests/questions.test.tsx
+++ b/examples/white-label-minimal/tests/questions.test.tsx
@@ -15,9 +15,10 @@ test('input displays declared secret fields as password inputs', () => {
assert.equal(parseQuestion(t).kind, 'input');
const html = render(t); assert.match(html, /type="password"/); assert.match(html, /Weather provider key/);
});
-test('approval shows arguments, approve, and reject', () => {
- const html = render(tool('approval', { packages: [{ name: 'example' }] }));
+test('approval keeps unreviewed arguments out of the card', () => {
+ const html = render(tool('approval', { packages: [{ name: 'private-package' }] }));
assert.match(html, /Review proposed action/); assert.match(html, />Approve); assert.match(html, />Reject);
+ assert.doesNotMatch(html, /private-package/);
});
test('unknown or malformed questions remain visible and cannot be blindly approved', () => {
for (const t of [tool('future', {}), tool('input', { fields: [] }), { ...tool('choice', {}), arguments_string: '{bad' }]) {
From ac058fda93cd594609c4fdcbbb888c0c13490b0b Mon Sep 17 00:00:00 2001
From: Oz Sayag
Date: Thu, 17 Sep 2026 13:44:57 +0300
Subject: [PATCH 4/4] Render reviewed builder tool widgets
---
examples/white-label-minimal/README.md | 5 +-
examples/white-label-minimal/app/globals.css | 7 ++
.../components/Question.tsx | 22 +++--
.../components/ToolActivity.tsx | 84 +++++++++++++++++--
.../lib/chat/socket-messages.ts | 10 ++-
examples/white-label-minimal/lib/types.ts | 6 +-
examples/white-label-minimal/package.json | 2 +-
.../tests/build-stream.test.ts | 3 +-
.../tests/questions.test.tsx | 4 +
.../tests/tool-activity.test.tsx | 50 +++++++++++
10 files changed, 171 insertions(+), 22 deletions(-)
create mode 100644 examples/white-label-minimal/tests/tool-activity.test.tsx
diff --git a/examples/white-label-minimal/README.md b/examples/white-label-minimal/README.md
index 3d74e91..ae0bd22 100644
--- a/examples/white-label-minimal/README.md
+++ b/examples/white-label-minimal/README.md
@@ -170,8 +170,9 @@ Queue/task events advance the cursor but have no separate UI in this minimal exa
Reviewed question and secret-form schemas arrive in the socket update that opens the
tool card. The browser renders those schemas directly and posts any answer through
the existing partner-backend mutation route. Tool cards use reviewed file paths,
-activity summaries, entity counts, package names, plan fields and media labels; they
-never render source, diffs, commands, execution output, secret values or raw results.
+activity summaries, entity counts, package names, plan fields, and generated-media
+labels, state, and approved asset URLs; they never render source, diffs, commands,
+execution output, secret values or raw results.
The partner backend remains responsible for applying the same filtering policy to
its existing HTTP history responses.
diff --git a/examples/white-label-minimal/app/globals.css b/examples/white-label-minimal/app/globals.css
index a8ab0ff..b99e86f 100644
--- a/examples/white-label-minimal/app/globals.css
+++ b/examples/white-label-minimal/app/globals.css
@@ -418,6 +418,13 @@ iframe {
.tool-dot { width: 6px; height: 6px; border-radius: 50%; background: #929394; flex-shrink: 0; }
.tool-activity > div { padding: 8px 12px; border-top: 1px solid var(--border); background: white; }
.tool-activity pre { font-size: 11px; line-height: 1.5; }
+.tool-widget { display: grid; gap: 6px; }
+.tool-widget-heading { display: flex; align-items: center; gap: 6px; }
+.tool-widget small { display: block; }
+.tool-list { display: grid; gap: 4px; padding: 0; margin: 0; list-style: none; }
+.tool-list li { overflow-wrap: anywhere; }
+.tool-list code { font-size: 11px; }
+.tool-media img { width: 100%; max-height: 220px; object-fit: cover; border: 1px solid var(--border); border-radius: 4px; }
.composer { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 8px; flex-shrink: 0; border: 1px solid var(--border); border-radius: var(--radius-inset); background: white; padding: 12px; margin: 0 14px 14px; box-shadow: var(--shadow-card); }
.composer textarea { flex: 1; width: 0; margin: 0; padding: 9px 10px; font-size: 14px; line-height: 20px; height: 40px; min-height: 40px; max-height: 112px; resize: none; border-radius: 4px; }
.composer .send-button { flex-shrink: 0; height: 38px; width: 38px; padding: 0; border-radius: 4px; }
diff --git a/examples/white-label-minimal/components/Question.tsx b/examples/white-label-minimal/components/Question.tsx
index 4492c91..0fe3f36 100644
--- a/examples/white-label-minimal/components/Question.tsx
+++ b/examples/white-label-minimal/components/Question.tsx
@@ -4,16 +4,24 @@ import type { ToolCall, ToolInput } from "../lib/types";
type Choice = { question: string; options: string[]; multi: boolean };
type Field = { name: string; description: string };
+type Package = { name: string; action: "install" | "uninstall" };
export function parseQuestion(
tool: ToolCall,
):
| { kind: "choice"; choices: Choice[] }
| { kind: "input"; fields: Field[] }
- | { kind: "approval" }
+ | { kind: "approval"; packages: Package[] }
| { kind: "unknown" } {
try {
const args = JSON.parse(tool.arguments_string || "{}");
- if (tool.waiting_on?.kind === "approval") return { kind: "approval" };
+ if (tool.waiting_on?.kind === "approval") {
+ const packages = tool.name === "install_npm_package" && Array.isArray(args.packages)
+ ? args.packages.flatMap((pkg: Record) => typeof pkg?.name === "string" && pkg.name
+ ? [{ name: pkg.name, action: pkg.action === "uninstall" ? "uninstall" as const : "install" as const }]
+ : [])
+ : [];
+ return { kind: "approval", packages };
+ }
if (
tool.waiting_on?.kind === "choice" &&
Array.isArray(args.questions) &&
@@ -120,13 +128,13 @@ export default function Question({
{tool.name || "Agent action"} {" "}
{submitted ? "Answer sent" : tool.status}
{(question.kind === "unknown" || question.kind === "approval") && (
-
+ 0}>
- {question.kind === "unknown"
- ? "Unsupported question / tool details"
- : "Review proposed action"}
+ {question.kind === "unknown" ? "Unsupported question / tool details" : "Review proposed action"}
- This action needs input that this example cannot render.
+ {question.kind === "approval" && question.packages.length ? (
+ {question.packages.map(pkg => {pkg.action === "uninstall" ? "Remove" : "Install"} {pkg.name} )}
+ ) : This action needs input that this example cannot render.
}
)}
{waiting && !submitted && (
diff --git a/examples/white-label-minimal/components/ToolActivity.tsx b/examples/white-label-minimal/components/ToolActivity.tsx
index be8691a..8f3c964 100644
--- a/examples/white-label-minimal/components/ToolActivity.tsx
+++ b/examples/white-label-minimal/components/ToolActivity.tsx
@@ -1,6 +1,31 @@
-import { CircleAlert, Loader2, ChevronDown } from "lucide-react";
+import { CircleAlert, FileCode2, ImageIcon, Loader2, Package, PencilLine, Terminal, ChevronDown } from "lucide-react";
import type { ToolCall } from "../lib/types";
+type ToolArguments = {
+ packages?: Array<{ name?: unknown; action?: unknown }>;
+ updates?: Array<{ action?: unknown; section_label?: unknown; section?: unknown; text?: unknown }>;
+ sections_with_enough?: unknown[];
+ label?: unknown;
+ aspect_ratio?: unknown;
+};
+
+function argumentsFor(tool: ToolCall): ToolArguments {
+ try {
+ const parsed: unknown = JSON.parse(tool.arguments_string || "{}");
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as ToolArguments : {};
+ } catch {
+ return {};
+ }
+}
+
+function packagesFor(args: ToolArguments) {
+ return (args.packages || []).flatMap((pkg) =>
+ typeof pkg?.name === "string" && pkg.name
+ ? [{ name: pkg.name, action: pkg.action === "uninstall" ? "uninstall" : "install" }]
+ : [],
+ );
+}
+
function activityLabel(tool: ToolCall) {
const activity = tool.display_projection;
if (activity?.file_paths?.length) {
@@ -14,6 +39,55 @@ function activityLabel(tool: ToolCall) {
return activity?.summary || tool.name?.replaceAll("_", " ") || "Agent action";
}
+function statusLabel(tool: ToolCall) {
+ if (["error", "stopped"].includes(tool.status || "")) return "Failed";
+ if (["running", "pending"].includes(tool.status || "")) return "Working";
+ if (tool.auto_approved) return "Auto-approved";
+ return "Done";
+}
+
+function ToolDetails({ tool }: { tool: ToolCall }) {
+ const args = argumentsFor(tool);
+ const packages = packagesFor(args);
+ const plans = (args.updates || []).flatMap((update) => {
+ const label = typeof update.section_label === "string"
+ ? update.section_label
+ : typeof update.section === "string"
+ ? update.section
+ : "Plan";
+ return typeof update.text === "string" && update.text ? [{ label, text: update.text }] : [];
+ });
+ const media = typeof tool.results === "object" && tool.results ? tool.results : null;
+ const mediaLabel = typeof args.label === "string" && args.label ? args.label : "Generated media";
+ const mediaPending = media?.status === "pending" || (!media && tool.status === "running");
+
+ if (media || typeof args.label === "string") {
+ return (
+
+
{mediaPending ? "Generating" : media?.status === "failed" ? "Couldn’t generate" : "Generated"} {mediaLabel}
+ {typeof args.aspect_ratio === "string" &&
{args.aspect_ratio} }
+ {media?.image_url &&
}
+
+ );
+ }
+ if (packages.length) {
+ return {packages.map(pkg => {pkg.action === "uninstall" ? "Remove" : "Install"} {pkg.name} )} ;
+ }
+ if (plans.length || (args.sections_with_enough || []).some((item) => typeof item === "string")) {
+ return {plans.map(plan => {plan.label} {plan.text} )} {typeof tool.results === "string" &&
{tool.results} }
;
+ }
+ if (tool.display_projection?.file_paths?.length) {
+ return Files
{tool.display_projection.file_paths.map(path => {path}{tool.display_projection?.content_empty ? " (empty file)" : ""} )} ;
+ }
+ if (tool.display_projection?.entity_name || tool.display_projection?.summary) {
+ return {tool.display_projection.summary || "Entity activity"}
{tool.display_projection.entity_name &&
{tool.display_projection.record_count ?? ""} {tool.display_projection.entity_name} records }{tool.display_projection.writes_entities &&
Updated app data }
;
+ }
+ if (tool.user_input?.answers?.length) {
+ return Answer received {tool.user_input.answers.map((answer, index) => {[...(answer.selected_labels || []), answer.custom_text].filter(Boolean).join(", ")} )} ;
+ }
+ return typeof tool.results === "string" ? {tool.results}
: null;
+}
+
export default function ToolActivity({ tool }: { tool: ToolCall }) {
const pending = ["running", "pending"].includes(tool.status || "");
const failed = ["error", "stopped"].includes(tool.status || "");
@@ -28,14 +102,10 @@ export default function ToolActivity({ tool }: { tool: ToolCall }) {
)}
{activityLabel(tool)}
- {pending ? "Working" : failed ? "Failed" : "Done"}
+ {statusLabel(tool)}
- {tool.results && (
-
- {tool.results}
-
- )}
+
);
}
diff --git a/examples/white-label-minimal/lib/chat/socket-messages.ts b/examples/white-label-minimal/lib/chat/socket-messages.ts
index 6291807..f9e3e77 100644
--- a/examples/white-label-minimal/lib/chat/socket-messages.ts
+++ b/examples/white-label-minimal/lib/chat/socket-messages.ts
@@ -12,16 +12,20 @@ export function applyMessageUpdate(messages: Message[], update: AppUpdate): Mess
}
export function resolveImage(messages: Message[], image: ImageReady): Message[] {
- if (image.status !== "completed" || !image.image_url) return messages;
+ const resolvedUrl = image.status === "completed" ? image.image_url : null;
const replace = (text: string | null | undefined) =>
- text?.split(image.placeholder_url).join(image.image_url!) ?? text;
+ resolvedUrl ? text?.split(image.placeholder_url).join(resolvedUrl) ?? text : text;
return messages.map(message => ({
...message,
content: replace(message.content),
tool_calls: message.tool_calls?.map(tool => ({
...tool,
arguments_string: replace(tool.arguments_string),
- results: replace(tool.results),
+ results: typeof tool.results === "string"
+ ? replace(tool.results)
+ : tool.results?.placeholder_url === image.placeholder_url
+ ? { ...tool.results, status: image.status, image_url: image.image_url ?? null }
+ : tool.results,
})),
}));
}
diff --git a/examples/white-label-minimal/lib/types.ts b/examples/white-label-minimal/lib/types.ts
index aed9842..40a61c9 100644
--- a/examples/white-label-minimal/lib/types.ts
+++ b/examples/white-label-minimal/lib/types.ts
@@ -14,7 +14,11 @@ export type ToolCall = {
status?: string | null;
waiting_on?: { kind?: string | null } | null;
arguments_string?: string | null;
- results?: string | null;
+ results?: string | {
+ placeholder_url: string;
+ status: "pending" | "completed" | "failed";
+ image_url: string | null;
+ } | null;
auto_approved?: boolean | null;
mutation_applied?: boolean | null;
display_projection?: {
diff --git a/examples/white-label-minimal/package.json b/examples/white-label-minimal/package.json
index 71e97e6..cb1c09c 100644
--- a/examples/white-label-minimal/package.json
+++ b/examples/white-label-minimal/package.json
@@ -7,7 +7,7 @@
"build": "npm run db:generate && next build",
"start": "next start --hostname 127.0.0.1 --port 3001",
"typecheck": "next typegen && tsc --noEmit",
- "test": "tsx --conditions=react-server --test tests/server.test.ts tests/identity.test.ts && tsx --test tests/build-stream.test.ts tests/conversation.test.ts tests/questions.test.tsx",
+ "test": "tsx --conditions=react-server --test tests/server.test.ts tests/identity.test.ts && tsx --test tests/build-stream.test.ts tests/conversation.test.ts tests/questions.test.tsx tests/tool-activity.test.tsx",
"test:browser": "playwright test",
"db:generate": "prisma generate",
"postinstall": "npm run db:generate"
diff --git a/examples/white-label-minimal/tests/build-stream.test.ts b/examples/white-label-minimal/tests/build-stream.test.ts
index 2000328..f835ae0 100644
--- a/examples/white-label-minimal/tests/build-stream.test.ts
+++ b/examples/white-label-minimal/tests/build-stream.test.ts
@@ -70,8 +70,9 @@ test('cleanup prevents an in-flight snapshot from publishing into another app',
});
test('message replacement preserves omission/null and image completion resolves placeholders', () => {
- const previous = [{ id: 'm', role: 'assistant', content: '/placeholder', tool_calls: [{ id: 'old' }] }];
+ const previous = [{ id: 'm', role: 'assistant', content: '/placeholder', tool_calls: [{ id: 'old', results: { placeholder_url: '/placeholder', status: 'pending' as const, image_url: null } }] }];
assert.deepEqual(applyMessageUpdate(previous, { _last_msg: { id: 'm', content: null } }), [{ id: 'm', content: null }]);
assert.equal(resolveImage(previous, { placeholder_url: '/placeholder', status: 'completed', image_url: '/image' })[0].content, '/image');
+ assert.deepEqual(resolveImage(previous, { placeholder_url: '/placeholder', status: 'completed', image_url: '/image' })[0].tool_calls?.[0].results, { placeholder_url: '/placeholder', status: 'completed', image_url: '/image' });
assert.equal(previous[0].content, '/placeholder');
});
diff --git a/examples/white-label-minimal/tests/questions.test.tsx b/examples/white-label-minimal/tests/questions.test.tsx
index 95d7b98..cac67c3 100644
--- a/examples/white-label-minimal/tests/questions.test.tsx
+++ b/examples/white-label-minimal/tests/questions.test.tsx
@@ -20,6 +20,10 @@ test('approval keeps unreviewed arguments out of the card', () => {
assert.match(html, /Review proposed action/); assert.match(html, />Approve); assert.match(html, />Reject);
assert.doesNotMatch(html, /private-package/);
});
+test('package approval shows only the reviewed package names and operations', () => {
+ const html = render({ ...tool('approval', { packages: [{ name: 'zod', action: 'install', semver: 'private' }] }), name: 'install_npm_package' });
+ assert.match(html, /Install/); assert.match(html, /zod/); assert.doesNotMatch(html, /private/);
+});
test('unknown or malformed questions remain visible and cannot be blindly approved', () => {
for (const t of [tool('future', {}), tool('input', { fields: [] }), { ...tool('choice', {}), arguments_string: '{bad' }]) {
assert.equal(parseQuestion(t).kind, 'unknown');
diff --git a/examples/white-label-minimal/tests/tool-activity.test.tsx b/examples/white-label-minimal/tests/tool-activity.test.tsx
new file mode 100644
index 0000000..f818ef6
--- /dev/null
+++ b/examples/white-label-minimal/tests/tool-activity.test.tsx
@@ -0,0 +1,50 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import ToolActivity from "../components/ToolActivity";
+import type { ToolCall } from "../lib/types";
+
+function render(tool: ToolCall) {
+ return renderToStaticMarkup( );
+}
+
+test("renders reviewed file, package, and plan activity as tool widgets", () => {
+ const file = render({
+ name: "write_file",
+ status: "success",
+ display_projection: { file_paths: ["src/App.tsx"] },
+ });
+ const packages = render({
+ name: "install_npm_package",
+ status: "success",
+ arguments_string: JSON.stringify({ packages: [{ name: "zod", action: "install" }] }),
+ });
+ const plan = render({
+ name: "update_plan",
+ status: "success",
+ arguments_string: JSON.stringify({ updates: [{ section_label: "Scope", text: "Add accounts" }] }),
+ results: "Plan updated.",
+ });
+ assert.match(file, /src\/App.tsx/);
+ assert.match(packages, /Install/);
+ assert.match(packages, /zod/);
+ assert.match(plan, /Scope/);
+ assert.match(plan, /Add accounts/);
+});
+
+test("renders a reviewed generated-media result without raw prompt data", () => {
+ const html = render({
+ name: "generate_image",
+ status: "success",
+ arguments_string: JSON.stringify({ label: "Hero image", aspect_ratio: "16:9" }),
+ results: {
+ placeholder_url: "/__generating__/hero.png",
+ status: "completed",
+ image_url: "https://images.example/hero.png",
+ },
+ });
+ assert.match(html, /Generated/);
+ assert.match(html, /Hero image/);
+ assert.match(html, /images.example/);
+ assert.doesNotMatch(html, /prompt/);
+});