From 1c1f8ba9d3fc6d2fe364370291d805fe86132a6f Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 6 May 2026 17:21:00 +0530 Subject: [PATCH 1/2] feat(widgets): unified declarative IR + bundled widget system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a declarative widget IR with reactive runtime, unifies bundled integration widgets and IR widgets behind a single Widget table + tag (), and introduces sub-agents for authoring. Schema: - Widget table with engine: DECLARATIVE | BUNDLED discriminator - Auto-seed BUNDLED rows on integration install (FK CASCADE on disconnect) - Per-user state persistence for declarative widgets (persist: true) Runtime (v0): - Reactive store via useSyncExternalStore (no external deps) - Mustache + filter expression evaluator with uuid/now built-ins - 13 closed block primitives (Container/Text/Heading/Markdown/Badge/Card/ Button/Tabs/List/Table/Form/Modal/EmptyState) - Action dispatcher: setState/mutateState/openModal/closeModal/runRequest - Inline config attribute on the embed tag overrides defaults per-render Agent surface: - widget_builder + skill_builder sub-agents (focused tool lists, focused prompts) - Parent retains read-only list_widgets / get_widget; delegates authoring - create_widget tool validates IR end-to-end, returns slug + render tag Render path: - Single tiptap node parses , dispatches by engine - DECLARATIVE → WidgetRuntime; BUNDLED → BundledWidgetRenderer - Hybrid auth route: API key OR session cookie Known v0 gaps (request executor lands in v1.1): - ai.text / ai.structured / integration_action requests render placeholder - Scheduled cron / TTL cache policies declared but not honored Co-Authored-By: Claude Opus 4.7 (1M context) --- .../conversation/editor-extensions.tsx | 2 + .../extensions/widget-node-extension.tsx | 2 +- apps/webapp/app/components/overview/types.ts | 2 +- .../widgets/BundledWidgetRenderer.tsx | 111 +++ .../app/components/widgets/CoreWidgetView.tsx | 265 +++++++ .../components/widgets/core-widget-node.tsx | 107 +++ .../widgets/runtime/WidgetRuntime.tsx | 114 +++ .../app/components/widgets/runtime/actions.ts | 101 +++ .../widgets/runtime/blocks/index.tsx | 710 ++++++++++++++++++ .../components/widgets/runtime/context.tsx | 129 ++++ .../components/widgets/runtime/expression.ts | 251 +++++++ .../app/components/widgets/runtime/filters.ts | 222 ++++++ .../app/components/widgets/runtime/index.ts | 11 + .../app/components/widgets/runtime/store.ts | 279 +++++++ .../app/routes/api.v1.conversation._index.tsx | 6 + .../app/routes/api.v1.integration_account.tsx | 36 + apps/webapp/app/routes/api.v1.voice-turn.tsx | 6 + .../app/routes/api.v1.widgets.$id.state.tsx | 45 ++ apps/webapp/app/routes/api.v1.widgets.$id.tsx | 91 +++ apps/webapp/app/services/agent/agents/core.ts | 49 +- .../services/agent/agents/skill-builder.ts | 98 +++ .../services/agent/agents/widget-builder.ts | 170 +++++ apps/webapp/app/services/agent/context.ts | 13 +- .../app/services/agent/no-stream-process.ts | 6 + .../services/agent/prompts/capabilities.ts | 21 +- .../app/services/agent/tools/widget-tools.ts | 191 +++++ .../integrations/integration-runner.ts | 17 + apps/webapp/app/services/postAuth.server.ts | 18 +- apps/webapp/app/services/widgets.server.ts | 2 +- apps/webapp/app/services/widgets/defaults.ts | 358 +++++++++ apps/webapp/app/services/widgets/validate.ts | 298 ++++++++ .../app/services/widgets/widget.server.ts | 489 ++++++++++++ .../migration.sql | 38 + .../migration.sql | 16 + packages/database/prisma/schema.prisma | 79 ++ packages/types/package.json | 3 +- packages/types/src/index.ts | 1 + packages/types/src/integration.ts | 70 +- packages/types/src/widget/bundled.ts | 64 ++ packages/types/src/widget/index.ts | 17 + packages/types/src/widget/ir.ts | 435 +++++++++++ pnpm-lock.yaml | 3 + 42 files changed, 4871 insertions(+), 75 deletions(-) create mode 100644 apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx create mode 100644 apps/webapp/app/components/widgets/CoreWidgetView.tsx create mode 100644 apps/webapp/app/components/widgets/core-widget-node.tsx create mode 100644 apps/webapp/app/components/widgets/runtime/WidgetRuntime.tsx create mode 100644 apps/webapp/app/components/widgets/runtime/actions.ts create mode 100644 apps/webapp/app/components/widgets/runtime/blocks/index.tsx create mode 100644 apps/webapp/app/components/widgets/runtime/context.tsx create mode 100644 apps/webapp/app/components/widgets/runtime/expression.ts create mode 100644 apps/webapp/app/components/widgets/runtime/filters.ts create mode 100644 apps/webapp/app/components/widgets/runtime/index.ts create mode 100644 apps/webapp/app/components/widgets/runtime/store.ts create mode 100644 apps/webapp/app/routes/api.v1.widgets.$id.state.tsx create mode 100644 apps/webapp/app/routes/api.v1.widgets.$id.tsx create mode 100644 apps/webapp/app/services/agent/agents/skill-builder.ts create mode 100644 apps/webapp/app/services/agent/agents/widget-builder.ts create mode 100644 apps/webapp/app/services/agent/tools/widget-tools.ts create mode 100644 apps/webapp/app/services/widgets/defaults.ts create mode 100644 apps/webapp/app/services/widgets/validate.ts create mode 100644 apps/webapp/app/services/widgets/widget.server.ts create mode 100644 packages/database/prisma/migrations/20260506000000_add_widget_table/migration.sql create mode 100644 packages/database/prisma/migrations/20260506100000_unify_widget_engine/migration.sql create mode 100644 packages/types/src/widget/bundled.ts create mode 100644 packages/types/src/widget/index.ts create mode 100644 packages/types/src/widget/ir.ts diff --git a/apps/webapp/app/components/conversation/editor-extensions.tsx b/apps/webapp/app/components/conversation/editor-extensions.tsx index 32e0e5cc0..f844131ea 100644 --- a/apps/webapp/app/components/conversation/editor-extensions.tsx +++ b/apps/webapp/app/components/conversation/editor-extensions.tsx @@ -14,6 +14,7 @@ import { all, createLowlight } from "lowlight"; import { mergeAttributes } from "@tiptap/core"; import { type Extension } from "@tiptap/react"; import { Markdown } from "tiptap-markdown"; +import { CoreWidgetNode } from "~/components/widgets/core-widget-node"; // create a lowlight instance with all languages loaded export const lowlight = createLowlight(all); @@ -149,4 +150,5 @@ export const extensionsForConversation = [ lowlight, }), Markdown, + CoreWidgetNode, ]; diff --git a/apps/webapp/app/components/editor/extensions/widget-node-extension.tsx b/apps/webapp/app/components/editor/extensions/widget-node-extension.tsx index 01fbf4abb..02fbefa40 100644 --- a/apps/webapp/app/components/editor/extensions/widget-node-extension.tsx +++ b/apps/webapp/app/components/editor/extensions/widget-node-extension.tsx @@ -10,7 +10,7 @@ import { Node } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; import { Settings, X, Loader2 } from "lucide-react"; -import type { WidgetConfigField } from "@redplanethq/types"; +import type { WidgetConfigField } from "@core/types"; import { loadWidgetBundle } from "~/utils/widget-loader.client"; import type { WidgetOption } from "~/components/overview/types"; diff --git a/apps/webapp/app/components/overview/types.ts b/apps/webapp/app/components/overview/types.ts index 071efb445..05937873e 100644 --- a/apps/webapp/app/components/overview/types.ts +++ b/apps/webapp/app/components/overview/types.ts @@ -1,4 +1,4 @@ -import type { WidgetConfigField } from "@redplanethq/types"; +import type { WidgetConfigField } from "@core/types"; export interface OverviewCell { id: string; diff --git a/apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx b/apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx new file mode 100644 index 000000000..c9df763f7 --- /dev/null +++ b/apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx @@ -0,0 +1,111 @@ +/** + * BundledWidgetRenderer — mounts a vendor-shipped (github/spotify/etc.) + * widget bundle. + * + * Loads the integration's compiled `frontendUrl`, finds the widget by + * `bundledWidgetSlug`, calls its `render(ctx)` with the workspace's widget + * PAT and the user's connected account, and mounts the returned React + * component. + * + * Mirrors the rendering logic that previously lived in + * `apps/webapp/app/components/editor/extensions/widget-node-extension.tsx`, + * extracted here so the unified `CoreWidgetView` can dispatch BUNDLED rows + * to it without duplicating bundle-loading code. + */ + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { loadWidgetBundle } from "~/utils/widget-loader.client"; + +type WidgetComponent = React.ComponentType>; + +export interface BundledWidgetProps { + bundledWidgetSlug: string; + frontendUrl: string; + integrationAccountId: string; + integrationSlug: string; + integrationName: string; + pat: string; + baseUrl: string; + configValues: Record; +} + +export function BundledWidgetRenderer({ + bundledWidgetSlug, + frontendUrl, + integrationAccountId, + integrationSlug, + integrationName, + pat, + baseUrl, + configValues, +}: BundledWidgetProps) { + const [Component, setComponent] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setComponent(null); + setError(null); + + (async () => { + try { + const { widgets } = await loadWidgetBundle(frontendUrl); + if (cancelled) return; + const widget = widgets.find((w) => w.slug === bundledWidgetSlug); + if (!widget) { + if (!cancelled) { + setError(`Widget "${bundledWidgetSlug}" not found in bundle`); + } + return; + } + const ctx = { + placement: "webapp" as const, + pat, + accounts: [ + { + id: integrationAccountId, + slug: integrationSlug, + name: integrationName, + }, + ], + baseUrl, + config: configValues, + }; + const Comp = await widget.render(ctx); + if (!cancelled) { + setComponent(() => Comp as WidgetComponent); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [ + frontendUrl, + bundledWidgetSlug, + pat, + baseUrl, + integrationAccountId, + integrationSlug, + integrationName, + JSON.stringify(configValues), + ]); + + if (error) { + return

