Skip to content

Commit 5dd6191

Browse files
committed
feat(templates): inline template chip in chat input and fix state loss
Pass template context via agent state (pending_template) instead of embedding the template ID in the chat message text. The user's message stays clean — just their data description — while a dismissible chip renders inline inside the CopilotChat input pill. - Add PendingTemplate type and pending_template field to AgentState - Add clear_pending_template tool for agent to reset after applying - Update system prompt to read pending_template from state - Create TemplateChip portal component that injects into textarea column - Spread full agent.state on all setState calls to prevent field loss - Add chipIn keyframe animation
1 parent 90945cc commit 5dd6191

8 files changed

Lines changed: 159 additions & 15 deletions

File tree

apps/agent/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,16 @@
5555
You have backend tools: `save_template`, `list_templates`, `apply_template`, `delete_template`.
5656
5757
**When a user asks to apply/recreate a template with new data:**
58-
The message includes the template name and ID in the format: "template name" (template-id)
59-
1. Call `apply_template(template_id="...")` with the ID from the message
58+
Check `pending_template` in state — the frontend sets this when the user picks a template.
59+
If `pending_template` is present (has `id` and `name`):
60+
1. Call `apply_template(template_id=pending_template["id"])` to retrieve the HTML
6061
2. Take the returned HTML and COPY IT EXACTLY, only replacing the data values
61-
(names, numbers, dates, labels, amounts) to match the user's new data
62+
(names, numbers, dates, labels, amounts) to match the user's message
6263
3. Render the modified HTML using `widgetRenderer`
64+
4. Call `clear_pending_template` to reset the pending state
65+
66+
If no `pending_template` is set but the user mentions a template by name, use
67+
`apply_template(name="...")` instead.
6368
6469
CRITICAL: Do NOT rewrite or generate HTML from scratch. Take the original HTML string,
6570
find-and-replace ONLY the data values, and pass the result to widgetRenderer.

apps/agent/src/templates.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,27 @@ def delete_template(template_id: str, runtime: ToolRuntime) -> Command:
154154
})
155155

156156

157+
@tool
158+
def clear_pending_template(runtime: ToolRuntime) -> Command:
159+
"""
160+
Clear the pending_template from state after applying it.
161+
Call this after you have finished applying a template.
162+
"""
163+
return Command(update={
164+
"pending_template": None,
165+
"messages": [
166+
ToolMessage(
167+
content="Pending template cleared",
168+
tool_call_id=runtime.tool_call_id,
169+
)
170+
],
171+
})
172+
173+
157174
template_tools = [
158175
save_template,
159176
list_templates,
160177
apply_template,
161178
delete_template,
179+
clear_pending_template,
162180
]

apps/agent/src/todos.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from langchain.tools import ToolRuntime, tool
33
from langchain.messages import ToolMessage
44
from langgraph.types import Command
5-
from typing import TypedDict, Literal
5+
from typing import Optional, TypedDict, Literal
66
import uuid
77

88
from src.templates import UITemplate
@@ -14,9 +14,14 @@ class Todo(TypedDict):
1414
emoji: str
1515
status: Literal["pending", "completed"]
1616

17+
class PendingTemplate(TypedDict, total=False):
18+
id: str
19+
name: str
20+
1721
class AgentState(BaseAgentState):
1822
todos: list[Todo]
1923
templates: list[UITemplate]
24+
pending_template: Optional[PendingTemplate]
2025

2126
@tool
2227
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:

apps/app/src/app/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,3 +637,8 @@ body, html {
637637
from { transform: translateY(-4px) scale(0.97); opacity: 0; }
638638
to { transform: translateY(0) scale(1); opacity: 1; }
639639
}
640+
641+
@keyframes chipIn {
642+
from { transform: scale(0.9); opacity: 0; }
643+
to { transform: scale(1); opacity: 1; }
644+
}

apps/app/src/app/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ExampleLayout } from "@/components/example-layout";
55
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
66
import { ExplainerCardsPortal } from "@/components/explainer-cards";
77
import { TemplateLibrary } from "@/components/template-library";
8+
import { TemplateChip } from "@/components/template-library/template-chip";
89

910
import { CopilotChat } from "@copilotkit/react-core/v2";
1011

