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) && ( @@ -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 )} 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, />ApproveReject { 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 && {mediaLabel}} +
+ ); + } + if (packages.length) { + return
Package changes
    {packages.map(pkg =>
  • {pkg.action === "uninstall" ? "Remove" : "Install"} {pkg.name}
  • )}
; + } + if (plans.length || (args.sections_with_enough || []).some((item) => typeof item === "string")) { + return
Plan update
    {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, />ApproveReject { + 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/); +});