{error}

; + } + if (!Component) { + return ( +
+ +
+ ); + } + return ; +} diff --git a/apps/webapp/app/components/widgets/CoreWidgetView.tsx b/apps/webapp/app/components/widgets/CoreWidgetView.tsx new file mode 100644 index 000000000..f452dbf79 --- /dev/null +++ b/apps/webapp/app/components/widgets/CoreWidgetView.tsx @@ -0,0 +1,265 @@ +/** + * CoreWidgetView — unified chat-side renderer for the Widget table. + * + * Fetches by slug or uuid, then dispatches based on `engine`: + * DECLARATIVE → + * BUNDLED → + * + * One tag (``), one entry point, one fetch. + */ + +import { useEffect, useState } from "react"; +import { Loader2, AlertCircle, Box } from "lucide-react"; +import type { WidgetIR } from "@core/types"; +import { WidgetRuntime } from "./runtime"; +import { BundledWidgetRenderer } from "./BundledWidgetRenderer"; + +interface BundledMeta { + integrationSlug: string; + integrationName: string; + integrationIcon: string | null; + frontendUrl: string | null; + configSchema: Array<{ + key: string; + label: string; + type: "input" | "select"; + placeholder?: string; + required?: boolean; + options?: Array<{ label: string; value: string }>; + default?: string; + }>; +} + +interface WidgetEnvelope { + id: string; + slug: string; + name: string; + description: string | null; + icon: string | null; + kind: "DEFAULT" | "USER"; + engine: "DECLARATIVE" | "BUNDLED"; + version: number; + + // Declarative + spec: WidgetIR | null; + state: Record | null; + sourceSlug: string | null; + + // Bundled + integrationAccountId: string | null; + bundledWidgetSlug: string | null; + configValues: Record | null; + bundled: BundledMeta | null; + pat: string | null; + baseUrl: string | null; +} + +interface FetchState { + status: "loading" | "ok" | "error"; + widget?: WidgetEnvelope; + error?: string; +} + +export function CoreWidgetView({ + widgetRef, + configOverride, +}: { + widgetRef: string; + /** + * Inline config from the embed tag — overrides per-key on top of the + * row's stored config (BUNDLED) or the IR's config[].default (DECLARATIVE). + */ + configOverride?: Record; +}) { + const [fetchState, setFetchState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch( + `/api/v1/widgets/${encodeURIComponent(widgetRef)}`, + { + credentials: "include", + headers: { Accept: "application/json" }, + }, + ); + if (!res.ok) { + const body = await res.text(); + if (!cancelled) { + setFetchState({ + status: "error", + error: `HTTP ${res.status}: ${body.slice(0, 200)}`, + }); + } + return; + } + const data = (await res.json()) as { widget: WidgetEnvelope }; + if (!cancelled) { + setFetchState({ status: "ok", widget: data.widget }); + } + } catch (err) { + if (!cancelled) { + setFetchState({ + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [widgetRef]); + + if (fetchState.status === "loading") { + return ( +
+ +
+ ); + } + + if (fetchState.status === "error" || !fetchState.widget) { + return ( +
+ +
+
Widget unavailable
+
+ {fetchState.error ?? "unknown error"} +
+
+
+ ); + } + + return ( + + ); +} + +function WidgetFrame({ + widget, + configOverride, +}: { + widget: WidgetEnvelope; + configOverride?: Record; +}) { + return ( +
+
+
+ + {widget.name} + · + {widget.slug} + {widget.kind === "DEFAULT" && ( + + default + + )} + + {widget.engine === "BUNDLED" ? "bundled" : "ir"} + +
+
v{widget.version}
+
+ +
+ +
+
+ ); +} + +function WidgetBody({ + widget, + configOverride, +}: { + widget: WidgetEnvelope; + configOverride?: Record; +}) { + if (widget.engine === "DECLARATIVE") { + if (!widget.spec) { + return ( +

+ Declarative widget has no spec — data corruption. +

+ ); + } + const onStatePersist = + widget.kind === "USER" + ? (state: Record) => { + void fetch(`/api/v1/widgets/${encodeURIComponent(widget.id)}/state`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state }), + }); + } + : undefined; + + // Inline config overrides take precedence over IR config[].default. + return ( + + ); + } + + // BUNDLED + if ( + !widget.bundled || + !widget.bundled.frontendUrl || + !widget.integrationAccountId || + !widget.bundledWidgetSlug || + !widget.pat || + !widget.baseUrl + ) { + return ( +