@@ -108,6 +109,9 @@ export default function HomePage() {
108109
</div>
109110
</div>
110111

112+
{/* Template chip — portal renders above chat input */}
113+
<TemplateChip />
114+
111115
{/* Template Library Drawer */}
112116
<TemplateLibrary
113117
open={templateDrawerOpen}

apps/app/src/components/generative-ui/save-template-overlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function SaveTemplateOverlay({
5050
created_at: new Date().toISOString(),
5151
version: 1,
5252
};
53-
agent.setState({ templates: [...templates, newTemplate] });
53+
agent.setState({ ...agent.state, templates: [...templates, newTemplate] });
5454

5555
setTemplateName("");
5656
setTimeout(() => {

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,20 @@ export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
6363
const dataDesc = applyData.trim();
6464
if (!dataDesc) return;
6565

66-
const isChart =
67-
applyingTemplate.component_type === "barChart" ||
68-
applyingTemplate.component_type === "pieChart";
69-
70-
const chartType = applyingTemplate.component_type === "pieChart" ? "pie chart" : "bar chart";
71-
const message = isChart
72-
? `Apply ${chartType} template "${applyingTemplate.name}" (${applyingTemplate.id}) with: ${dataDesc}`
73-
: `Apply template "${applyingTemplate.name}" (${applyingTemplate.id}) with: ${dataDesc}`;
66+
// Set pending_template in agent state so the agent knows which template to apply.
67+
// Spread full state to guard against replace-style setState.
68+
agent.setState({
69+
...agent.state,
70+
pending_template: { id: applyingTemplate.id, name: applyingTemplate.name },
71+
});
7472

75-
submitChatPrompt(message);
73+
// Send only the user's data description — no template ID in the message
74+
submitChatPrompt(dataDesc);
7675

7776
setApplyingTemplate(null);
7877
setApplyData("");
7978
onClose();
80-
}, [applyingTemplate, applyData, onClose]);
79+
}, [agent, applyingTemplate, applyData, onClose]);
8180

8281
const handleApplyCancel = () => {
8382
setApplyingTemplate(null);
@@ -86,6 +85,7 @@ export function TemplateLibrary({ open, onClose }: TemplateLibraryProps) {
8685

8786
const handleDelete = (id: string) => {
8887
agent.setState({
88+
...agent.state,
8989
templates: templates.filter((t) => t.id !== id),
9090
});
9191
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import { createPortal } from "react-dom";
5+
import { useAgent } from "@copilotkit/react-core/v2";
6+
7+
/**
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.
11+
*/
12+
export function TemplateChip() {
13+
const { agent } = useAgent();
14+
const pending = agent.state?.pending_template as
15+
| { id: string; name: string }
16+
| null
17+
| undefined;
18+
19+
const [container, setContainer] = useState<HTMLElement | null>(null);
20+
21+
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);
48+
}, [pending?.name]);
49+
50+
const handleDismiss = useCallback(() => {
51+
agent.setState({ ...agent.state, pending_template: null });
52+
}, [agent]);
53+
54+
if (!pending?.name || !container) return null;
55+
56+
return createPortal(
57+
<div
58+
className="inline-flex items-center gap-1.5 pl-2 pr-1 py-0.5 rounded-md text-xs font-medium select-none"
59+
style={{
60+
background:
61+
"linear-gradient(135deg, rgba(99,102,241,0.12), rgba(16,185,129,0.12))",
62+
border: "1px solid rgba(99,102,241,0.22)",
63+
color: "var(--copilot-kit-contrast-color, #1a1a1a)",
64+
animation: "chipIn 0.15s ease-out",
65+
}}
66+
>
67+
<svg
68+
width="11"
69+
height="11"
70+
viewBox="0 0 24 24"
71+
fill="none"
72+
stroke="currentColor"
73+
strokeWidth="2"
74+
strokeLinecap="round"
75+
strokeLinejoin="round"
76+
style={{ opacity: 0.55, flexShrink: 0 }}
77+
>
78+
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
79+
</svg>
80+
<span className="max-w-[140px] truncate" style={{ lineHeight: "16px" }}>
81+
{pending.name}
82+
</span>
83+
<button
84+
onClick={handleDismiss}
85+
className="p-0.5 rounded transition-colors duration-100 hover:bg-black/10 dark:hover:bg-white/10"
86+
style={{ lineHeight: 0 }}
87+
aria-label="Remove template"
88+
type="button"
89+
>
90+
<svg
91+
width="11"
92+
height="11"
93+
viewBox="0 0 24 24"
94+
fill="none"
95+
stroke="currentColor"
96+
strokeWidth="2.5"
97+
strokeLinecap="round"
98+
strokeLinejoin="round"
99+
>
100+
<path d="M18 6 6 18" />
101+
<path d="m6 6 12 12" />
102+
</svg>
103+
</button>
104+
</div>,
105+
container
106+
);
107+
}

0 commit comments

Comments
 (0)