Skip to content

Commit a20116b

Browse files
tylerslatonclaude
andcommitted
feat(frontend): add CopilotKit chat interface with generative UI components
- Add CopilotChatInterface powered by CopilotKit with generative UI support - Add PieChart and BarChart for financial data visualization - Add ToolReasoning default tool renderer - Add MeetingTimePicker for human-in-the-loop interactions - Add TodoCanvas with TodoCard/TodoColumn/TodoList for shared agent state - Add useCopilotExamples hook consolidating all CopilotKit registrations - Add ThemeProvider and useTheme hook with light/dark mode toggle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 99ab54b commit a20116b

26 files changed

+10069
-4740
lines changed

frontend/package-lock.json

Lines changed: 8780 additions & 4688 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"clean": "rm -rf build/ node_modules/ .vite/"
1414
},
1515
"dependencies": {
16+
"@copilotkit/react-core": "^1.54.0",
17+
"@copilotkit/react-ui": "^1.54.0",
1618
"@radix-ui/react-alert-dialog": "^1.1.15",
1719
"@radix-ui/react-dialog": "^1.1.15",
1820
"@radix-ui/react-progress": "^1.1.7",
@@ -32,8 +34,10 @@
3234
"react-router-dom": "^6.21.0",
3335
"react-spinners": "^0.17.0",
3436
"react-syntax-highlighter": "^16.1.0",
37+
"recharts": "^3.8.0",
3538
"remark-gfm": "^4.0.1",
36-
"tailwind-merge": "^3.2.0"
39+
"tailwind-merge": "^3.2.0",
40+
"zod": "^3.25.76"
3741
},
3842
"devDependencies": {
3943
"@eslint/eslintrc": "^3",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// The canvas is always visible alongside the chat pane (spec: "render chat plus a
2+
// todo canvas in the same page shell"). It shows an empty state when there are no
3+
// todos, and fills in as the agent or user adds items.
4+
import { useAgent } from "@copilotkit/react-core/v2"
5+
import { TodoList } from "./TodoList"
6+
import type { Todo } from "./types"
7+
8+
export function TodoCanvas() {
9+
const { agent } = useAgent()
10+
11+
return (
12+
<div className="h-full overflow-y-auto bg-white dark:bg-neutral-950 [background-image:radial-gradient(circle,#d5d5d5_1px,transparent_1px)] dark:[background-image:radial-gradient(circle,#333_1px,transparent_1px)] [background-size:20px_20px]">
13+
<div className="max-w-4xl mx-auto px-8 py-10 h-full">
14+
<TodoList
15+
todos={(agent.state as { todos?: Todo[] })?.todos ?? []}
16+
onUpdate={(updatedTodos) => agent.setState({ todos: updatedTodos })}
17+
isAgentRunning={agent.isRunning}
18+
/>
19+
</div>
20+
</div>
21+
)
22+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { useState, useRef, useEffect } from "react"
2+
import type { Todo } from "./types"
3+
4+
interface TodoCardProps {
5+
todo: Todo
6+
onToggleStatus: (todo: Todo) => void
7+
onDelete: (todo: Todo) => void
8+
onUpdateTitle: (todoId: string, title: string) => void
9+
onUpdateDescription: (todoId: string, description: string) => void
10+
onUpdateEmoji: (todoId: string, emoji: string) => void
11+
}
12+
13+
const EMOJI_OPTIONS = ["✅", "🔥", "🎯", "💡", "🚀"]
14+
15+
export function TodoCard({
16+
todo,
17+
onToggleStatus,
18+
onDelete,
19+
onUpdateTitle,
20+
onUpdateDescription,
21+
onUpdateEmoji,
22+
}: TodoCardProps) {
23+
const [editingField, setEditingField] = useState<"title" | "description" | null>(null)
24+
const [editValue, setEditValue] = useState("")
25+
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
26+
const textareaRef = useRef<HTMLTextAreaElement>(null)
27+
28+
const isCompleted = todo.status === "completed"
29+
const truncatedDescription =
30+
todo.description.length > 120
31+
? todo.description.slice(0, 120) + "..."
32+
: todo.description
33+
34+
const startEdit = (field: "title" | "description") => {
35+
setEditingField(field)
36+
setEditValue(field === "title" ? todo.title : todo.description)
37+
}
38+
39+
const saveEdit = (field: "title" | "description") => {
40+
if (!editValue.trim()) {
41+
// Don't save empty value — keep the editor open
42+
return
43+
}
44+
if (field === "title") onUpdateTitle(todo.id, editValue.trim())
45+
else onUpdateDescription(todo.id, editValue.trim())
46+
setEditingField(null)
47+
setEditValue("")
48+
}
49+
50+
const cancelEdit = () => {
51+
setEditingField(null)
52+
setEditValue("")
53+
}
54+
55+
useEffect(() => {
56+
if (textareaRef.current) {
57+
textareaRef.current.style.height = "auto"
58+
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"
59+
}
60+
}, [editValue])
61+
62+
return (
63+
<div
64+
className={`group relative rounded-2xl p-5 transition-all duration-150 border ${
65+
isCompleted
66+
? "bg-neutral-100 border-neutral-200 dark:bg-neutral-800/50 dark:border-neutral-700"
67+
: "bg-white border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700"
68+
}`}
69+
>
70+
{/* Delete button — visible on hover */}
71+
<button
72+
onClick={() => onDelete(todo)}
73+
className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-100 cursor-pointer rounded-full p-1 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300"
74+
aria-label="Delete todo"
75+
>
76+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
77+
<line x1="18" y1="6" x2="6" y2="18" />
78+
<line x1="6" y1="6" x2="18" y2="18" />
79+
</svg>
80+
</button>
81+
82+
{/* Emoji avatar */}
83+
<div className="relative inline-block mb-3">
84+
<button
85+
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
86+
className={`block text-3xl leading-none cursor-pointer rounded-xl p-2 transition-colors duration-100 ${
87+
isCompleted
88+
? "bg-neutral-200 dark:bg-neutral-700"
89+
: "bg-neutral-100 dark:bg-neutral-700/50"
90+
}`}
91+
aria-label="Change emoji"
92+
>
93+
{todo.emoji}
94+
</button>
95+
{showEmojiPicker && (
96+
<div className="absolute top-0 left-full ml-2 z-10 flex gap-1 p-1.5 rounded-full bg-white border border-neutral-300 shadow-lg dark:bg-neutral-800 dark:border-neutral-600">
97+
{EMOJI_OPTIONS.map((emoji) => (
98+
<button
99+
key={emoji}
100+
onClick={() => {
101+
onUpdateEmoji(todo.id, emoji)
102+
setShowEmojiPicker(false)
103+
}}
104+
className="text-lg w-8 h-8 flex items-center justify-center rounded-full cursor-pointer transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
105+
>
106+
{emoji}
107+
</button>
108+
))}
109+
</div>
110+
)}
111+
</div>
112+
113+
{/* Title + description */}
114+
<div className="flex items-start gap-3">
115+
<button
116+
onClick={() => onToggleStatus(todo)}
117+
className="flex-shrink-0 mt-[2px] cursor-pointer"
118+
aria-label={isCompleted ? "Mark as incomplete" : "Mark as complete"}
119+
>
120+
{isCompleted ? (
121+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
122+
<rect x="1" y="1" width="18" height="18" rx="6" className="fill-neutral-900 dark:fill-neutral-100" />
123+
<path d="M6 10.5L8.5 13L14 7" className="stroke-white dark:stroke-neutral-900" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
124+
</svg>
125+
) : (
126+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
127+
<rect x="1" y="1" width="18" height="18" rx="6" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" />
128+
</svg>
129+
)}
130+
</button>
131+
132+
<div className="flex-1 min-w-0">
133+
{editingField === "title" ? (
134+
<input
135+
type="text"
136+
value={editValue}
137+
onChange={(e) => setEditValue(e.target.value)}
138+
onBlur={() => saveEdit("title")}
139+
onKeyDown={(e) => {
140+
if (e.key === "Enter") saveEdit("title")
141+
if (e.key === "Escape") cancelEdit()
142+
}}
143+
className="w-full text-[16px] font-semibold focus:outline-none bg-transparent text-neutral-900 dark:text-neutral-100 border-b-2 border-neutral-900 dark:border-neutral-100 pb-[2px]"
144+
autoFocus
145+
aria-label="Edit todo title"
146+
/>
147+
) : (
148+
<div
149+
onClick={() => startEdit("title")}
150+
className={`text-[16px] font-semibold cursor-text break-words leading-snug ${
151+
isCompleted
152+
? "text-neutral-400 line-through dark:text-neutral-500"
153+
: "text-neutral-900 dark:text-neutral-100"
154+
}`}
155+
>
156+
{todo.title}
157+
</div>
158+
)}
159+
160+
{editingField === "description" ? (
161+
<textarea
162+
ref={textareaRef}
163+
value={editValue}
164+
onChange={(e) => setEditValue(e.target.value)}
165+
onBlur={() => saveEdit("description")}
166+
onKeyDown={(e) => {
167+
if (e.key === "Escape") cancelEdit()
168+
}}
169+
className="w-full mt-1.5 text-[14px] leading-relaxed focus:outline-none resize-none bg-transparent text-neutral-500 dark:text-neutral-400 border-b-2 border-neutral-900 dark:border-neutral-100 pb-[2px]"
170+
rows={1}
171+
autoFocus
172+
aria-label="Edit todo description"
173+
/>
174+
) : (
175+
<p
176+
onClick={() => startEdit("description")}
177+
className={`mt-1.5 text-[14px] leading-relaxed cursor-text ${
178+
isCompleted
179+
? "text-neutral-300 line-through dark:text-neutral-600"
180+
: "text-neutral-500 dark:text-neutral-400"
181+
}`}
182+
>
183+
{truncatedDescription}
184+
</p>
185+
)}
186+
</div>
187+
</div>
188+
</div>
189+
)
190+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Todo } from "./types"
2+
import { TodoCard } from "./TodoCard"
3+
4+
interface TodoColumnProps {
5+
title: string
6+
todos: Todo[]
7+
emptyMessage: string
8+
showAddButton?: boolean
9+
onAddTodo?: () => void
10+
onToggleStatus: (todo: Todo) => void
11+
onDelete: (todo: Todo) => void
12+
onUpdateTitle: (todoId: string, title: string) => void
13+
onUpdateDescription: (todoId: string, description: string) => void
14+
onUpdateEmoji: (todoId: string, emoji: string) => void
15+
isAgentRunning: boolean
16+
}
17+
18+
export function TodoColumn({
19+
title,
20+
todos,
21+
emptyMessage,
22+
showAddButton = false,
23+
onAddTodo,
24+
onToggleStatus,
25+
onDelete,
26+
onUpdateTitle,
27+
onUpdateDescription,
28+
onUpdateEmoji,
29+
isAgentRunning,
30+
}: TodoColumnProps) {
31+
return (
32+
<section aria-label={`${title} column`} className="flex-1 min-w-0">
33+
<div className="flex items-center justify-between mb-5">
34+
<div className="flex items-center gap-3">
35+
<h2 className="text-[18px] font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
36+
{title}
37+
</h2>
38+
<span className="text-[12px] font-semibold rounded-full px-2 py-0.5 text-neutral-500 bg-neutral-200 dark:text-neutral-400 dark:bg-neutral-700">
39+
{todos.length}
40+
</span>
41+
</div>
42+
{showAddButton && onAddTodo && (
43+
<button
44+
onClick={onAddTodo}
45+
className="rounded-full cursor-pointer transition-colors p-1.5 text-neutral-500 bg-neutral-200 hover:bg-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
46+
aria-label="Add new todo"
47+
disabled={isAgentRunning}
48+
>
49+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
50+
<line x1="12" y1="5" x2="12" y2="19" />
51+
<line x1="5" y1="12" x2="19" y2="12" />
52+
</svg>
53+
</button>
54+
)}
55+
</div>
56+
57+
<div className="space-y-4">
58+
{todos.length === 0 ? (
59+
<div className="text-center text-[14px] rounded-2xl border-2 border-dashed p-5 min-h-[151px] flex items-center justify-center text-neutral-400 border-neutral-300 dark:text-neutral-500 dark:border-neutral-700">
60+
{emptyMessage}
61+
</div>
62+
) : (
63+
todos.map((todo) => (
64+
<TodoCard
65+
key={todo.id}
66+
todo={todo}
67+
onToggleStatus={onToggleStatus}
68+
onDelete={onDelete}
69+
onUpdateTitle={onUpdateTitle}
70+
onUpdateDescription={onUpdateDescription}
71+
onUpdateEmoji={onUpdateEmoji}
72+
/>
73+
))
74+
)}
75+
</div>
76+
</section>
77+
)
78+
}

0 commit comments

Comments
 (0)