From b84598b8d22759ddc034544f84f66bd3bcdae04e Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 21:59:27 +0800
Subject: [PATCH 01/16] feat: add UI template system for saving and reusing
generated widgets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add template tools (save, list, apply, delete) in agent backend
- Extend AgentState with templates field
- Add "Save as Template" button overlay on widget renderer with
animated save flow (input → saving spinner → checkmark confirmation)
- Add template library drawer panel (toggle from header)
- Templates saved directly to agent state via setState (no chat round-trip)
- Template deletion from drawer updates state directly
- Apply template sends chat prompt for agent to adapt HTML with new data
Closes #16
---
apps/agent/main.py | 17 ++-
apps/agent/src/templates.py | 138 +++++++++++++++++
apps/agent/src/todos.py | 3 +
apps/app/src/app/globals.css | 16 ++
apps/app/src/app/page.tsx | 108 ++++++++++++--
.../generative-ui/widget-renderer.tsx | 137 ++++++++++++++++-
.../src/components/template-library/index.tsx | 139 ++++++++++++++++++
.../template-library/template-card.tsx | 130 ++++++++++++++++
8 files changed, 670 insertions(+), 18 deletions(-)
create mode 100644 apps/agent/src/templates.py
create mode 100644 apps/app/src/components/template-library/index.tsx
create mode 100644 apps/app/src/components/template-library/template-card.tsx
diff --git a/apps/agent/main.py b/apps/agent/main.py
index 35aadda..1d03975 100644
--- a/apps/agent/main.py
+++ b/apps/agent/main.py
@@ -10,6 +10,7 @@
from src.query import query_data
from src.todos import AgentState, todo_tools
from src.form import generate_form
+from src.templates import template_tools
from skills import load_all_skills
# Load all visualization skills
@@ -17,7 +18,7 @@
agent = create_agent(
model=ChatOpenAI(model="gpt-5.4-2026-03-05"),
- tools=[query_data, *todo_tools, generate_form],
+ tools=[query_data, *todo_tools, generate_form, *template_tools],
middleware=[CopilotKitMiddleware()],
state_schema=AgentState,
system_prompt=f"""
@@ -47,6 +48,20 @@
Follow the skills below for how to produce high-quality visuals:
{_skills_text}
+
+ ## UI Templates
+
+ Users can save generated UIs as reusable templates and apply them later:
+
+ - When a user asks to save a widget as a template, call `save_template` with the
+ widget's HTML, a short name, description, and a description of the data shape.
+ - When a user asks to apply a template, first call `list_templates` to find the
+ right one, then call `apply_template` to get its HTML. Adapt the HTML with the
+ user's new data and render via `widgetRenderer`.
+ - When a user asks to see their templates, call `list_templates`.
+ - When a user asks to delete a template, call `delete_template`.
+ - A "save-as-template" message from the frontend means the user clicked the save
+ button on a widget. Extract the template details and call `save_template`.
""",
)
diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py
new file mode 100644
index 0000000..48f4eb9
--- /dev/null
+++ b/apps/agent/src/templates.py
@@ -0,0 +1,138 @@
+from langchain.tools import ToolRuntime, tool
+from langchain.messages import ToolMessage
+from langgraph.types import Command
+from typing import TypedDict
+import uuid
+from datetime import datetime
+
+
+class UITemplate(TypedDict):
+ id: str
+ name: str
+ description: str
+ html: str
+ data_description: str
+ created_at: str
+ version: int
+
+
+@tool
+def save_template(
+ name: str,
+ description: str,
+ html: str,
+ data_description: str,
+ runtime: ToolRuntime,
+) -> Command:
+ """
+ Save a generated UI as a reusable template.
+ Call this when the user wants to save a widget/visualization they liked for reuse later.
+
+ Args:
+ name: Short name for the template (e.g. "Invoice", "Dashboard")
+ description: What the template displays or does
+ html: The raw HTML string of the widget to save as a template
+ data_description: Description of the data shape this template expects
+ """
+ templates = list(runtime.state.get("templates", []))
+
+ template: UITemplate = {
+ "id": str(uuid.uuid4()),
+ "name": name,
+ "description": description,
+ "html": html,
+ "data_description": data_description,
+ "created_at": datetime.now().isoformat(),
+ "version": 1,
+ }
+ templates.append(template)
+
+ return Command(update={
+ "templates": templates,
+ "messages": [
+ ToolMessage(
+ content=f"Template '{name}' saved successfully (id: {template['id']})",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+
+@tool
+def list_templates(runtime: ToolRuntime):
+ """
+ List all saved UI templates. Returns template summaries (id, name, description, data_description).
+ """
+ templates = runtime.state.get("templates", [])
+ return [
+ {
+ "id": t["id"],
+ "name": t["name"],
+ "description": t["description"],
+ "data_description": t["data_description"],
+ "version": t["version"],
+ }
+ for t in templates
+ ]
+
+
+@tool
+def apply_template(template_id: str, runtime: ToolRuntime):
+ """
+ Retrieve a saved template's HTML so you can adapt it with new data.
+ After calling this, modify the HTML to fit the user's new data and render it via widgetRenderer.
+
+ Args:
+ template_id: The ID of the template to apply
+ """
+ templates = runtime.state.get("templates", [])
+ for t in templates:
+ if t["id"] == template_id:
+ return {
+ "name": t["name"],
+ "description": t["description"],
+ "html": t["html"],
+ "data_description": t["data_description"],
+ }
+ return {"error": f"Template with id '{template_id}' not found"}
+
+
+@tool
+def delete_template(template_id: str, runtime: ToolRuntime) -> Command:
+ """
+ Delete a saved UI template.
+
+ Args:
+ template_id: The ID of the template to delete
+ """
+ templates = list(runtime.state.get("templates", []))
+ original_len = len(templates)
+ templates = [t for t in templates if t["id"] != template_id]
+
+ if len(templates) == original_len:
+ return Command(update={
+ "messages": [
+ ToolMessage(
+ content=f"Template with id '{template_id}' not found",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+ return Command(update={
+ "templates": templates,
+ "messages": [
+ ToolMessage(
+ content=f"Template deleted successfully",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+
+template_tools = [
+ save_template,
+ list_templates,
+ apply_template,
+ delete_template,
+]
diff --git a/apps/agent/src/todos.py b/apps/agent/src/todos.py
index b647fee..ce1f731 100644
--- a/apps/agent/src/todos.py
+++ b/apps/agent/src/todos.py
@@ -5,6 +5,8 @@
from typing import TypedDict, Literal
import uuid
+from src.templates import UITemplate
+
class Todo(TypedDict):
id: str
title: str
@@ -14,6 +16,7 @@ class Todo(TypedDict):
class AgentState(BaseAgentState):
todos: list[Todo]
+ templates: list[UITemplate]
@tool
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css
index 0c98827..75d41c6 100644
--- a/apps/app/src/app/globals.css
+++ b/apps/app/src/app/globals.css
@@ -621,3 +621,19 @@ body, html {
@keyframes spin {
to { transform: rotate(360deg); }
}
+
+/* Template save animations */
+@keyframes tmpl-pop {
+ 0% { transform: scale(0.8); opacity: 0; }
+ 50% { transform: scale(1.05); }
+ 100% { transform: scale(1); opacity: 1; }
+}
+
+@keyframes tmpl-check {
+ to { stroke-dashoffset: 0; }
+}
+
+@keyframes tmpl-slideIn {
+ from { transform: translateY(-4px) scale(0.97); opacity: 0; }
+ to { transform: translateY(0) scale(1); opacity: 1; }
+}
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index 7994182..c23ab68 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -1,26 +1,76 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useState, useCallback } from "react";
import { ExampleLayout } from "@/components/example-layout";
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
import { ExplainerCardsPortal } from "@/components/explainer-cards";
+import { TemplateLibrary } from "@/components/template-library";
import { CopilotChat } from "@copilotkit/react-core/v2";
+import { useAgent } from "@copilotkit/react-core/v2";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
- // Widget bridge: handle openLink from widget iframes
+ const { agent } = useAgent();
+ const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
+
+ // Save a template directly to agent state — no chat round-trip
+ const saveTemplate = useCallback((data: {
+ name: string;
+ title: string;
+ description: string;
+ html: string;
+ }) => {
+ const templates = agent.state?.templates || [];
+ const newTemplate = {
+ id: crypto.randomUUID(),
+ name: data.name || data.title || "Untitled Template",
+ description: data.description || data.title || "",
+ html: data.html,
+ data_description: "",
+ created_at: new Date().toISOString(),
+ version: 1,
+ };
+ agent.setState({ templates: [...templates, newTemplate] });
+ }, [agent]);
+
+ // Send a prompt to the CopilotChat by finding its textarea and submitting
+ const sendPrompt = useCallback((text: string) => {
+ const input = document.querySelector(
+ '[class*="copilot"] textarea, [data-copilotkit] textarea'
+ );
+ if (input) {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLTextAreaElement.prototype,
+ "value"
+ )?.set;
+ setter?.call(input, text);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ setTimeout(() => {
+ const form = input.closest("form");
+ if (form) {
+ form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ }
+ }, 50);
+ }
+ }, []);
+
+ // Widget bridge: handle messages from widget iframes
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === "open-link" && typeof e.data.url === "string") {
window.open(e.data.url, "_blank", "noopener,noreferrer");
}
+ // Handle save-as-template from WidgetRenderer — save directly to state
+ if (e.data?.type === "save-as-template") {
+ saveTemplate(e.data);
+ }
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
- }, []);
+ }, [saveTemplate]);
return (
<>
@@ -58,19 +108,38 @@ export default function HomePage() {
— powered by CopilotKit
-
- Get started
-
+
+ {/* Template Library toggle */}
+
+
+ Get started
+
+
@@ -84,6 +153,13 @@ export default function HomePage() {
+
+ {/* Template Library Drawer */}
+ setTemplateDrawerOpen(false)}
+ onSendPrompt={sendPrompt}
+ />
>
);
}
diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx
index f978385..a69eb4c 100644
--- a/apps/app/src/components/generative-ui/widget-renderer.tsx
+++ b/apps/app/src/components/generative-ui/widget-renderer.tsx
@@ -421,10 +421,14 @@ function useLoadingPhrase(active: boolean) {
}
// ─── React Component ─────────────────────────────────────────────────
+type SaveState = "idle" | "input" | "saving" | "saved";
+
export function WidgetRenderer({ title, description, html }: WidgetRendererProps) {
const iframeRef = useRef(null);
const [height, setHeight] = useState(0);
const [loaded, setLoaded] = useState(false);
+ const [saveState, setSaveState] = useState("idle");
+ const [templateName, setTemplateName] = useState("");
// Track what html has been committed to the iframe to avoid redundant reloads
const committedHtmlRef = useRef("");
@@ -473,8 +477,139 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
const showLoading = !!html && !ready;
const loadingPhrase = useLoadingPhrase(showLoading);
+ const handleSaveTemplate = useCallback(() => {
+ const name = templateName.trim() || title || "Untitled Template";
+ setSaveState("saving");
+ window.postMessage(
+ { type: "save-as-template", name, title, description, html },
+ "*"
+ );
+ setTemplateName("");
+ // Brief "saving" pulse then show "saved" confirmation
+ setTimeout(() => {
+ setSaveState("saved");
+ // Return to idle after the confirmation
+ setTimeout(() => setSaveState("idle"), 1800);
+ }, 400);
+ }, [templateName, title, description, html]);
+
return (
-
+
+ {/* Save as Template — only shown when widget is ready */}
+ {ready && html && (
+
+ {/* ── Saved confirmation ── */}
+ {saveState === "saved" && (
+
+ )}
+
+ {/* ── Saving spinner ── */}
+ {saveState === "saving" && (
+
+ )}
+
+ {/* ── Name input ── */}
+ {saveState === "input" && (
+
+ setTemplateName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSaveTemplate();
+ if (e.key === "Escape") {
+ setSaveState("idle");
+ setTemplateName("");
+ }
+ }}
+ autoFocus
+ className="text-xs px-2 py-1 rounded-md outline-none"
+ style={{
+ width: 140,
+ background: "var(--color-background-secondary, #f5f5f5)",
+ color: "var(--text-primary, #1a1a1a)",
+ border: "1px solid var(--color-border-tertiary, rgba(0,0,0,0.1))",
+ }}
+ />
+
+
+
+ )}
+
+ {/* ── Idle bookmark button ── */}
+ {saveState === "idle" && (
+
+ )}
+
+ )}
{/* Loading indicator: visible until iframe is fully ready */}
{showLoading && (
void;
+ onSendPrompt: (text: string) => void;
+}
+
+interface Template {
+ id: string;
+ name: string;
+ description: string;
+ html: string;
+ data_description: string;
+ version: number;
+}
+
+export function TemplateLibrary({ open, onClose, onSendPrompt }: TemplateLibraryProps) {
+ const { agent } = useAgent();
+ const templates: Template[] = agent.state?.templates || [];
+
+ const handleApply = (id: string, name: string) => {
+ onSendPrompt(`Apply the "${name}" template (id: ${id}) to my new data`);
+ onClose();
+ };
+
+ const handleDelete = (id: string) => {
+ agent.setState({
+ templates: templates.filter((t) => t.id !== id),
+ });
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+ {open && (
+
+ )}
+
+ {/* Drawer panel */}
+
+ {/* Header */}
+
+
+
+
+ Templates
+
+
+ {templates.length}
+
+
+
+
+
+ {/* Content */}
+
+ {templates.length === 0 ? (
+
+
+
+ No templates yet
+
+
+ Hover over a widget and click "Save as Template" to save it for reuse.
+
+
+ ) : (
+
+ {templates.map((t) => (
+
+ ))}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/app/src/components/template-library/template-card.tsx b/apps/app/src/components/template-library/template-card.tsx
new file mode 100644
index 0000000..304cbac
--- /dev/null
+++ b/apps/app/src/components/template-library/template-card.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useRef, useEffect, useState } from "react";
+
+interface TemplateCardProps {
+ id: string;
+ name: string;
+ description: string;
+ html: string;
+ dataDescription: string;
+ version: number;
+ onApply: (id: string, name: string) => void;
+ onDelete: (id: string, name: string) => void;
+}
+
+export function TemplateCard({
+ id,
+ name,
+ description,
+ html,
+ dataDescription,
+ version,
+ onApply,
+ onDelete,
+}: TemplateCardProps) {
+ const iframeRef = useRef
(null);
+ const [previewReady, setPreviewReady] = useState(false);
+
+ useEffect(() => {
+ if (!iframeRef.current || !html) return;
+ const doc = `
+
+${html}
`;
+ iframeRef.current.srcdoc = doc;
+ const timer = setTimeout(() => setPreviewReady(true), 500);
+ return () => clearTimeout(timer);
+ }, [html]);
+
+ return (
+
+ {/* Preview */}
+
+
+ {/* Version badge */}
+
+ v{version}
+
+
+
+ {/* Info */}
+
+
+ {name}
+
+
+ {description}
+
+ {dataDescription && (
+
+ Data: {dataDescription}
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+ );
+}
From e33f7c74d24b1d651962e6f0676ccc84710c1f14 Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 22:25:10 +0800
Subject: [PATCH 02/16] fix: address code review feedback
- Remove misleading system prompt instruction about "save-as-template"
messages (saves go directly via agent.setState, not through chat)
- Replace fragile DOM querySelector hack for sending prompts with
agent.addMessage() + agent.runAgent() API calls
---
apps/agent/main.py | 2 --
apps/app/src/app/page.tsx | 27 ++++++++-------------------
2 files changed, 8 insertions(+), 21 deletions(-)
diff --git a/apps/agent/main.py b/apps/agent/main.py
index 1d03975..004d8da 100644
--- a/apps/agent/main.py
+++ b/apps/agent/main.py
@@ -60,8 +60,6 @@
user's new data and render via `widgetRenderer`.
- When a user asks to see their templates, call `list_templates`.
- When a user asks to delete a template, call `delete_template`.
- - A "save-as-template" message from the frontend means the user clicked the save
- button on a widget. Extract the template details and call `save_template`.
""",
)
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index c23ab68..df8f6d6 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -36,26 +36,15 @@ export default function HomePage() {
agent.setState({ templates: [...templates, newTemplate] });
}, [agent]);
- // Send a prompt to the CopilotChat by finding its textarea and submitting
+ // Send a prompt via the agent API — adds a user message and triggers a run
const sendPrompt = useCallback((text: string) => {
- const input = document.querySelector(
- '[class*="copilot"] textarea, [data-copilotkit] textarea'
- );
- if (input) {
- const setter = Object.getOwnPropertyDescriptor(
- window.HTMLTextAreaElement.prototype,
- "value"
- )?.set;
- setter?.call(input, text);
- input.dispatchEvent(new Event("input", { bubbles: true }));
- setTimeout(() => {
- const form = input.closest("form");
- if (form) {
- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
- }
- }, 50);
- }
- }, []);
+ agent.addMessage({
+ id: crypto.randomUUID(),
+ role: "user",
+ content: text,
+ });
+ agent.runAgent();
+ }, [agent]);
// Widget bridge: handle messages from widget iframes
useEffect(() => {
From cd4feb25e10e218dded29f0cbd71115805dba065 Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 23:04:03 +0800
Subject: [PATCH 03/16] feat: progressive streaming rendering for widget
previews
Replace the hide-everything-then-reveal approach with progressive
streaming that shows content building up as the LLM streams HTML
tokens. The iframe shell loads once and content updates are sent
via postMessage instead of full srcdoc reloads.
- Loading phrases shown above the widget during streaming
- Save Template button only appears after streaming settles
- Debounced htmlSettled detection (800ms of no changes)
- Smooth fade-out transitions for streaming indicator
---
.../generative-ui/widget-renderer.tsx | 206 ++++++++++++------
1 file changed, 143 insertions(+), 63 deletions(-)
diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx
index a69eb4c..03f997a 100644
--- a/apps/app/src/components/generative-ui/widget-renderer.tsx
+++ b/apps/app/src/components/generative-ui/widget-renderer.tsx
@@ -346,6 +346,28 @@ document.addEventListener('click', function(e) {
}
});
+// Listen for streaming content updates from parent
+window.addEventListener('message', function(e) {
+ if (e.data && e.data.type === 'update-content') {
+ var content = document.getElementById('content');
+ if (content) {
+ content.innerHTML = e.data.html;
+ // Re-run any inline scripts (new ones added by streaming)
+ var scripts = content.querySelectorAll('script');
+ scripts.forEach(function(oldScript) {
+ var newScript = document.createElement('script');
+ if (oldScript.src) {
+ newScript.src = oldScript.src;
+ } else {
+ newScript.textContent = oldScript.textContent;
+ }
+ oldScript.parentNode.replaceChild(newScript, oldScript);
+ });
+ reportHeight();
+ }
+ }
+});
+
// Auto-resize: report content height to host
function reportHeight() {
var content = document.getElementById('content');
@@ -361,7 +383,13 @@ setTimeout(function() { clearInterval(_resizeInterval); }, 15000);
`;
// ─── Document Assembly ───────────────────────────────────────────────
+/** Full document with content — used for final/complete renders */
function assembleDocument(html: string): string {
+ return assembleShell(html);
+}
+
+/** Empty shell or shell with initial content — iframe loads once, content streamed via postMessage */
+function assembleShell(initialHtml: string = ""): string {
return `
@@ -387,7 +415,7 @@ function assembleDocument(html: string): string {
- ${html}
+ ${initialHtml}
`;
+
+export const SEED_TEMPLATES: SeedTemplate[] = [
+ {
+ id: "seed-weather-001",
+ name: "Weather",
+ description: "Current weather conditions card with temperature, humidity, wind, and UV index",
+ html: weatherHtml,
+ data_description: "City name, date, temperature, condition, humidity, wind speed/direction, UV index",
+ created_at: "2026-01-01T00:00:00.000Z",
+ version: 1,
+ },
+ {
+ id: "seed-invoice-001",
+ name: "Invoice Card",
+ description: "Compact invoice card with amount, client info, and action buttons",
+ html: invoiceHtml,
+ data_description: "Title, amount, description, client name, billing month, invoice number, due date",
+ created_at: "2026-01-01T00:00:01.000Z",
+ version: 1,
+ },
+ {
+ id: "seed-calculator-001",
+ name: "Calculator",
+ description: "Interactive calculator with basic arithmetic operations",
+ html: calculatorHtml,
+ data_description: "N/A — interactive widget, no data substitution needed",
+ created_at: "2026-01-01T00:00:02.000Z",
+ version: 1,
+ },
+];
diff --git a/apps/app/src/hooks/use-seed-templates.ts b/apps/app/src/hooks/use-seed-templates.ts
new file mode 100644
index 0000000..4f3606e
--- /dev/null
+++ b/apps/app/src/hooks/use-seed-templates.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useAgent } from "@copilotkit/react-core/v2";
+import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
+
+/**
+ * Seeds the agent state with built-in templates on first load
+ * if no templates exist yet.
+ */
+export function useSeedTemplates() {
+ const { agent } = useAgent();
+ const seeded = useRef(false);
+
+ useEffect(() => {
+ if (seeded.current) return;
+ const existing = agent.state?.templates;
+ // Only seed if templates array is empty or absent
+ if (existing && existing.length > 0) {
+ seeded.current = true;
+ return;
+ }
+ seeded.current = true;
+ agent.setState({
+ ...agent.state,
+ templates: [...SEED_TEMPLATES],
+ });
+ }, [agent]);
+}
From a1c84e07428389f9d25e3fd0f16b6196d27006d3 Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 06:19:53 -0700
Subject: [PATCH 15/16] fix(templates): reliable apply via pending_template and
non-overlapping badge
- apply_template tool now auto-reads pending_template from state before
falling back to name/ID args, so the agent doesn't need to parse state
- Seed templates merged client-side in TemplateLibrary (removed broken
agent-state seeding hook that ran before session was established)
- Template name badge moved above widget content to avoid overlapping
- Badge detects source template via pending_template ref + exact HTML match
---
apps/agent/src/templates.py | 14 +++--
apps/app/src/app/page.tsx | 2 -
.../generative-ui/save-template-overlay.tsx | 57 ++++++++++++++++++-
.../src/components/template-library/index.tsx | 15 ++++-
apps/app/src/hooks/use-seed-templates.ts | 29 ----------
5 files changed, 77 insertions(+), 40 deletions(-)
delete mode 100644 apps/app/src/hooks/use-seed-templates.ts
diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py
index 4788223..7eb1605 100644
--- a/apps/agent/src/templates.py
+++ b/apps/agent/src/templates.py
@@ -84,15 +84,21 @@ def apply_template(name: str = "", template_id: str = "", runtime: ToolRuntime =
Retrieve a saved template's HTML so you can adapt it with new data.
After calling this, generate a NEW widget in the same style and render via widgetRenderer.
- You can look up by name or ID. If both are provided, ID takes priority.
- When multiple templates share the same name, returns the most recently created one.
+ This tool automatically checks for a pending_template in state (set by the
+ frontend when the user picks a template from the library). If pending_template
+ is present, it takes priority over name/template_id arguments.
Args:
- name: The name of the template to apply (e.g. "Invoice")
- template_id: The ID of the template to apply (optional)
+ name: The name of the template to apply (fallback if no pending_template)
+ template_id: The ID of the template to apply (fallback if no pending_template)
"""
templates = runtime.state.get("templates", [])
+ # Check pending_template from frontend first — this is the most reliable source
+ pending = runtime.state.get("pending_template")
+ if pending and pending.get("id"):
+ template_id = pending["id"]
+
# Look up by ID first
if template_id:
for t in templates:
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index 64172a6..5f52618 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { ExampleLayout } from "@/components/example-layout";
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
-import { useSeedTemplates } from "@/hooks/use-seed-templates";
import { ExplainerCardsPortal } from "@/components/explainer-cards";
import { TemplateLibrary } from "@/components/template-library";
import { TemplateChip } from "@/components/template-library/template-chip";
@@ -13,7 +12,6 @@ import { CopilotChat } from "@copilotkit/react-core/v2";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
- useSeedTemplates();
const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx
index 324ade4..e071bc9 100644
--- a/apps/app/src/components/generative-ui/save-template-overlay.tsx
+++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState, useCallback, type ReactNode } from "react";
+import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
+import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
type SaveState = "idle" | "input" | "saving" | "saved";
@@ -34,6 +35,37 @@ export function SaveTemplateOverlay({
const [saveState, setSaveState] = useState("idle");
const [templateName, setTemplateName] = useState("");
+ // Capture pending_template at mount time — it may be cleared by the agent later
+ const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined;
+ const sourceRef = useRef<{ id: string; name: string } | null>(null);
+ if (pending?.id && !sourceRef.current) {
+ sourceRef.current = pending;
+ }
+
+ // Check if this content matches an existing template:
+ // 1. Exact HTML match (seed templates rendered as-is)
+ // 2. Source template captured from pending_template (applied templates with modified data)
+ const matchedTemplate = useMemo(() => {
+ // First check source template from apply flow
+ if (sourceRef.current) {
+ const allTemplates = [
+ ...SEED_TEMPLATES,
+ ...((agent.state?.templates as { id: string; name: string }[]) || []),
+ ];
+ const source = allTemplates.find((t) => t.id === sourceRef.current!.id);
+ if (source) return source;
+ }
+ // Then check exact HTML match
+ if (!html) return null;
+ const normalise = (s: string) => s.replace(/\s+/g, " ").trim();
+ const norm = normalise(html);
+ const allTemplates = [
+ ...SEED_TEMPLATES,
+ ...((agent.state?.templates as { id: string; name: string; html: string }[]) || []),
+ ];
+ return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null;
+ }, [html, agent.state?.templates]);
+
const handleSave = useCallback(() => {
const name = templateName.trim() || title || "Untitled Template";
setSaveState("saving");
@@ -161,8 +193,8 @@ export function SaveTemplateOverlay({
)}
- {/* Idle bookmark button */}
- {saveState === "idle" && (
+ {/* Idle: show save button (badge moved outside this container) */}
+ {saveState === "idle" && !matchedTemplate && (
+ {/* Template name badge — shown above widget when matched */}
+ {saveState === "idle" && matchedTemplate && ready && (
+
+
+
+ {matchedTemplate.name}
+
+
+ )}
+
{children}
);
diff --git a/apps/app/src/components/template-library/index.tsx b/apps/app/src/components/template-library/index.tsx
index 1b9fbfc..164af19 100644
--- a/apps/app/src/components/template-library/index.tsx
+++ b/apps/app/src/components/template-library/index.tsx
@@ -2,6 +2,7 @@
import { useAgent } from "@copilotkit/react-core/v2";
import { TemplateCard } from "./template-card";
+import { SEED_TEMPLATES } from "./seed-templates";
interface TemplateLibraryProps {
open: boolean;
@@ -21,15 +22,25 @@ interface Template {
export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
const { agent } = useAgent();
- const templates: Template[] = agent.state?.templates || [];
+ const agentTemplates: Template[] = agent.state?.templates || [];
+ // Merge seed templates with user-saved ones
+ const templates: Template[] = [
+ ...SEED_TEMPLATES.filter((s) => !agentTemplates.some((t) => t.id === s.id)),
+ ...agentTemplates,
+ ];
const handleApplyClick = (id: string) => {
const template = templates.find((t) => t.id === id);
if (!template) return;
- // Attach template as a chip in the chat input — user types their prompt naturally
+ // Ensure template is in agent state so the backend can retrieve it via apply_template
+ const stateTemplates = agentTemplates.some((t) => t.id === id)
+ ? agentTemplates
+ : [...agentTemplates, template];
+
agent.setState({
...agent.state,
+ templates: stateTemplates,
pending_template: { id: template.id, name: template.name },
});
onClose();
diff --git a/apps/app/src/hooks/use-seed-templates.ts b/apps/app/src/hooks/use-seed-templates.ts
deleted file mode 100644
index 4f3606e..0000000
--- a/apps/app/src/hooks/use-seed-templates.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { useAgent } from "@copilotkit/react-core/v2";
-import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
-
-/**
- * Seeds the agent state with built-in templates on first load
- * if no templates exist yet.
- */
-export function useSeedTemplates() {
- const { agent } = useAgent();
- const seeded = useRef(false);
-
- useEffect(() => {
- if (seeded.current) return;
- const existing = agent.state?.templates;
- // Only seed if templates array is empty or absent
- if (existing && existing.length > 0) {
- seeded.current = true;
- return;
- }
- seeded.current = true;
- agent.setState({
- ...agent.state,
- templates: [...SEED_TEMPLATES],
- });
- }, [agent]);
-}
From 368517f906394e4d59d8c6520ad90f2ae1f24372 Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 06:34:31 -0700
Subject: [PATCH 16/16] feat(templates): replace calculator seed with dashboard
template
Swap the calculator widget for a KPI dashboard with revenue, active
users, conversion metrics, and a monthly revenue bar chart.
---
.../template-library/seed-templates.ts | 133 +++++++++---------
1 file changed, 64 insertions(+), 69 deletions(-)
diff --git a/apps/app/src/components/template-library/seed-templates.ts b/apps/app/src/components/template-library/seed-templates.ts
index aa3b9f3..6c46ac3 100644
--- a/apps/app/src/components/template-library/seed-templates.ts
+++ b/apps/app/src/components/template-library/seed-templates.ts
@@ -127,81 +127,76 @@ const invoiceHtml = `
-
-
-
-
0
+
+
Q1 2026 Performance
+
Revenue, users, and conversion metrics — Jan to Mar 2026
+
+
+
Revenue
+
$284k
+
+12.3% vs Q4
+
+
+
Active Users
+
18.2k
+
+8.1% vs Q4
+
+
+
Conversion
+
3.4%
+
-0.2% vs Q4
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-`;
+
`;
export const SEED_TEMPLATES: SeedTemplate[] = [
{
@@ -223,11 +218,11 @@ export const SEED_TEMPLATES: SeedTemplate[] = [
version: 1,
},
{
- id: "seed-calculator-001",
- name: "Calculator",
- description: "Interactive calculator with basic arithmetic operations",
- html: calculatorHtml,
- data_description: "N/A — interactive widget, no data substitution needed",
+ id: "seed-dashboard-001",
+ name: "Dashboard",
+ description: "KPI dashboard with metrics cards and bar chart for quarterly performance",
+ html: dashboardHtml,
+ data_description: "Title, subtitle, KPI labels/values/changes, monthly bar chart data, legend items",
created_at: "2026-01-01T00:00:02.000Z",
version: 1,
},