+ Bundled widget missing required fields (integration disconnected, or + bundle not deployed). +

+ ); + } + + // Merge: stored row config + per-embed override (override wins per key). + // Bundled widgets' configSchema is string-typed (input | select), so we + // coerce override values to strings here. Declarative widgets keep richer + // types via WidgetRuntime.initialConfig. + const mergedConfig: Record = { + ...(widget.configValues ?? {}), + }; + if (configOverride) { + for (const [k, v] of Object.entries(configOverride)) { + mergedConfig[k] = v == null ? "" : String(v); + } + } + + return ( + + ); +} diff --git a/apps/webapp/app/components/widgets/core-widget-node.tsx b/apps/webapp/app/components/widgets/core-widget-node.tsx new file mode 100644 index 000000000..15371fe2d --- /dev/null +++ b/apps/webapp/app/components/widgets/core-widget-node.tsx @@ -0,0 +1,107 @@ +/** + * Tiptap Node for declarative IR widgets. + * + * Parses `` (preferred) or `` + * from chat HTML into an atom node that renders the widget via + * `CoreWidgetView`. The agent emits the slug form because slugs are + * human-readable, agent-friendly, and round-trip cleanly through the model + * — uuids are easy to mistranscribe and meaningless to read. + * + * Distinct from the existing `WidgetNode` + * (`apps/webapp/app/components/editor/extensions/widget-node-extension.tsx`) + * which mounts bundled-integration widgets via `loadWidgetBundle()`. That + * node is for vendor-shipped React bundles; this one is for IR widgets + * persisted in the Widget table. + */ + +import { Node } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; +import type { NodeViewProps } from "@tiptap/react"; +import { CoreWidgetView } from "./CoreWidgetView"; + +function CoreWidgetNodeView({ node }: NodeViewProps) { + const slug = node.attrs.slug as string | null; + const id = node.attrs.id as string | null; + const configRaw = node.attrs.config as string | null; + const ref = slug || id; + + if (!ref) { + return ( + +
+ <core-widget> missing required `slug` (or `id`) attribute +
+
+ ); + } + + // Parse the optional config JSON. Bad JSON / wrong shape is a soft fail + // (render proceeds with no overrides). Values can be string | number | + // boolean | null — honest typing, not the narrower string-only contract + // we used to claim. + let configOverride: Record | undefined; + if (configRaw) { + try { + const parsed = JSON.parse(configRaw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + configOverride = parsed as Record; + } + } catch { + configOverride = undefined; + } + } + + return ( + + + + ); +} + +export const CoreWidgetNode = Node.create({ + name: "coreWidget", + group: "block", + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + slug: { default: null }, + id: { default: null }, + /** + * Optional JSON string of per-render config overrides. + * + * For DECLARATIVE widgets: overrides values in `config[].default`. + * For BUNDLED widgets: merged on top of the row's stored configValues. + * + * Use single-quoted attribute around double-quoted JSON: + * + */ + config: { default: null }, + }; + }, + + parseHTML() { + return [ + // Lower-cased custom element form (what agents emit) + { tag: "core-widget" }, + // CamelCase fallback for editor-internal serialization + { tag: "coreWidget" }, + ]; + }, + + renderHTML({ node }) { + const attrs: Record = {}; + if (node.attrs.slug) attrs.slug = String(node.attrs.slug); + if (node.attrs.id) attrs.id = String(node.attrs.id); + if (node.attrs.config) attrs.config = String(node.attrs.config); + return ["core-widget", attrs]; + }, + + addNodeView() { + return ReactNodeViewRenderer(CoreWidgetNodeView, { + stopEvent: () => false, + }); + }, +}); diff --git a/apps/webapp/app/components/widgets/runtime/WidgetRuntime.tsx b/apps/webapp/app/components/widgets/runtime/WidgetRuntime.tsx new file mode 100644 index 000000000..0ff4ba640 --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/WidgetRuntime.tsx @@ -0,0 +1,114 @@ +/** + * WidgetRuntime — top-level orchestrator for v0. + * + * Wraps a widget IR in: + * - a `WidgetStore` (state, requests, derived) + * - a RuntimeProvider so block renderers can read/dispatch + * - the block-tree render + * - an optional persistence hook for `state[].persist: true` fields + * + * Persistence: pass `onStatePersist` to opt in. The runtime debounces by + * ~600ms after each commit and invokes the callback with only the persisted + * subset of state. Pass undefined for DEFAULT widgets / previews. + */ + +import { useEffect, useMemo, useRef } from "react"; +import type { WidgetIR } from "@core/types"; +import { WidgetStore } from "./store"; +import { RuntimeProvider } from "./context"; +import { BlockRenderer } from "./blocks"; + +export interface WidgetRuntimeProps { + ir: WidgetIR; + initialState?: Record; + initialConfig?: Record; + /** Called with persisted state after debounce. Pass undefined to disable. */ + onStatePersist?: (state: Record) => void; +} + +const PERSIST_DEBOUNCE_MS = 600; + +export function WidgetRuntime({ + ir, + initialState, + initialConfig, + onStatePersist, +}: WidgetRuntimeProps) { + const store = useMemo( + () => + new WidgetStore(ir, { + initialState: initialState ?? undefined, + initialConfig: initialConfig ?? undefined, + }), + // Recreate store on IR object identity change. Cheap — widgets remount. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ir], + ); + + const persistedKeys = useMemo( + () => (ir.state ?? []).filter((s) => s.persist).map((s) => s.id), + [ir], + ); + + // Debounced state persistence — subscribes to the store, extracts the + // persisted-state subset on each commit, and POSTs after the debounce + // window. Skips initial mount commit (the snapshot equals what we just + // hydrated from the server, no point round-tripping it). + const initialSnapshotRef = useRef(store.getSnapshot()); + const lastPersistedRef = useRef(""); + + useEffect(() => { + if (!onStatePersist || persistedKeys.length === 0) return; + + let timer: ReturnType | null = null; + + const computeSubset = ( + state: Record, + ): Record => { + const out: Record = {}; + for (const key of persistedKeys) { + if (key in state) out[key] = state[key]; + } + return out; + }; + + // Seed lastPersisted with the initial snapshot so the first commit is + // only persisted if it actually changes from what we hydrated with. + lastPersistedRef.current = JSON.stringify( + computeSubset(initialSnapshotRef.current.state), + ); + + const unsubscribe = store.subscribe(() => { + const snap = store.getSnapshot(); + const subset = computeSubset(snap.state); + const serialized = JSON.stringify(subset); + if (serialized === lastPersistedRef.current) return; + + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + lastPersistedRef.current = serialized; + try { + onStatePersist(subset); + } catch { + // Caller's handler is responsible for surfacing errors. The runtime + // doesn't crash on a failed persist — the user keeps editing. + } + }, PERSIST_DEBOUNCE_MS); + }); + + return () => { + if (timer) clearTimeout(timer); + unsubscribe(); + }; + }, [store, onStatePersist, persistedKeys]); + + return ( + +
+ {ir.blocks.map((block, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/webapp/app/components/widgets/runtime/actions.ts b/apps/webapp/app/components/widgets/runtime/actions.ts new file mode 100644 index 000000000..6fcb64fd4 --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/actions.ts @@ -0,0 +1,101 @@ +/** + * Action dispatcher — v0. + * + * Looks up an action by id in the IR, evaluates each `do` op against a + * scope (state + requests + derived + config + caller-supplied args/event), + * and applies it to the store. + * + * Supported ops: + * - setState set a state field to a value (templated allowed) + * - mutateState append/prepend/remove_where/patch_where/set on an array state + * - openModal flip a Modal block's open flag to true + * - closeModal flip it to false + * - runRequest v0: no-op (logs warning). v1.1 wires this to the request executor. + * + * Confirm prompts are shown via window.confirm in v0 — good enough for + * shipping the architecture; v1.3 will replace with an in-widget dialog. + */ + +import type { ActionOp, WidgetAction, WidgetIR } from "@core/types"; +import { evaluate, evaluateValue, truthy, type Scope } from "./expression"; +import type { WidgetStore } from "./store"; + +export interface DispatchContext { + store: WidgetStore; + ir: WidgetIR; + /** Inline args from the block (e.g. {{ id }} on List item onClick). */ + args?: Record; + /** Event payload (e.g. form values on submit). */ + event?: Record; + /** Whether to bypass confirm prompts (used for chained ops, internal calls). */ + skipConfirm?: boolean; +} + +export function dispatchAction(actionId: string, ctx: DispatchContext): void { + const action = (ctx.ir.actions ?? []).find((a) => a.id === actionId); + if (!action) { + if (typeof console !== "undefined") { + console.warn(`[widget-runtime] unknown action "${actionId}"`); + } + return; + } + if (action.confirm && !ctx.skipConfirm && typeof window !== "undefined") { + if (!window.confirm(action.confirm)) return; + } + for (const op of action.do) { + runOp(op, action, ctx); + } +} + +function runOp(op: ActionOp, action: WidgetAction, ctx: DispatchContext): void { + const scope: Scope = { + ...ctx.store.scope(), + args: ctx.args ?? {}, + event: ctx.event ?? {}, + }; + + switch (op.op) { + case "setState": { + const value = evaluateValue(op.value, scope); + ctx.store.setState(op.state, value); + break; + } + case "mutateState": { + const value = evaluateValue(op.value, scope); + const where = op.where + ? buildPredicate(op.where, scope, ctx) + : undefined; + ctx.store.mutateArray(op.state, op.mutation, { value, where }); + break; + } + case "openModal": + ctx.store.setModalOpen(op.block, true); + break; + case "closeModal": + ctx.store.setModalOpen(op.block, false); + break; + case "runRequest": + // v0: not implemented. v1.1 will dispatch to the request executor. + if (typeof console !== "undefined") { + console.warn( + `[widget-runtime] runRequest("${op.request}") — request execution lands in v1.1`, + ); + } + break; + } +} + +/** + * Build an item predicate from a templated where-clause. The where string is + * evaluated for each candidate item with `item` and `index` added to scope. + */ +function buildPredicate( + whereExpr: string, + baseScope: Scope, + _ctx: DispatchContext, +): (item: unknown, index: number) => boolean { + return (item, index) => { + const result = evaluate(whereExpr, { ...baseScope, item, index }); + return truthy(result); + }; +} diff --git a/apps/webapp/app/components/widgets/runtime/blocks/index.tsx b/apps/webapp/app/components/widgets/runtime/blocks/index.tsx new file mode 100644 index 000000000..c65633411 --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/blocks/index.tsx @@ -0,0 +1,710 @@ +/** + * Block renderers — v0. + * + * One React component per block type in the closed primitive set: + * Container, Text, Heading, Markdown, Badge, Card, Button, Tabs, + * List, Table, Form, Modal, EmptyState + * + * Each component reads the runtime via `useEvaluate` for expression-bearing + * fields, dispatches actions through `dispatchAction`, and pushes per-item + * scope (item, index, args, event) via ``. + * + * Styling follows the codebase's existing chat/widget conventions (border, + * grayAlpha, muted-foreground tokens). Lucide icons for chrome. + */ + +import { useCallback, useState } from "react"; +import { X } from "lucide-react"; +import ReactMarkdown from "react-markdown"; + +import { + ScopeProvider, + useDispatch, + useEvaluate, + useRuntime, + useSnapshot, +} from "../context"; +import { cn } from "~/lib/utils"; + +// ─── Top-level block dispatch ─────────────────────────────────────────────── + +export function BlockRenderer({ block }: { block: unknown }) { + if (!block || typeof block !== "object") return null; + const b = block as { type: string }; + switch (b.type) { + case "Container": + return ; + case "Text": + return ; + case "Heading": + return ; + case "Markdown": + return ; + case "Badge": + return ; + case "Card": + return ; + case "Button": + return ; + case "Tabs": + return ; + case "List": + return ; + case "Table": + return ; + case "Form": + return ; + case "Modal": + return ; + case "EmptyState": + return ; + default: + return ( +
+ unknown block: {b.type} +
+ ); + } +} + +// ─── Container ────────────────────────────────────────────────────────────── + +interface ContainerSpec { + id: string; + type: "Container"; + layout?: "row" | "column"; + gap?: number; + children?: unknown[]; +} + +function ContainerBlock({ block }: { block: ContainerSpec }) { + const layout = block.layout ?? "column"; + const gap = block.gap ?? 8; + return ( +
+ {(block.children ?? []).map((child, i) => ( + + ))} +
+ ); +} + +// ─── Text / Heading / Markdown ────────────────────────────────────────────── + +interface TextSpec { + id: string; + type: "Text"; + text: string; + variant?: "default" | "muted" | "danger"; +} + +function TextBlock({ block }: { block: TextSpec }) { + const text = useEvaluate(block.text) as string; + const cls = + block.variant === "muted" + ? "text-muted-foreground" + : block.variant === "danger" + ? "text-destructive" + : "text-foreground"; + return

