Skip to content

Commit b24a7a9

Browse files
committed
fix(templates): resolve review findings — security comment, chip fallback, seed sync, param order
- 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
1 parent f958184 commit b24a7a9

4 files changed

Lines changed: 89 additions & 37 deletions

File tree

apps/agent/src/templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def list_templates(runtime: ToolRuntime):
7979

8080

8181
@tool
82-
def apply_template(name: str = "", template_id: str = "", runtime: ToolRuntime = None):
82+
def apply_template(runtime: ToolRuntime, name: str = "", template_id: str = ""):
8383
"""
8484
Retrieve a saved template's HTML so you can adapt it with new data.
8585
After calling this, generate a NEW widget in the same style and render via widgetRenderer.

apps/app/src/components/generative-ui/widget-renderer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
517517

518518
const iframe = iframeRef.current;
519519
if (iframe.contentWindow) {
520+
// targetOrigin "*" is required: the sandboxed iframe (allow-scripts only,
521+
// no allow-same-origin) has a null origin, so no specific origin can be used.
520522
iframe.contentWindow.postMessage(
521523
{ type: "update-content", html },
522524
"*"

apps/app/src/components/template-library/index.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useEffect } from "react";
34
import { useAgent } from "@copilotkit/react-core/v2";
45
import { TemplateCard } from "./template-card";
56
import { SEED_TEMPLATES } from "./seed-templates";
@@ -23,7 +24,24 @@ interface Template {
2324
export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
2425
const { agent } = useAgent();
2526
const agentTemplates: Template[] = agent.state?.templates || [];
26-
// Merge seed templates with user-saved ones
27+
28+
// Seed templates into agent state on first render so the backend can find them
29+
// via apply_template even when the user asks by name in chat (no pending_template).
30+
useEffect(() => {
31+
const missing = SEED_TEMPLATES.filter(
32+
(s) => !agentTemplates.some((t) => t.id === s.id)
33+
);
34+
if (missing.length > 0 && agent.state) {
35+
agent.setState({
36+
...agent.state,
37+
templates: [...agentTemplates, ...missing],
38+
});
39+
}
40+
// Only run when agent state first becomes available
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, [!!agent.state]);
43+
44+
// Merge seed templates with user-saved ones for display
2745
const templates: Template[] = [
2846
...SEED_TEMPLATES.filter((s) => !agentTemplates.some((t) => t.id === s.id)),
2947
...agentTemplates,

apps/app/src/components/template-library/template-chip.tsx

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,87 @@
11
"use client";
22

3-
import { useEffect, useState, useCallback } from "react";
3+
import { useSyncExternalStore, useEffect, useCallback } from "react";
44
import { createPortal } from "react-dom";
55
import { useAgent } from "@copilotkit/react-core/v2";
66

77
/**
8-
* Renders a dismissible chip inside the CopilotChat input pill when a template
9-
* is attached (pending_template is set in agent state). Uses a portal to insert
10-
* itself inside the textarea's column container.
8+
* Manages the DOM element used as the portal container for the template chip.
9+
* Uses a subscribe/getSnapshot pattern compatible with useSyncExternalStore
10+
* to avoid setState-in-effect and ref-during-render lint violations.
11+
*
12+
* COUPLING NOTE: This depends on CopilotKit's internal DOM structure —
13+
* specifically that `[data-testid="copilot-chat-textarea"]` exists and sits inside
14+
* a parent column div. If CopilotKit changes this structure, the chip falls back
15+
* to rendering inline instead of portaling into the textarea.
1116
*/
17+
let chipContainer: HTMLElement | null = null;
18+
const listeners = new Set<() => void>();
19+
20+
function getChipContainer() {
21+
return chipContainer;
22+
}
23+
24+
function subscribeChipContainer(cb: () => void) {
25+
listeners.add(cb);
26+
return () => { listeners.delete(cb); };
27+
}
28+
29+
function ensureChipContainer(active: boolean) {
30+
if (!active) {
31+
if (chipContainer) {
32+
chipContainer.remove();
33+
chipContainer = null;
34+
listeners.forEach((cb) => cb());
35+
}
36+
return;
37+
}
38+
39+
if (chipContainer) return;
40+
41+
const textarea = document.querySelector<HTMLElement>(
42+
'[data-testid="copilot-chat-textarea"]'
43+
);
44+
const textareaColumn = textarea?.parentElement;
45+
if (!textareaColumn) return;
46+
47+
let el = textareaColumn.querySelector<HTMLElement>("[data-template-chip]");
48+
if (!el) {
49+
el = document.createElement("div");
50+
el.setAttribute("data-template-chip", "");
51+
el.style.cssText = "display: flex; padding: 4px 0 0 0;";
52+
textareaColumn.insertBefore(el, textarea);
53+
}
54+
chipContainer = el;
55+
listeners.forEach((cb) => cb());
56+
}
57+
1258
export function TemplateChip() {
1359
const { agent } = useAgent();
1460
const pending = agent.state?.pending_template as
1561
| { id: string; name: string }
1662
| null
1763
| undefined;
1864

19-
const [container, setContainer] = useState<HTMLElement | null>(null);
65+
const container = useSyncExternalStore(subscribeChipContainer, getChipContainer, () => null);
2066

2167
useEffect(() => {
22-
if (!pending?.name) {
23-
// Clean up existing container
24-
document.querySelector("[data-template-chip]")?.remove();
25-
setContainer(null);
26-
return;
27-
}
28-
29-
const textarea = document.querySelector<HTMLElement>(
30-
'[data-testid="copilot-chat-textarea"]'
31-
);
32-
// The textarea sits inside a column div inside the grid
33-
const textareaColumn = textarea?.parentElement;
34-
if (!textareaColumn) {
35-
setContainer(null);
36-
return;
37-
}
38-
39-
// Reuse existing or create chip container
40-
let el = textareaColumn.querySelector<HTMLElement>("[data-template-chip]");
41-
if (!el) {
42-
el = document.createElement("div");
43-
el.setAttribute("data-template-chip", "");
44-
el.style.cssText = "display: flex; padding: 4px 0 0 0;";
45-
textareaColumn.insertBefore(el, textarea);
46-
}
47-
setContainer(el);
68+
ensureChipContainer(!!pending?.name);
4869
}, [pending?.name]);
4970

71+
// Clean up DOM node on unmount
72+
useEffect(() => {
73+
return () => {
74+
ensureChipContainer(false);
75+
};
76+
}, []);
77+
5078
const handleDismiss = useCallback(() => {
5179
agent.setState({ ...agent.state, pending_template: null });
5280
}, [agent]);
5381

54-
if (!pending?.name || !container) return null;
82+
if (!pending?.name) return null;
5583

56-
return createPortal(
84+
const chipContent = (
5785
<div
5886
className="inline-flex items-center gap-1.5 pl-2 pr-1 py-0.5 rounded-md text-xs font-medium select-none"
5987
style={{
@@ -101,7 +129,11 @@ export function TemplateChip() {
101129
<path d="m6 6 12 12" />
102130
</svg>
103131
</button>
104-
</div>,
105-
container
132+
</div>
106133
);
134+
135+
// Fallback: render inline when CopilotKit DOM structure isn't available
136+
if (!container) return chipContent;
137+
138+
return createPortal(chipContent, container);
107139
}

0 commit comments

Comments
 (0)