From 75c79a43dae8a8ea6e4053abee73bb9bf81591fe Mon Sep 17 00:00:00 2001 From: Yoav Farhi Date: Sun, 29 Mar 2026 17:24:34 +0300 Subject: [PATCH] feat: add @base44/agent-ui package with headless tool-call renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package in packages/agent-ui/ providing headless React components for rendering agent tool calls in app chat UIs. Components: - ToolCallRenderer: Routes tool calls to the correct renderer via a built-in registry + custom renderer extension point - DefaultToolCall: Headless expand/collapse for generic tool calls, exposes parsed state via render prop - RequestUserConnection: Headless OAuth connect flow with popup management, polling, and state transitions via render prop All components are headless (render no DOM) — apps provide their own UI via render props, keeping full control over styling. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/agent-ui/package-lock.json | 69 ++++++ packages/agent-ui/package.json | 36 +++ packages/agent-ui/src/DefaultToolCall.tsx | 132 +++++++++++ packages/agent-ui/src/ToolCallRenderer.tsx | 80 +++++++ packages/agent-ui/src/index.ts | 16 ++ .../src/tools/RequestUserConnection.tsx | 221 ++++++++++++++++++ packages/agent-ui/src/types.ts | 30 +++ packages/agent-ui/tsconfig.json | 17 ++ 8 files changed, 601 insertions(+) create mode 100644 packages/agent-ui/package-lock.json create mode 100644 packages/agent-ui/package.json create mode 100644 packages/agent-ui/src/DefaultToolCall.tsx create mode 100644 packages/agent-ui/src/ToolCallRenderer.tsx create mode 100644 packages/agent-ui/src/index.ts create mode 100644 packages/agent-ui/src/tools/RequestUserConnection.tsx create mode 100644 packages/agent-ui/src/types.ts create mode 100644 packages/agent-ui/tsconfig.json diff --git a/packages/agent-ui/package-lock.json b/packages/agent-ui/package-lock.json new file mode 100644 index 0000000..b2487d7 --- /dev/null +++ b/packages/agent-ui/package-lock.json @@ -0,0 +1,69 @@ +{ + "name": "@base44/agent-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@base44/agent-ui", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/react": "^18.2.0", + "typescript": "^5.3.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/agent-ui/package.json b/packages/agent-ui/package.json new file mode 100644 index 0000000..6d4ab66 --- /dev/null +++ b/packages/agent-ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@base44/agent-ui", + "version": "0.1.0", + "description": "Headless React components for rendering Base44 agent tool calls", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "typescript": "^5.3.2" + }, + "keywords": [ + "base44", + "agent", + "ui", + "headless", + "tool-call" + ], + "author": "Base44", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/base44/javascript-sdk.git", + "directory": "packages/agent-ui" + } +} diff --git a/packages/agent-ui/src/DefaultToolCall.tsx b/packages/agent-ui/src/DefaultToolCall.tsx new file mode 100644 index 0000000..65c15a1 --- /dev/null +++ b/packages/agent-ui/src/DefaultToolCall.tsx @@ -0,0 +1,132 @@ +import React, { useState, useCallback, useMemo } from "react"; +import { ToolCallProps } from "./types.js"; + +/** Parsed and normalized state exposed by the headless DefaultToolCall. */ +export interface DefaultToolCallState { + /** Tool name. */ + name: string; + /** Human-readable tool name (snake_case → Title Case). */ + displayName: string; + /** Normalized status. */ + status: "pending" | "running" | "success" | "error"; + /** Whether the tool is still executing. */ + isLoading: boolean; + /** Whether the result indicates an error. */ + isError: boolean; + /** Whether the details section is expanded. */ + expanded: boolean; + /** Toggle the expanded state. */ + toggleExpanded: () => void; + /** Parsed arguments object, or null. */ + parsedArgs: Record | null; + /** Parsed result (object or string), or null. */ + parsedResults: unknown; + /** Raw arguments string. */ + rawArgs: string | undefined; + /** Raw results string or object. */ + rawResults: string | Record | undefined; +} + +export interface DefaultToolCallProps extends ToolCallProps { + /** Render prop — receives the headless state, returns your UI. */ + children?: (state: DefaultToolCallState) => React.ReactNode; +} + +function formatToolName(name: string): string { + return name + .replace(/([A-Z])/g, " $1") + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); +} + +function parseJson(value: string | Record | undefined): unknown { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function normalizeStatus( + status: string, + results: unknown +): "pending" | "running" | "success" | "error" { + if (status === "running" || status === "in_progress") return "running"; + if (status === "error" || status === "failed") return "error"; + if (status === "success" || status === "completed") { + // Check if the result itself indicates failure + if (typeof results === "string" && /error|failed/i.test(results)) return "error"; + if ( + typeof results === "object" && + results !== null && + (results as Record).success === false + ) + return "error"; + return "success"; + } + return "pending"; +} + +/** + * Headless component for rendering a generic tool call with expand/collapse. + * + * Exposes parsed state via a render prop. If no `children` render prop is + * provided, renders nothing (truly headless). + * + * @example + * ```tsx + * + * {({ displayName, status, expanded, toggleExpanded, parsedArgs, parsedResults }) => ( + *
+ * + * {expanded && ( + *
{JSON.stringify(parsedResults, null, 2)}
+ * )} + *
+ * )} + *
+ * ``` + */ +export function DefaultToolCall({ toolCall, children }: DefaultToolCallProps) { + const [expanded, setExpanded] = useState(false); + + const toggleExpanded = useCallback(() => setExpanded((e) => !e), []); + + const parsedArgs = useMemo( + () => parseJson(toolCall.arguments_string) as Record | null, + [toolCall.arguments_string] + ); + + const parsedResults = useMemo( + () => parseJson(toolCall.results), + [toolCall.results] + ); + + const status = useMemo( + () => normalizeStatus(toolCall.status, parsedResults), + [toolCall.status, parsedResults] + ); + + const state: DefaultToolCallState = { + name: toolCall.name, + displayName: formatToolName(toolCall.name), + status, + isLoading: status === "running", + isError: status === "error", + expanded, + toggleExpanded, + parsedArgs, + parsedResults, + rawArgs: toolCall.arguments_string, + rawResults: toolCall.results, + }; + + if (!children) return null; + return <>{children(state)}; +} diff --git a/packages/agent-ui/src/ToolCallRenderer.tsx b/packages/agent-ui/src/ToolCallRenderer.tsx new file mode 100644 index 0000000..a37f250 --- /dev/null +++ b/packages/agent-ui/src/ToolCallRenderer.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { ToolCall, ToolCallProps } from "./types.js"; +import { DefaultToolCall } from "./DefaultToolCall.js"; +import { RequestUserConnection } from "./tools/RequestUserConnection.js"; + +/** + * Built-in registry of platform tool names to headless renderer components. + * + * When adding a new platform tool that needs custom UI, register it here. + */ +const BUILT_IN_RENDERERS: Record< + string, + React.ComponentType +> = { + request_user_connection: RequestUserConnection, +}; + +export interface ToolCallRendererProps { + /** The tool call data from the agent message. */ + toolCall: ToolCall; + /** The current app ID. */ + appId: string; + /** + * App-specific custom renderers that take priority over built-in ones. + * Map of tool name → React component. + */ + customRenderers?: Record>; + /** + * Override the default fallback component for unrecognized tool calls. + * Defaults to `DefaultToolCall`. + */ + fallback?: React.ComponentType; + /** + * Render prop for providing custom UI around the headless component's state. + * If not provided, the matched component renders directly. + */ + children?: ( + Component: React.ComponentType, + props: ToolCallProps + ) => React.ReactNode; +} + +/** + * Routes a tool call to the appropriate renderer component. + * + * Resolution order: + * 1. `customRenderers[toolCall.name]` (app-specific) + * 2. Built-in platform renderers (e.g., `request_user_connection`) + * 3. `fallback` prop or `DefaultToolCall` + * + * @example + * ```tsx + * import { ToolCallRenderer } from '@base44/agent-ui'; + * + * {message.tool_calls?.map((tc) => ( + * + * ))} + * ``` + */ +export function ToolCallRenderer({ + toolCall, + appId, + customRenderers, + fallback, + children, +}: ToolCallRendererProps) { + const Component = + customRenderers?.[toolCall.name] ?? + BUILT_IN_RENDERERS[toolCall.name] ?? + fallback ?? + DefaultToolCall; + + const props: ToolCallProps = { toolCall, appId }; + + if (children) { + return <>{children(Component, props)}; + } + + return ; +} diff --git a/packages/agent-ui/src/index.ts b/packages/agent-ui/src/index.ts new file mode 100644 index 0000000..d416662 --- /dev/null +++ b/packages/agent-ui/src/index.ts @@ -0,0 +1,16 @@ +// Components +export { ToolCallRenderer } from "./ToolCallRenderer.js"; +export type { ToolCallRendererProps } from "./ToolCallRenderer.js"; + +export { DefaultToolCall } from "./DefaultToolCall.js"; +export type { DefaultToolCallState, DefaultToolCallProps } from "./DefaultToolCall.js"; + +export { RequestUserConnection } from "./tools/RequestUserConnection.js"; +export type { + RequestUserConnectionState, + RequestUserConnectionProps, + ConnectorInfo, +} from "./tools/RequestUserConnection.js"; + +// Types +export type { ToolCall, ToolCallProps } from "./types.js"; diff --git a/packages/agent-ui/src/tools/RequestUserConnection.tsx b/packages/agent-ui/src/tools/RequestUserConnection.tsx new file mode 100644 index 0000000..227113e --- /dev/null +++ b/packages/agent-ui/src/tools/RequestUserConnection.tsx @@ -0,0 +1,221 @@ +import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { ToolCallProps } from "../types.js"; + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 40; // 2 minutes + +/** Parsed connector info from the tool result. */ +export interface ConnectorInfo { + action: string; + integration_type: string; + display_name: string; + connect_url: string; + connector_id: string; + app_id: string; +} + +/** State exposed by the headless RequestUserConnection component. */ +export interface RequestUserConnectionState { + /** Connector info parsed from the tool result, or null if parsing failed. */ + connectorInfo: ConnectorInfo | null; + /** Integration type from args (fallback if result not available). */ + integrationType: string | undefined; + /** Human-readable service name. */ + displayName: string; + /** Current connection state. */ + status: "idle" | "connecting" | "success" | "error" | "unavailable"; + /** Error message when status is "error". */ + errorMessage: string; + /** The tool call's execution status. */ + toolStatus: string; + /** Whether the tool call itself failed (no connector info). */ + toolFailed: boolean; + /** Raw error/message from tool failure. */ + toolErrorMessage: string; + /** Initiate the OAuth connection flow (opens popup). */ + connect: () => void; +} + +export interface RequestUserConnectionProps extends ToolCallProps { + /** Render prop — receives the headless state, returns your UI. */ + children?: (state: RequestUserConnectionState) => React.ReactNode; +} + +function parseConnectorInfo(results: string | Record | undefined): ConnectorInfo | null { + if (!results) return null; + try { + let parsed = results; + // Unwrap string layers (may be double-encoded) + while (typeof parsed === "string") { + parsed = JSON.parse(parsed); + } + if (typeof parsed === "object" && parsed !== null && (parsed as Record).action === "connect_user_account") { + return parsed as unknown as ConnectorInfo; + } + } catch { + // Not valid JSON + } + return null; +} + +/** + * Headless component for the `request_user_connection` tool call. + * + * Manages OAuth popup lifecycle: opens a popup with the `connect_url`, + * polls for completion, and exposes state transitions via a render prop. + * + * @example + * ```tsx + * + * {({ displayName, status, connect, errorMessage }) => ( + *
+ * {status === 'idle' && ( + * + * )} + * {status === 'connecting' && Waiting for authorization...} + * {status === 'success' && Connected!} + * {status === 'error' && ( + * <> + * {errorMessage} + * + * + * )} + *
+ * )} + *
+ * ``` + */ +export function RequestUserConnection({ toolCall, appId, children }: RequestUserConnectionProps) { + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const cleanupRef = useRef<(() => void) | null>(null); + + const connectorInfo = useMemo(() => parseConnectorInfo(toolCall.results), [toolCall.results]); + + const parsedArgs = useMemo(() => { + try { + return toolCall.arguments_string ? JSON.parse(toolCall.arguments_string) : null; + } catch { + return null; + } + }, [toolCall.arguments_string]); + + const integrationType = connectorInfo?.integration_type ?? parsedArgs?.integration_type; + const displayName = connectorInfo?.display_name ?? integrationType ?? "service"; + + // Tool-level failure detection + const toolFailed = !connectorInfo && (toolCall.status === "error" || toolCall.status === "success"); + const toolErrorMessage = toolFailed + ? (typeof toolCall.results === "string" ? toolCall.results : "Connection setup failed.") + : ""; + + useEffect(() => { + return () => { + if (cleanupRef.current) cleanupRef.current(); + }; + }, []); + + const connect = useCallback(() => { + if (!connectorInfo?.connect_url) return; + + setStatus("connecting"); + setErrorMessage(""); + + const { connect_url, app_id } = connectorInfo; + + // Open OAuth popup + const width = 600; + const height = 700; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + const popup = window.open( + connect_url, + "oauth_popup", + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes` + ); + + if (!popup) { + setStatus("error"); + setErrorMessage("Popup was blocked. Please allow popups and try again."); + return; + } + + let attempts = 0; + + const checkStatus = async () => { + try { + const res = await fetch( + `/api/apps/${app_id}/external-auth/status?integration_type=${encodeURIComponent(integrationType!)}&connection_id=`, + { credentials: "include" } + ); + if (res.ok) { + const data = await res.json(); + if (data?.status === "ACTIVE") return "success" as const; + if (data?.status === "FAILED") return "failed" as const; + } + } catch { + // Polling error — keep trying + } + return "pending" as const; + }; + + const pollTimer = setInterval(async () => { + attempts++; + + if (popup.closed) { + clearInterval(pollTimer); + // Give the callback a moment to process + setTimeout(async () => { + const result = await checkStatus(); + if (result === "success") { + setStatus("success"); + } else { + setStatus("error"); + setErrorMessage("Connection was not completed. Please try again."); + } + }, 1500); + return; + } + + if (attempts >= MAX_POLL_ATTEMPTS) { + clearInterval(pollTimer); + popup.close(); + setStatus("error"); + setErrorMessage("Connection timed out. Please try again."); + return; + } + + const result = await checkStatus(); + if (result === "success") { + clearInterval(pollTimer); + popup.close(); + setStatus("success"); + } else if (result === "failed") { + clearInterval(pollTimer); + popup.close(); + setStatus("error"); + setErrorMessage("Connection failed. Please try again."); + } + }, POLL_INTERVAL_MS); + + cleanupRef.current = () => { + clearInterval(pollTimer); + if (popup && !popup.closed) popup.close(); + }; + }, [connectorInfo, integrationType]); + + const state: RequestUserConnectionState = { + connectorInfo, + integrationType, + displayName, + status: toolFailed ? "unavailable" : status, + errorMessage, + toolStatus: toolCall.status, + toolFailed, + toolErrorMessage, + connect, + }; + + if (!children) return null; + return <>{children(state)}; +} diff --git a/packages/agent-ui/src/types.ts b/packages/agent-ui/src/types.ts new file mode 100644 index 0000000..d5b76f6 --- /dev/null +++ b/packages/agent-ui/src/types.ts @@ -0,0 +1,30 @@ +/** + * A tool call from an agent message. + * + * Matches the shape of `AgentMessageToolCall` from `@base44/sdk`. + */ +export interface ToolCall { + /** Tool call ID. */ + id: string; + /** Name of the tool called. */ + name: string; + /** Arguments passed to the tool as JSON string. */ + arguments_string?: string; + /** Status of the tool call. */ + status: "running" | "success" | "error" | "stopped" | string; + /** Results from the tool call (JSON string or object). */ + results?: string | Record; +} + +/** + * Props passed to every tool-call renderer component. + * + * Headless components receive these props and expose state/callbacks + * via render props or children. They render no DOM of their own. + */ +export interface ToolCallProps { + /** The tool call data from the agent message. */ + toolCall: ToolCall; + /** The current app ID (needed for API calls like OAuth initiation). */ + appId: string; +} diff --git a/packages/agent-ui/tsconfig.json b/packages/agent-ui/tsconfig.json new file mode 100644 index 0000000..82746ee --- /dev/null +++ b/packages/agent-ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "esnext", + "lib": ["dom", "esnext"], + "declaration": true, + "outDir": "./dist", + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}