{String(text ?? "")}

; +} + +interface HeadingSpec { + id: string; + type: "Heading"; + text: string; + level?: 1 | 2 | 3 | 4; +} + +function HeadingBlock({ block }: { block: HeadingSpec }) { + const text = useEvaluate(block.text) as string; + const level = block.level ?? 2; + const sizes = { + 1: "text-2xl", + 2: "text-xl", + 3: "text-lg", + 4: "text-base", + }; + const Tag = (`h${level}`) as "h1" | "h2" | "h3" | "h4"; + return {String(text ?? "")}; +} + +interface MarkdownSpec { + id: string; + type: "Markdown"; + source: string; +} + +function MarkdownBlock({ block }: { block: MarkdownSpec }) { + const source = useEvaluate(block.source) as string; + return ( +
+ {String(source ?? "")} +
+ ); +} + +// ─── Badge ────────────────────────────────────────────────────────────────── + +interface BadgeSpec { + id: string; + type: "Badge"; + text: string; + color?: string; +} + +const COLOR_CLASSES: Record = { + red: "bg-red-500/10 text-red-600 border-red-500/20", + orange: "bg-orange-500/10 text-orange-600 border-orange-500/20", + yellow: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20", + green: "bg-green-500/10 text-green-600 border-green-500/20", + blue: "bg-blue-500/10 text-blue-600 border-blue-500/20", + gray: "bg-grayAlpha-100 text-muted-foreground border-border", +}; + +function BadgeBlock({ block }: { block: BadgeSpec }) { + const text = useEvaluate(block.text) as string; + const color = (useEvaluate(block.color ?? "gray") as string) || "gray"; + const cls = COLOR_CLASSES[color] ?? COLOR_CLASSES.gray; + return ( + + {String(text ?? "")} + + ); +} + +// ─── Card ─────────────────────────────────────────────────────────────────── + +interface CardSpec { + id: string; + type: "Card"; + title?: string; + children?: unknown[]; +} + +function CardBlock({ block }: { block: CardSpec }) { + const title = useEvaluate(block.title) as string | undefined; + return ( +
+ {title && ( +
+ {String(title ?? "")} +
+ )} +
+ {(block.children ?? []).map((child, i) => ( + + ))} +
+
+ ); +} + +// ─── Button ───────────────────────────────────────────────────────────────── + +interface ButtonSpec { + id: string; + type: "Button"; + label: string; + variant?: "primary" | "secondary" | "ghost" | "danger"; + onClick?: string; + args?: Record; + disabled?: string; +} + +const BUTTON_VARIANTS: Record = { + primary: "bg-primary text-primary-foreground hover:bg-primary/90", + secondary: "border border-border hover:bg-accent", + ghost: "hover:bg-accent", + danger: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", +}; + +function ButtonBlock({ block }: { block: ButtonSpec }) { + useSnapshot(); + const dispatch = useDispatch(); + + const label = useEvaluate(block.label) as string; + const disabled = block.disabled + ? Boolean(useEvaluate(block.disabled)) + : false; + const variant = block.variant ?? "secondary"; + + return ( + + ); +} + +// ─── Tabs ─────────────────────────────────────────────────────────────────── + +interface TabsSpec { + id: string; + type: "Tabs"; + bind: string; + options: Array<{ label: string; value: string }>; +} + +function TabsBlock({ block }: { block: TabsSpec }) { + const { store } = useRuntime(); + const snap = useSnapshot(); + const current = String(snap.state[block.bind] ?? ""); + return ( +
+ {block.options.map((opt) => ( + + ))} +
+ ); +} + +// ─── List ─────────────────────────────────────────────────────────────────── + +interface ListSpec { + id: string; + type: "List"; + data: string; + item: { + title: string; + subtitle?: string; + badge?: string; + badgeColor?: string; + href?: string; + onClick?: string; + args?: Record; + }; + emptyText?: string; +} + +function ListBlock({ block }: { block: ListSpec }) { + const data = useEvaluate(block.data); + const empty = useEvaluate(block.emptyText) as string | undefined; + const items = Array.isArray(data) ? data : []; + + if (items.length === 0) { + return ( +

