From 7e5fc35038948fc31426e224bd5afcc1402772ca Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 21:59:27 +0800
Subject: [PATCH 01/19] 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 50d00e211b3cd163a6c41d06d4dcc6f91b1219d4 Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 22:25:10 +0800
Subject: [PATCH 02/19] 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 245b314723d8dc71d0e3b57afd3079b87d1cfb96 Mon Sep 17 00:00:00 2001
From: davidpanonce-nx
Date: Fri, 20 Mar 2026 23:04:03 +0800
Subject: [PATCH 03/19] 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 3386854946d96def53246072e03747c07bf4ce44 Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 06:19:53 -0700
Subject: [PATCH 15/19] 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 f95818429333fb0db194e89e9bee2d116ee09a4a Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 06:34:31 -0700
Subject: [PATCH 16/19] 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,
},
From b24a7a97b8d4d1e37b02072f637f4bdec62f1dee Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 06:47:50 -0700
Subject: [PATCH 17/19] =?UTF-8?q?fix(templates):=20resolve=20review=20find?=
=?UTF-8?q?ings=20=E2=80=94=20security=20comment,=20chip=20fallback,=20see?=
=?UTF-8?q?d=20sync,=20param=20order?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add comment explaining why postMessage uses targetOrigin "*" (sandboxed iframe)
- Rewrite TemplateChip with useSyncExternalStore to satisfy lint rules, add
inline fallback when CopilotKit DOM structure isn't available, document coupling
- Seed templates into agent state on first render so apply_template works when
users ask by name in chat (not just via UI button)
- Reorder apply_template params to put runtime first, removing unsafe None default
---
apps/agent/src/templates.py | 2 +-
.../generative-ui/widget-renderer.tsx | 2 +
.../src/components/template-library/index.tsx | 20 +++-
.../template-library/template-chip.tsx | 102 ++++++++++++------
4 files changed, 89 insertions(+), 37 deletions(-)
diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py
index 7eb1605..d4327fe 100644
--- a/apps/agent/src/templates.py
+++ b/apps/agent/src/templates.py
@@ -79,7 +79,7 @@ def list_templates(runtime: ToolRuntime):
@tool
-def apply_template(name: str = "", template_id: str = "", runtime: ToolRuntime = None):
+def apply_template(runtime: ToolRuntime, name: str = "", template_id: str = ""):
"""
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.
diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx
index 3aa17fd..15c7fa7 100644
--- a/apps/app/src/components/generative-ui/widget-renderer.tsx
+++ b/apps/app/src/components/generative-ui/widget-renderer.tsx
@@ -517,6 +517,8 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
const iframe = iframeRef.current;
if (iframe.contentWindow) {
+ // targetOrigin "*" is required: the sandboxed iframe (allow-scripts only,
+ // no allow-same-origin) has a null origin, so no specific origin can be used.
iframe.contentWindow.postMessage(
{ type: "update-content", html },
"*"
diff --git a/apps/app/src/components/template-library/index.tsx b/apps/app/src/components/template-library/index.tsx
index 164af19..fecb1f5 100644
--- a/apps/app/src/components/template-library/index.tsx
+++ b/apps/app/src/components/template-library/index.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useEffect } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
import { TemplateCard } from "./template-card";
import { SEED_TEMPLATES } from "./seed-templates";
@@ -23,7 +24,24 @@ interface Template {
export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
const { agent } = useAgent();
const agentTemplates: Template[] = agent.state?.templates || [];
- // Merge seed templates with user-saved ones
+
+ // Seed templates into agent state on first render so the backend can find them
+ // via apply_template even when the user asks by name in chat (no pending_template).
+ useEffect(() => {
+ const missing = SEED_TEMPLATES.filter(
+ (s) => !agentTemplates.some((t) => t.id === s.id)
+ );
+ if (missing.length > 0 && agent.state) {
+ agent.setState({
+ ...agent.state,
+ templates: [...agentTemplates, ...missing],
+ });
+ }
+ // Only run when agent state first becomes available
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [!!agent.state]);
+
+ // Merge seed templates with user-saved ones for display
const templates: Template[] = [
...SEED_TEMPLATES.filter((s) => !agentTemplates.some((t) => t.id === s.id)),
...agentTemplates,
diff --git a/apps/app/src/components/template-library/template-chip.tsx b/apps/app/src/components/template-library/template-chip.tsx
index 21efd7b..31caa94 100644
--- a/apps/app/src/components/template-library/template-chip.tsx
+++ b/apps/app/src/components/template-library/template-chip.tsx
@@ -1,14 +1,60 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
+import { useSyncExternalStore, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useAgent } from "@copilotkit/react-core/v2";
/**
- * Renders a dismissible chip inside the CopilotChat input pill when a template
- * is attached (pending_template is set in agent state). Uses a portal to insert
- * itself inside the textarea's column container.
+ * Manages the DOM element used as the portal container for the template chip.
+ * Uses a subscribe/getSnapshot pattern compatible with useSyncExternalStore
+ * to avoid setState-in-effect and ref-during-render lint violations.
+ *
+ * COUPLING NOTE: This depends on CopilotKit's internal DOM structure —
+ * specifically that `[data-testid="copilot-chat-textarea"]` exists and sits inside
+ * a parent column div. If CopilotKit changes this structure, the chip falls back
+ * to rendering inline instead of portaling into the textarea.
*/
+let chipContainer: HTMLElement | null = null;
+const listeners = new Set<() => void>();
+
+function getChipContainer() {
+ return chipContainer;
+}
+
+function subscribeChipContainer(cb: () => void) {
+ listeners.add(cb);
+ return () => { listeners.delete(cb); };
+}
+
+function ensureChipContainer(active: boolean) {
+ if (!active) {
+ if (chipContainer) {
+ chipContainer.remove();
+ chipContainer = null;
+ listeners.forEach((cb) => cb());
+ }
+ return;
+ }
+
+ if (chipContainer) return;
+
+ const textarea = document.querySelector(
+ '[data-testid="copilot-chat-textarea"]'
+ );
+ const textareaColumn = textarea?.parentElement;
+ if (!textareaColumn) return;
+
+ let el = textareaColumn.querySelector("[data-template-chip]");
+ if (!el) {
+ el = document.createElement("div");
+ el.setAttribute("data-template-chip", "");
+ el.style.cssText = "display: flex; padding: 4px 0 0 0;";
+ textareaColumn.insertBefore(el, textarea);
+ }
+ chipContainer = el;
+ listeners.forEach((cb) => cb());
+}
+
export function TemplateChip() {
const { agent } = useAgent();
const pending = agent.state?.pending_template as
@@ -16,44 +62,26 @@ export function TemplateChip() {
| null
| undefined;
- const [container, setContainer] = useState(null);
+ const container = useSyncExternalStore(subscribeChipContainer, getChipContainer, () => null);
useEffect(() => {
- if (!pending?.name) {
- // Clean up existing container
- document.querySelector("[data-template-chip]")?.remove();
- setContainer(null);
- return;
- }
-
- const textarea = document.querySelector(
- '[data-testid="copilot-chat-textarea"]'
- );
- // The textarea sits inside a column div inside the grid
- const textareaColumn = textarea?.parentElement;
- if (!textareaColumn) {
- setContainer(null);
- return;
- }
-
- // Reuse existing or create chip container
- let el = textareaColumn.querySelector("[data-template-chip]");
- if (!el) {
- el = document.createElement("div");
- el.setAttribute("data-template-chip", "");
- el.style.cssText = "display: flex; padding: 4px 0 0 0;";
- textareaColumn.insertBefore(el, textarea);
- }
- setContainer(el);
+ ensureChipContainer(!!pending?.name);
}, [pending?.name]);
+ // Clean up DOM node on unmount
+ useEffect(() => {
+ return () => {
+ ensureChipContainer(false);
+ };
+ }, []);
+
const handleDismiss = useCallback(() => {
agent.setState({ ...agent.state, pending_template: null });
}, [agent]);
- if (!pending?.name || !container) return null;
+ if (!pending?.name) return null;
- return createPortal(
+ const chipContent = (
- ,
- container
+
);
+
+ // Fallback: render inline when CopilotKit DOM structure isn't available
+ if (!container) return chipContent;
+
+ return createPortal(chipContent, container);
}
From 917d72b24d1f775f7c251e45736231b1e75c219a Mon Sep 17 00:00:00 2001
From: jerelvelarde
Date: Tue, 24 Mar 2026 07:28:31 -0700
Subject: [PATCH 18/19] fix(templates): seed delete bug, backend seed lookup,
and delete button visibility
- Hide delete button on seed templates (they reappeared after deletion
because client-side merge re-added them on every render)
- handleDelete now filters from agentTemplates only, not the merged list
- Backend apply_template and list_templates now include SEED_TEMPLATES as
a fallback so users can apply seeds by name in chat without clicking
the UI button (fixes chat-only flow for seed templates)
- Seed HTML loaded from frontend source at module init (single source of truth)
---
apps/agent/src/templates.py | 78 ++++++++++++++++++-
.../src/components/template-library/index.tsx | 6 +-
.../template-library/seed-templates.ts | 2 +
.../template-library/template-card.tsx | 24 +++---
4 files changed, 93 insertions(+), 17 deletions(-)
diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py
index d4327fe..38e66c2 100644
--- a/apps/agent/src/templates.py
+++ b/apps/agent/src/templates.py
@@ -18,6 +18,70 @@ class UITemplate(TypedDict, total=False):
component_data: Optional[dict[str, Any]]
+# Built-in seed templates — must stay in sync with apps/app/src/components/template-library/seed-templates.ts
+# Only id, name, description, html, and data_description are needed for apply_template lookups.
+SEED_TEMPLATES: list[UITemplate] = [
+ {
+ "id": "seed-weather-001",
+ "name": "Weather",
+ "description": "Current weather conditions card with temperature, humidity, wind, and UV index",
+ "html": "", # Populated at module load from _SEED_HTML below
+ "data_description": "City name, date, temperature, condition, humidity, wind speed/direction, UV index",
+ "version": 1,
+ },
+ {
+ "id": "seed-invoice-001",
+ "name": "Invoice Card",
+ "description": "Compact invoice card with amount, client info, and action buttons",
+ "html": "",
+ "data_description": "Title, amount, description, client name, billing month, invoice number, due date",
+ "version": 1,
+ },
+ {
+ "id": "seed-dashboard-001",
+ "name": "Dashboard",
+ "description": "KPI dashboard with metrics cards and bar chart for quarterly performance",
+ "html": "",
+ "data_description": "Title, subtitle, KPI labels/values/changes, monthly bar chart data, legend items",
+ "version": 1,
+ },
+]
+
+# Load seed HTML from the frontend source so there's a single source of truth.
+# If the file isn't available (e.g. in a standalone agent deploy), seeds will
+# still be discoverable by name but with empty HTML — the agent can regenerate.
+def _load_seed_html() -> None:
+ from pathlib import Path
+
+ seed_file = Path(__file__).resolve().parents[2] / "app" / "src" / "components" / "template-library" / "seed-templates.ts"
+ if not seed_file.exists():
+ return
+ text = seed_file.read_text()
+ # Map TS variable names to seed IDs
+ mapping = {
+ "weatherHtml": "seed-weather-001",
+ "invoiceHtml": "seed-invoice-001",
+ "dashboardHtml": "seed-dashboard-001",
+ }
+ for var_name, seed_id in mapping.items():
+ # Extract template literal content between first ` and last `
+ marker = f"const {var_name} = `"
+ start = text.find(marker)
+ if start == -1:
+ continue
+ start += len(marker)
+ end = text.find("`;", start)
+ if end == -1:
+ continue
+ html = text[start:end]
+ for seed in SEED_TEMPLATES:
+ if seed["id"] == seed_id:
+ seed["html"] = html
+ break
+
+_load_seed_html()
+
+
@tool
def save_template(
name: str,
@@ -63,9 +127,12 @@ def save_template(
@tool
def list_templates(runtime: ToolRuntime):
"""
- List all saved UI templates. Returns template summaries (id, name, description, data_description).
+ List all saved UI templates, including built-in seed templates.
+ Returns template summaries (id, name, description, data_description).
"""
- templates = runtime.state.get("templates", [])
+ state_templates = runtime.state.get("templates", [])
+ state_ids = {t["id"] for t in state_templates}
+ templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]
return [
{
"id": t["id"],
@@ -88,11 +155,16 @@ def apply_template(runtime: ToolRuntime, name: str = "", template_id: str = ""):
frontend when the user picks a template from the library). If pending_template
is present, it takes priority over name/template_id arguments.
+ Also searches built-in seed templates, so users can apply them by name in chat
+ even if the frontend hasn't pushed them into agent state yet.
+
Args:
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", [])
+ state_templates = runtime.state.get("templates", [])
+ state_ids = {t["id"] for t in state_templates}
+ templates = [*state_templates, *(s for s in SEED_TEMPLATES if s["id"] not in state_ids)]
# Check pending_template from frontend first — this is the most reliable source
pending = runtime.state.get("pending_template")
diff --git a/apps/app/src/components/template-library/index.tsx b/apps/app/src/components/template-library/index.tsx
index fecb1f5..3fca2ea 100644
--- a/apps/app/src/components/template-library/index.tsx
+++ b/apps/app/src/components/template-library/index.tsx
@@ -3,7 +3,7 @@
import { useEffect } from "react";
import { useAgent } from "@copilotkit/react-core/v2";
import { TemplateCard } from "./template-card";
-import { SEED_TEMPLATES } from "./seed-templates";
+import { SEED_TEMPLATES, SEED_IDS } from "./seed-templates";
interface TemplateLibraryProps {
open: boolean;
@@ -75,7 +75,7 @@ export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
const handleDelete = (id: string) => {
agent.setState({
...agent.state,
- templates: templates.filter((t) => t.id !== id),
+ templates: agentTemplates.filter((t) => t.id !== id),
});
};
@@ -175,7 +175,7 @@ export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
dataDescription={t.data_description}
version={t.version}
onApply={handleApplyClick}
- onDelete={handleDelete}
+ onDelete={SEED_IDS.has(t.id) ? undefined : handleDelete}
/>
))}
diff --git a/apps/app/src/components/template-library/seed-templates.ts b/apps/app/src/components/template-library/seed-templates.ts
index 6c46ac3..847eb46 100644
--- a/apps/app/src/components/template-library/seed-templates.ts
+++ b/apps/app/src/components/template-library/seed-templates.ts
@@ -198,6 +198,8 @@ const dashboardHtml = `