diff --git a/apps/webapp/app/components/conversation/editor-extensions.tsx b/apps/webapp/app/components/conversation/editor-extensions.tsx index 32e0e5cc..f844131e 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/daily/daily-widget-grid.client.tsx b/apps/webapp/app/components/daily/daily-widget-grid.client.tsx index 1860d21e..3d410099 100644 --- a/apps/webapp/app/components/daily/daily-widget-grid.client.tsx +++ b/apps/webapp/app/components/daily/daily-widget-grid.client.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import type { OverviewCell, WidgetOption } from "~/components/overview/types"; -import { WidgetCell } from "~/components/overview/widget-cell.client"; +import { CoreWidgetContent } from "~/components/widgets/CoreWidgetView"; import { Button } from "~/components/ui"; import { AlertCircle, @@ -48,15 +48,15 @@ const DEFAULT_CELLS: OverviewCell[] = [ integrationSlug: null, integrationAccountId: null, config: null, + widgetId: null, }, ]; interface Props { initialCells: OverviewCell[]; + /** Picker source — sourced from the unified Widget table by the loader. */ widgetOptions: WidgetOption[]; onSave: (cells: OverviewCell[]) => void; - widgetPat: string | null; - baseUrl: string; } type PickerSelection = WidgetOption | NativeWidget; @@ -98,7 +98,7 @@ function DailyWidgetPicker({ ))} - {/* Integration widgets */} + {/* Widgets sourced from the Widget table */} {widgetOptions.length > 0 && ( <> {NATIVE_WIDGETS.length > 0 && ( @@ -108,9 +108,14 @@ function DailyWidgetPicker({ const Icon = option.integrationIcon ? getIcon(option.integrationIcon as IconType) : null; + const isDeclarative = !option.integrationSlug; + const subtitle = + option.integrationName && option.widgetDescription + ? `${option.integrationName} · ${option.widgetDescription}` + : option.integrationName || option.widgetDescription || ""; return ( ); @@ -147,18 +154,119 @@ function DailyWidgetPicker({ ); } +// ─── Config form (shown when a picked widget has required unfilled fields) ─ + +function ConfigForm({ + open, + schema, + initialValues, + onSubmit, + onCancel, +}: { + open: boolean; + schema: WidgetOption["configSchema"]; + initialValues: Record; + onSubmit: (values: Record) => void; + onCancel: () => void; +}) { + const [values, setValues] = useState>(() => + Object.fromEntries( + schema.map((f) => [f.key, initialValues[f.key] ?? f.default ?? ""]), + ), + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(values); + }; + + return ( + !v && onCancel()}> + + + Configure widget + +
+ {schema.map((field) => ( +
+ + {field.type === "select" ? ( + + ) : ( + + setValues((v) => ({ ...v, [field.key]: e.target.value })) + } + placeholder={field.placeholder} + required={field.required} + className="rounded border border-border bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + )} +
+ ))} +
+ + +
+
+
+
+ ); +} + +/** True if the schema has at least one required field with no value (and no default). */ +function needsConfigInput( + schema: WidgetOption["configSchema"], + values: Record | null, +): boolean { + if (!schema || schema.length === 0) return false; + for (const field of schema) { + if (!field.required) continue; + const cur = values?.[field.key]; + if (cur && cur.length > 0) continue; + if (field.default && field.default.length > 0) continue; + return true; + } + return false; +} + export function DailyWidgetGrid({ initialCells, widgetOptions, onSave, - widgetPat, - baseUrl, }: Props) { const [cells, setCells] = useState( initialCells.length > 0 ? initialCells : DEFAULT_CELLS, ); const [pickerOpen, setPickerOpen] = useState(false); const [selectedCellId, setSelectedCellId] = useState(null); + const [configFormState, setConfigFormState] = useState<{ + cellId: string; + option: WidgetOption; + } | null>(null); const dragIndex = useRef(null); const handleAddCell = () => { @@ -172,6 +280,7 @@ export function DailyWidgetGrid({ integrationSlug: null, integrationAccountId: null, config: null, + widgetId: null, }; const updated = [...cells, newCell]; setCells(updated); @@ -189,26 +298,71 @@ export function DailyWidgetGrid({ setPickerOpen(true); }; - const handlePickWidget = (option: PickerSelection) => { - if (!selectedCellId) return; - const isNative = !("integrationAccountId" in option); + /** Apply a final selection (widget + config) to the cell and save. */ + const applySelection = ( + cellId: string, + selection: PickerSelection, + config: Record | null, + ) => { + const isNative = !("integrationAccountId" in selection); + const widgetOption = isNative ? null : (selection as WidgetOption); const updated = cells.map((c) => - c.id === selectedCellId + c.id === cellId ? { ...c, - widgetSlug: option.widgetSlug, - integrationSlug: isNative - ? null - : (option as WidgetOption).integrationSlug, - integrationAccountId: isNative - ? null - : (option as WidgetOption).integrationAccountId, + widgetSlug: selection.widgetSlug, + // Empty integration fields = declarative widget; store as null. + integrationSlug: widgetOption?.integrationSlug || null, + integrationAccountId: widgetOption?.integrationAccountId || null, + widgetId: widgetOption?.widgetId ?? null, + config: isNative ? null : config, } : c, ); setCells(updated); onSave(updated); + }; + + const handlePickWidget = (option: PickerSelection) => { + if (!selectedCellId) return; setPickerOpen(false); + + const isNative = !("integrationAccountId" in option); + if (isNative) { + applySelection(selectedCellId, option, null); + setSelectedCellId(null); + return; + } + + const widgetOption = option as WidgetOption; + if (needsConfigInput(widgetOption.configSchema, null)) { + // Defer save — show config form first. + setConfigFormState({ cellId: selectedCellId, option: widgetOption }); + return; + } + + // Auto-fill defaults; no form needed. + const defaults: Record = {}; + for (const f of widgetOption.configSchema ?? []) { + if (f.default) defaults[f.key] = f.default; + } + applySelection( + selectedCellId, + widgetOption, + Object.keys(defaults).length > 0 ? defaults : null, + ); + setSelectedCellId(null); + }; + + const handleConfigSubmit = (values: Record) => { + if (!configFormState) return; + applySelection(configFormState.cellId, configFormState.option, values); + setConfigFormState(null); + setSelectedCellId(null); + }; + + const handleConfigCancel = () => { + setConfigFormState(null); setSelectedCellId(null); }; @@ -260,20 +414,34 @@ export function DailyWidgetGrid({ const nativeMeta = isNative ? NATIVE_WIDGET_MAP[cell.widgetSlug!] : null; - const integrationOption = - !isNative && cell.widgetSlug && cell.integrationAccountId - ? widgetOptions.find( - (o) => - o.widgetSlug === cell.widgetSlug && - o.integrationAccountId === cell.integrationAccountId, - ) - : undefined; + + // Resolve a widgetRef for the new render path: prefer cell.widgetId, + // else fall back to a lookup against widgetOptions by (slug, account). + let widgetRef: string | null = null; + let displayLabel: string | null = null; + if (!isNative) { + if (cell.widgetId) { + widgetRef = cell.widgetId; + const opt = widgetOptions.find((o) => o.widgetId === cell.widgetId); + displayLabel = opt + ? `${opt.integrationName} · ${opt.widgetName}` + : null; + } else if (cell.widgetSlug && cell.integrationAccountId) { + const opt = widgetOptions.find( + (o) => + o.widgetSlug === cell.widgetSlug && + o.integrationAccountId === cell.integrationAccountId, + ); + if (opt) { + widgetRef = opt.widgetId ?? null; + displayLabel = `${opt.integrationName} · ${opt.widgetName}`; + } + } + } const label = nativeMeta ? nativeMeta.widgetName - : integrationOption - ? `${integrationOption.integrationName} · ${integrationOption.widgetName}` - : "Empty"; + : (displayLabel ?? "Empty"); return (
{isNative && cell.widgetSlug === "needs-attention" ? ( - ) : integrationOption && widgetPat ? ( - ) : (
); } diff --git a/apps/webapp/app/components/daily/daily-widget-panel.client.tsx b/apps/webapp/app/components/daily/daily-widget-panel.client.tsx index b92020bb..5ffbed64 100644 --- a/apps/webapp/app/components/daily/daily-widget-panel.client.tsx +++ b/apps/webapp/app/components/daily/daily-widget-panel.client.tsx @@ -5,16 +5,12 @@ interface Props { initialCells: OverviewCell[]; widgetOptions: WidgetOption[]; onSave: (cells: OverviewCell[]) => void; - widgetPat: string | null; - baseUrl: string; } export function DailyWidgetPanel({ initialCells, widgetOptions, onSave, - widgetPat, - baseUrl, }: Props) { return (
@@ -22,8 +18,6 @@ export function DailyWidgetPanel({ initialCells={initialCells} widgetOptions={widgetOptions} onSave={onSave} - widgetPat={widgetPat} - baseUrl={baseUrl} />
); 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 01fbf4ab..02fbefa4 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 071efb44..04b91a73 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; @@ -6,13 +6,29 @@ export interface OverviewCell { y: number; w: number; // 1–3 columns h: number; // row-height units + + /** + * For native widgets (e.g. "needs-attention"), set widgetSlug to the + * native id. integrationSlug/integrationAccountId/widgetId are null. + * + * For widgets backed by the unified Widget table, set `widgetId`. The + * integration metadata is resolved server-side from the row. + * + * Legacy cells may carry the old (widgetSlug, integrationSlug, + * integrationAccountId, config) shape with no widgetId — these are + * backward-compatible: the loader maps them to a Widget row by + * (integrationAccountId, widgetSlug) at fetch time. + */ widgetSlug: string | null; integrationSlug: string | null; integrationAccountId: string | null; config: Record | null; + + /** Reference to a Widget row. Preferred over the legacy fields above. */ + widgetId: string | null; } -/** A single renderable widget option available to the user */ +/** A single renderable widget option available to the user (legacy shape, kept for picker UI). */ export interface WidgetOption { widgetSlug: string; widgetName: string; @@ -23,4 +39,7 @@ export interface WidgetOption { frontendUrl: string; integrationAccountId: string; configSchema: WidgetConfigField[]; + + /** When sourced from the Widget table, the row id. Picker uses this for new pins. */ + widgetId?: string; } diff --git a/apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx b/apps/webapp/app/components/widgets/BundledWidgetRenderer.tsx new file mode 100644 index 00000000..c9df763f --- /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 00000000..cbf986f5 --- /dev/null +++ b/apps/webapp/app/components/widgets/CoreWidgetView.tsx @@ -0,0 +1,311 @@ +/** + * Unified Widget renderer for the Widget table. + * + * Two exports: + * - — fetches + renders WITH chrome (border, header, + * engine badge). Used by the chat embed + * (). + * - — fetches + renders WITHOUT chrome. Used by the + * dashboard pin grid which has its own chrome + * (drag handle + remove button). + * + * Both fetch `/api/v1/widgets/:id` and dispatch by `engine`: + * DECLARATIVE → + * BUNDLED → + */ + +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; +} + +interface CommonProps { + widgetRef: string; + /** + * Inline config overrides — for DECLARATIVE widgets, takes priority over + * IR config[].default; for BUNDLED widgets, merged on top of stored + * configValues (override wins per key). + */ + configOverride?: Record; +} + +// ─── Hook: fetch the widget envelope ──────────────────────────────────────── + +function useWidgetFetch(widgetRef: string): FetchState { + const [fetchState, setFetchState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setFetchState({ status: "loading" }); + (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]); + + return fetchState; +} + +// ─── CoreWidgetView (with chrome) ─────────────────────────────────────────── + +export function CoreWidgetView({ widgetRef, configOverride }: CommonProps) { + const fetchState = useWidgetFetch(widgetRef); + + if (fetchState.status === "loading") { + return ( +
+ +
+ ); + } + + if (fetchState.status === "error" || !fetchState.widget) { + return ( +
+ +
+
Widget unavailable
+
+ {fetchState.error ?? "unknown error"} +
+
+
+ ); + } + + return ; +} + +// ─── CoreWidgetContent (no chrome) ────────────────────────────────────────── + +/** + * Fetches and renders a widget WITHOUT the outer frame. Use this when the + * surface (e.g. dashboard pin grid) provides its own chrome. + */ +export function CoreWidgetContent({ widgetRef, configOverride }: CommonProps) { + const fetchState = useWidgetFetch(widgetRef); + + if (fetchState.status === "loading") { + return ( +
+ +
+ ); + } + + if (fetchState.status === "error" || !fetchState.widget) { + return ( +
+ + {fetchState.error ?? "Widget unavailable"} +
+ ); + } + + return ; +} + +// ─── Frame chrome ─────────────────────────────────────────────────────────── + +function WidgetFrame({ + widget, + configOverride, +}: { + widget: WidgetEnvelope; + configOverride?: Record; +}) { + return ( +
+
+
+ + {widget.name} + · + {widget.slug} + + {widget.engine === "BUNDLED" ? "bundled" : "ir"} + +
+
v{widget.version}
+
+ +
+ +
+
+ ); +} + +// ─── Engine dispatch (the actual content) ─────────────────────────────────── + +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 = (state: Record) => { + // Diagnostic — turn on to verify saves are firing. + if (typeof console !== "undefined") { + console.debug( + `[widget-runtime] persisting state for "${widget.slug}":`, + state, + ); + } + void fetch(`/api/v1/widgets/${encodeURIComponent(widget.id)}/state`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state }), + }).then((res) => { + if (!res.ok && typeof console !== "undefined") { + console.warn( + `[widget-runtime] persist failed for "${widget.slug}" — HTTP ${res.status}`, + ); + } + }); + }; + + 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). +

+ ); + } + + 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 00000000..15371fe2 --- /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 00000000..bc2fff97 --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/WidgetRuntime.tsx @@ -0,0 +1,261 @@ +/** + * WidgetRuntime — top-level orchestrator. + * + * Wraps a widget IR in: + * - a `WidgetStore` (state, requests, derived) + * - a RuntimeProvider so block renderers can read/dispatch + * - the block-tree render + * - optional persistence hook for `state[].persist: true` fields + * - optional server-side request executor (v1.1+) for ai.* / + * integration_action / static requests + * + * Server-side request execution kicks in when `widgetUuid` is provided. On + * mount, the runtime calls `GET /api/v1/widgets//requests`, parses the + * results, and pushes them into the store. The `runRequest` action op + * triggers a force-refresh POST to the same path. + * + * For previews / DEFAULT widgets / IRs without server-resolvable requests, + * pass `widgetUuid={undefined}` and the runtime skips the fetch — requests + * stay in their `__pending` placeholder shape (same as v0). + */ + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { WidgetIR } from "@core/types"; +import { WidgetStore } from "./store"; +import { RuntimeProvider } from "./context"; +import { BlockRenderer } from "./blocks"; +import { getTickIntervalMs } from "./expression"; + +export interface WidgetRuntimeProps { + ir: WidgetIR; + /** UUID of the persisted Widget row — required for server-side request execution. */ + widgetUuid?: string; + 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, + widgetUuid, + 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], + ); + + // ─── State persistence (unchanged from v0) ─────────────────────────────── + const persistedKeys = useMemo( + () => (ir.state ?? []).filter((s) => s.persist).map((s) => s.id), + [ir], + ); + + 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; + }; + + 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 */ + } + }, PERSIST_DEBOUNCE_MS); + }); + + return () => { + if (timer) clearTimeout(timer); + unsubscribe(); + }; + }, [store, onStatePersist, persistedKeys]); + + // ─── now ticker — auto-derived cadence for widgets that reference `{{now}}` ── + // 1s for countdowns / mmss / numeric comparisons; 60s when the IR only + // uses minute-precision filters (timeAgo, formatDate, formatDuration). + const tickIntervalMs = useMemo(() => getTickIntervalMs(ir), [ir]); + + useEffect(() => { + if (tickIntervalMs == null) return; + const id = setInterval(() => store.tick(), tickIntervalMs); + return () => clearInterval(id); + }, [tickIntervalMs, store]); + + // ─── Server-side request execution (v1.1+) ─────────────────────────────── + + const hasNonStaticRequests = useMemo( + () => (ir.requests ?? []).some((r) => r.type !== "static"), + [ir], + ); + + /** + * Run requests on the server and push results into the store. + * + * runRequests() — full graph, honors cache (initial mount) + * runRequests(undefined,true) — full graph, bypasses cache + * runRequests("send_email") — per-request mutation; bypasses cache + * + * The action dispatcher's `runRequest` op calls this with a specific + * request id, awaiting the promise so subsequent ops see the new state. + */ + const runRequests = useCallback( + async ( + requestId?: string, + opts?: { + forceFullGraph?: boolean; + args?: Record; + event?: Record; + }, + ): Promise => { + if (!widgetUuid || !hasNonStaticRequests) return; + try { + const url = `/api/v1/widgets/${encodeURIComponent(widgetUuid)}/requests`; + // POST always (so we can carry config/args/event in the body). GET + // path was the cache-honoring read; we now express that with + // force:false instead, since config overrides only travel via body. + const isMutation = requestId !== undefined; + const force = opts?.forceFullGraph || isMutation; + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + force, + ...(requestId ? { requestIds: [requestId] } : {}), + ...(initialConfig ? { config: initialConfig } : {}), + ...(opts?.args ? { args: opts.args } : {}), + ...(opts?.event ? { event: opts.event } : {}), + }), + }); + if (!res.ok) return; + const data = (await res.json()) as { + results?: Record; + errors?: Record; + }; + const results = data.results ?? {}; + for (const [id, value] of Object.entries(results)) { + store.setRequestResult(id, value); + } + } catch { + /* network / parse errors degrade gracefully — placeholder stays */ + } + }, + [widgetUuid, hasNonStaticRequests, store, initialConfig], + ); + + // Initial fetch on mount (and whenever widgetUuid changes). Full graph, + // cache-honoring. + useEffect(() => { + if (!widgetUuid || !hasNonStaticRequests) return; + let cancelled = false; + (async () => { + if (cancelled) return; + await runRequests(); + })(); + return () => { + cancelled = true; + }; + }, [widgetUuid, hasNonStaticRequests, runRequests]); + + // Per-request interval refresh — any request declaring + // `refresh: { kind: "interval", intervalMs: N }` gets its own ticker that + // re-executes that request every N ms (force-fetch, bypassing cache). + // Multiple interval requests are independent; each gets its own timer. + const intervalRequests = useMemo( + () => + (ir.requests ?? []) + .map((r) => { + const refresh = (r as { refresh?: { kind?: string; intervalMs?: number } }).refresh; + if (refresh?.kind !== "interval" || !refresh.intervalMs) return null; + return { id: r.id, intervalMs: refresh.intervalMs }; + }) + .filter((x): x is { id: string; intervalMs: number } => x !== null), + [ir], + ); + + useEffect(() => { + if (!widgetUuid || intervalRequests.length === 0) return; + const timers = intervalRequests.map(({ id, intervalMs }) => + setInterval(() => { + void runRequests(id); + }, intervalMs), + ); + return () => { + for (const t of timers) clearInterval(t); + }; + }, [widgetUuid, intervalRequests, runRequests]); + + // Adapter for the dispatcher: receives an optional requestId from the + // `runRequest` op and forwards. Undefined → refresh whole graph (force). + // `extra` carries args/event from the action payload so request `params` + // templated against `{{args.*}}` / `{{event.*}}` resolve server-side. + const triggerRunRequests = useCallback( + ( + requestId?: string, + extra?: { + args?: Record; + event?: Record; + }, + ) => + requestId + ? runRequests(requestId, { args: extra?.args, event: extra?.event }) + : runRequests(undefined, { + forceFullGraph: true, + args: extra?.args, + event: extra?.event, + }), + [runRequests], + ); + + 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 00000000..da2c09bd --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/actions.ts @@ -0,0 +1,126 @@ +/** + * Action dispatcher. + * + * dispatchAction("save", { store, ir, args, event, runRequests }) + * + * Executes an action's `do[]` ops in sequence. `runRequest` ops are awaited + * — the next op in the sequence runs only after the request returns and the + * store has its result. This is what makes "click → run mutation → close + * modal" sequences work cleanly: the modal only closes after the server + * confirms the mutation completed. + * + * 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 per-request execution; awaitable + * + * Confirm prompts use window.confirm in v0 — replace with in-widget dialog + * later. + */ + +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; + args?: Record; + event?: Record; + skipConfirm?: boolean; + /** + * Per-request runner injected by RuntimeProvider. Pass a requestId to + * execute just that request (mutation pattern); pass undefined to refresh + * the whole graph. The `extra` scope (args/event) is forwarded server-side + * so request `params` templates like `{{args.title}}` resolve correctly + * when fired from a Form submit or Button click. + */ + runRequests?: ( + requestId?: string, + extra?: { args?: Record; event?: Record }, + ) => Promise; +} + +export async function dispatchAction( + actionId: string, + ctx: DispatchContext, +): Promise { + 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) { + await runOp(op, action, ctx); + } +} + +async function runOp( + op: ActionOp, + action: WidgetAction, + ctx: DispatchContext, +): Promise { + 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": + // Awaitable — the next op in the sequence runs only after the request + // resolves and the store has the result. Critical for mutations: + // [ runRequest("send_email"), closeModal("compose") ] + // The modal only closes after send_email actually completes. + // Forward args/event so request `params` like `{{args.title}}` resolve + // server-side against the action's payload. + try { + await ctx.runRequests?.(op.request, { args: ctx.args, event: ctx.event }); + } catch (err) { + if (typeof console !== "undefined") { + console.warn( + `[widget-runtime] runRequest("${op.request}") failed`, + err, + ); + } + } + break; + } +} + +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 00000000..6c578fca --- /dev/null +++ b/apps/webapp/app/components/widgets/runtime/blocks/index.tsx @@ -0,0 +1,941 @@ +/** + * 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; + align?: Align; + children?: unknown[]; +} + +type Align = "left" | "center" | "right"; + +/** Cross-axis alignment for layout containers — flex justify map. */ +const JUSTIFY: Record = { + left: "justify-start", + center: "justify-center", + right: "justify-end", +}; + +const ITEMS: Record = { + left: "items-start", + center: "items-center", + right: "items-end", +}; + +/** text-align map for prose blocks. */ +const TEXT_ALIGN: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +function ContainerBlock({ block }: { block: ContainerSpec }) { + const layout = block.layout ?? "column"; + const gap = block.gap ?? 8; + const alignCls = block.align + ? layout === "row" + ? JUSTIFY[block.align] + : ITEMS[block.align] + : layout === "row" + ? "items-center" + : ""; + return ( +
+ {(block.children ?? []).map((child, i) => ( + + ))} +
+ ); +} + +// ─── Text / Heading / Markdown ────────────────────────────────────────────── + +interface TextSpec { + id: string; + type: "Text"; + text: string; + variant?: "default" | "muted" | "danger"; + align?: Align; + italic?: boolean; +} + +function TextBlock({ block }: { block: TextSpec }) { + const text = useEvaluate(block.text); + const cls = + block.variant === "muted" + ? "text-muted-foreground" + : block.variant === "danger" + ? "text-destructive" + : "text-foreground"; + if (isPending(text)) { + return ; + } + return ( +

+ {String(text ?? "")} +

+ ); +} + +interface HeadingSpec { + id: string; + type: "Heading"; + text: string; + level?: 1 | 2 | 3 | 4; + align?: Align; +} + +function HeadingBlock({ block }: { block: HeadingSpec }) { + const text = useEvaluate(block.text); + 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"; + if (isPending(text)) { + return ; + } + return ( + + {String(text ?? "")} + + ); +} + +interface MarkdownSpec { + id: string; + type: "Markdown"; + source: string; + align?: Align; + italic?: boolean; +} + +function MarkdownBlock({ block }: { block: MarkdownSpec }) { + const source = useEvaluate(block.source); + if (isPending(source)) { + return ; + } + 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; + variant?: "default" | "muted" | "outline" | "ghost"; + children?: unknown[]; +} + +const CARD_VARIANTS: Record, string> = { + default: "rounded-lg border border-border bg-background p-3", + muted: "rounded-lg bg-grayAlpha-100 p-3", + outline: "rounded-lg border border-border p-3", + ghost: "p-3", +}; + +function CardBlock({ block }: { block: CardSpec }) { + const title = useEvaluate(block.title) as string | undefined; + const variant = block.variant ?? "default"; + 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; + + // Pending takes priority over empty — without this, a List bound to an + // unresolved `ai.*` request renders the emptyText during initial fetch + // and looks like "no results" instead of "loading". + if (isPending(data)) { + return ; + } + + const items = Array.isArray(data) ? data : []; + + if (items.length === 0) { + return ( +

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

+ ); + } + + return ( +
    + {items.map((item, idx) => ( + + + + ))} +
+ ); +} + +/** + * List item scope: bare field access (`{{title}}`) AND namespaced access + * (`{{item.title}}`) both resolve. Bare access is the documented pattern in + * the widget-builder prompt and the seeded `tasks` default; without + * spreading the item fields here, `useEvaluate("{{title}}")` would walk + * `scope.title` and find nothing. + * + * Built-in identifiers (`item`, `index`) are written last so an item field + * named `item` or `index` can't shadow them. + */ +function buildItemScope( + item: unknown, + index: number, +): Record { + if (item && typeof item === "object" && !Array.isArray(item)) { + return { ...(item as Record), item, index }; + } + return { item, index }; +} + +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; + + if (isPending(data)) { + return ; + } + + 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"; + /** Optional — state id where values are bound. When absent, local React state. */ + 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 }>; + /** Templated initial value, e.g. "{{$state.focusMinutes}}". */ + defaultValue?: unknown; + }>; + submitLabel?: string; + onSubmit?: string; + onCancel?: string; +} + +function FormBlock({ block }: { block: FormSpec }) { + const { store, ir } = useRuntime(); + const snap = useSnapshot(); + const dispatch = useDispatch(); + + // `block.bind` is optional. Two paths: + // - bind set + declared in ir.state[] → write through to store state + // - bind missing or referencing undeclared state → use local React state + // + // The undeclared-state case used to silently no-op (controlled inputs + // showed empty forever even as the user typed). Now we fall back so the + // form is always usable. The onSubmit action receives values via args+event + // either way. + const bindKey = block.bind; + const stateDeclared = bindKey + ? (ir.state ?? []).some((s) => s.id === bindKey) + : false; + const [localBound, setLocalBound] = useState>(() => { + // Priority: existing bound state > field defaultValue evaluation > empty. + const raw = bindKey ? snap.state[bindKey] : undefined; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return raw as Record; + } + const initial: Record = {}; + for (const field of block.fields) { + if (field.defaultValue !== undefined) { + const evaluated = store.evaluate(field.defaultValue); + if (evaluated !== undefined && evaluated !== "") { + initial[field.id] = evaluated; + } + } + } + return initial; + }); + + const bound: Record = stateDeclared && bindKey + ? ((snap.state[bindKey] as Record | null | undefined) ?? {}) + : localBound; + + const updateField = (fieldId: string, value: unknown) => { + const next = { ...bound, [fieldId]: value }; + if (stateDeclared && bindKey) { + store.setState(bindKey, next); + } else { + setLocalBound(next); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // Pass form values via both `args` (agent's preferred pattern: {{args.field}}) + // AND `event` (legacy: {{event.values.field}} or {{event.field}}). This + // forgiveness saves a class of bugs where the IR's onSubmit action + // expects one or the other. + dispatch(block.onSubmit, { + args: bound, + event: { values: bound, ...bound }, + }); + }; + + return ( +
    + {block.fields.map((field) => { + // Fall back to evaluated defaultValue when bound state doesn't yet + // have a value for this field. Lets the agent pre-populate inputs + // from current state without requiring explicit bind+initial state. + let value: unknown = bound[field.id]; + if (value === undefined && field.defaultValue !== undefined) { + value = store.evaluate(field.defaultValue); + } + return ( +
    + + {renderField(field, value, (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 ( +