"use client"; /* Components screen (Feature 2 - generative UI): author UI widgets (HTML + CSS + props + button actions) that an agent can render in chat. Code-first editor (no visual builder, per product direction) with a live sandboxed preview. A new component pre-loads with a simple 2-column table example. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../icons"; import { Tile } from "../primitives"; import { VersionHistory } from "../version-history"; import { ImportExport } from "../import-export"; import { api, ComponentT } from "@/lib/api"; import { ComponentRenderer } from "../component-renderer"; const DEFAULT_HTML = `
{{title}}
{{#col1}}{{/col1}} {{#rows}} {{/rows}}
{{col1}}{{col2}}
{{label}}{{value}}
`; const DEFAULT_CSS = `.card { font-family: system-ui, -apple-system, sans-serif; border: 1px solid #e3e3e8; border-radius: 12px; overflow: hidden; max-width: 420px; background: #fff; color: #1a1a1f; } .title { font-weight: 600; font-size: 14px; padding: 10px 14px; border-bottom: 1px solid #e3e3e8; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { text-align: left; padding: 8px 14px; border-bottom: 1px solid #f0f0f3; } th { color: #6b6b76; font-weight: 600; background: #fafafb; } tbody tr:last-child td { border-bottom: none; }`; const DEFAULT_PROPS_SCHEMA = { type: "object", properties: { title: { type: "string", description: "Card title" }, col1: { type: "string", description: "First column header" }, col2: { type: "string", description: "Second column header" }, rows: { type: "array", description: "Rows, each an object with label and value" }, }, required: ["title"], }; const DEFAULT_SAMPLE = { title: "Weather - London", col1: "Day", col2: "Forecast", rows: [ { label: "Mon", value: "Sunny · 24°C" }, { label: "Tue", value: "Cloudy · 21°C" }, { label: "Wed", value: "Rain · 18°C" }, ], }; const NEW_COMPONENT = { name: "new_component", title: "New component", description: "A UI component the agent can render for the user.", html: DEFAULT_HTML, css: DEFAULT_CSS, props_schema: DEFAULT_PROPS_SCHEMA, sample_props: DEFAULT_SAMPLE, actions: [], }; export function ComponentsScreen({ project, onOpen }: { project: any; onOpen: (c: ComponentT) => void }) { const [items, setItems] = useState([]); const [loaded, setLoaded] = useState(false); const [err, setErr] = useState(null); const [creating, setCreating] = useState(false); const reload = useCallback(() => { if (!project?.id) return; setLoaded(false); setErr(null); api .listComponents(project.id) .then((c) => { setItems(c); setLoaded(true); }) .catch((e) => { setErr(String(e.message || e)); setLoaded(true); }); }, [project?.id]); useEffect(() => { reload(); }, [reload]); async function create() { if (creating) return; setCreating(true); try { const c = await api.createComponent(project.id, NEW_COMPONENT as any); onOpen(c); } catch (e: any) { setErr(String(e.message || e)); } finally { setCreating(false); } } return (
Components
UI widgets the agent can render in chat - attached to agents like tools.
({ id: c.id, name: c.name, sub: c.title || c.description || undefined }))} />
{err &&
{err}
} {!loaded &&
Loading…
} {loaded && items.length === 0 && !err && (
No components yet
Author an HTML/CSS widget - a table, product card, or form - then attach it to an agent. The agent renders it in chat when relevant.
)}
{items.map((c) => ( ))}
); } function tryParse(text: string): { value: any; error: string | null } { if (!text.trim()) return { value: undefined, error: null }; try { return { value: JSON.parse(text), error: null }; } catch (e: any) { return { value: undefined, error: String(e.message || e) }; } } export function ComponentBuilderScreen({ project, componentId, onBack, }: { project: any; componentId?: string; onBack: () => void; }) { const [loaded, setLoaded] = useState(false); const [name, setName] = useState("new_component"); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [html, setHtml] = useState(DEFAULT_HTML); const [css, setCss] = useState(DEFAULT_CSS); const [propsText, setPropsText] = useState(JSON.stringify(DEFAULT_PROPS_SCHEMA, null, 2)); const [sampleText, setSampleText] = useState(JSON.stringify(DEFAULT_SAMPLE, null, 2)); const [actionsText, setActionsText] = useState("[]"); const [enabled, setEnabled] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(null); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { if (!project?.id || !componentId) { setLoaded(true); return; } setLoaded(false); api .getComponent(project.id, componentId) .then((c) => { setName(c.name); setTitle(c.title || ""); setDescription(c.description || ""); setHtml(c.html || ""); setCss(c.css || ""); setPropsText(JSON.stringify(c.props_schema || {}, null, 2)); setSampleText(JSON.stringify(c.sample_props || {}, null, 2)); setActionsText(JSON.stringify(c.actions || [], null, 2)); setEnabled(c.enabled); setLoaded(true); }) .catch(() => setLoaded(true)); }, [project?.id, componentId, reloadKey]); const sample = useMemo(() => tryParse(sampleText), [sampleText]); const actionsParsed = useMemo(() => tryParse(actionsText), [actionsText]); const propsParsed = useMemo(() => tryParse(propsText), [propsText]); const previewProps = sample.error ? {} : sample.value || {}; const previewActions = actionsParsed.error ? [] : actionsParsed.value || []; async function save() { if (saving) return; if (propsParsed.error) return setStatus("Props schema is not valid JSON."); if (sample.error) return setStatus("Sample props is not valid JSON."); if (actionsParsed.error) return setStatus("Actions is not valid JSON."); setSaving(true); setStatus(null); const body = { name: name.trim().replace(/\s+/g, "_") || "component", title: title || null, description, html, css, props_schema: propsParsed.value || {}, sample_props: sample.value || {}, actions: previewActions, enabled, }; try { if (componentId) await api.updateComponent(project.id, componentId, body); else await api.createComponent(project.id, body as any); setStatus("Saved."); setTimeout(() => setStatus(null), 1600); } catch (e: any) { setStatus(`Save failed: ${e.message || e}`); } finally { setSaving(false); } } async function del() { if (!componentId) return; if (!window.confirm(`Delete component "${name}"?`)) return; await api.deleteComponent(project.id, componentId); onBack(); } const codeStyle: any = { fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: "18px", minHeight: 120, resize: "vertical" }; const field = (label: string, node: any, help?: string, helpErr?: boolean) => (
{node} {help &&
{help}
}
); if (!loaded) return
Loading…
; return (
{title || name}
{name}
{status && ( {status} )} {componentId && setReloadKey((k) => k + 1)} />} {componentId && ( )}
{field("Name", setName(e.target.value)} placeholder="product_card" />, "Machine name - this is the tool name the agent calls.")} {field("Title", setTitle(e.target.value)} placeholder="Product card" />)} {field( "Description",