+ {empty || "Nothing here yet."} +

+ ); + } + + return ( +
    + {items.map((item, idx) => ( + + + + ))} +
+ ); +} + +function ListRow({ itemSpec }: { itemSpec: ListSpec["item"] }) { + const dispatch = useDispatch(); + const title = useEvaluate(itemSpec.title) as string; + const subtitle = useEvaluate(itemSpec.subtitle) as string | undefined; + const badge = useEvaluate(itemSpec.badge) as string | undefined; + const badgeColor = useEvaluate(itemSpec.badgeColor ?? "gray") as string; + const href = useEvaluate(itemSpec.href) as string | undefined; + + const handleClick = () => dispatch(itemSpec.onClick, { args: itemSpec.args }); + + const interactive = Boolean(itemSpec.onClick || href); + const Wrap = href ? "a" : "div"; + const wrapProps: Record = href + ? { href, target: "_blank", rel: "noreferrer" } + : {}; + + return ( +
  • + )} + className="flex items-center justify-between gap-2" + > +
    +
    {String(title ?? "")}
    + {subtitle && ( +
    + {String(subtitle)} +
    + )} +
    + {badge && ( + + {String(badge)} + + )} +
    +
  • + ); +} + +// ─── Table ────────────────────────────────────────────────────────────────── + +interface TableSpec { + id: string; + type: "Table"; + data: string; + columns: Array<{ + key: string; + label: string; + format?: "text" | "date" | "number" | "badge"; + }>; + emptyText?: string; +} + +function TableBlock({ block }: { block: TableSpec }) { + const data = useEvaluate(block.data); + const empty = useEvaluate(block.emptyText) as string | undefined; + const rows = Array.isArray(data) ? data : []; + + if (rows.length === 0) { + return ( +

    + {empty || "No rows."} +

    + ); + } + + return ( +
    + + + + {block.columns.map((col) => ( + + ))} + + + + {rows.map((row, idx) => ( + + {block.columns.map((col) => { + const value = (row as Record)?.[col.key]; + return ( + + ); + })} + + ))} + +
    + {col.label} +
    + {formatCell(value, col.format)} +
    +
    + ); +} + +function formatCell( + value: unknown, + format?: "text" | "date" | "number" | "badge", +): React.ReactNode { + if (value == null) return ; + if (format === "date") { + const d = value instanceof Date ? value : new Date(String(value)); + if (isNaN(d.getTime())) return String(value); + return d.toLocaleDateString(); + } + if (format === "number") return Number(value).toLocaleString(); + if (format === "badge") { + return ( + + {String(value)} + + ); + } + return String(value); +} + +// ─── Form ─────────────────────────────────────────────────────────────────── + +interface FormSpec { + id: string; + type: "Form"; + bind: string; + fields: Array<{ + id: string; + type: "text" | "textarea" | "number" | "boolean" | "select" | "date"; + label: string; + placeholder?: string; + required?: boolean; + options?: Array<{ label: string; value: string }>; + }>; + submitLabel?: string; + onSubmit?: string; + onCancel?: string; +} + +function FormBlock({ block }: { block: FormSpec }) { + const { store } = useRuntime(); + const snap = useSnapshot(); + const dispatch = useDispatch(); + const bound = (snap.state[block.bind] as Record) ?? {}; + + const updateField = (fieldId: string, value: unknown) => { + const next = { ...bound, [fieldId]: value }; + store.setState(block.bind, next); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(block.onSubmit, { event: { values: bound, ...bound } }); + }; + + return ( +
    + {block.fields.map((field) => ( +
    + + {renderField(field, bound[field.id], (v) => updateField(field.id, v))} +
    + ))} +
    + {block.onCancel && ( + + )} + +
    +
    + ); +} + +function CancelButton({ actionId }: { actionId: string }) { + const dispatch = useDispatch(); + return ( + + ); +} + +function renderField( + field: FormSpec["fields"][number], + value: unknown, + onChange: (v: unknown) => void, +): React.ReactNode { + switch (field.type) { + case "textarea": + return ( +