Skip to content

Commit ee905c2

Browse files
tylerslatonclaude
andcommitted
feat(showcase/google-adk/headless-complete): port frontend from langgraph-python
Copy the full headless-complete demo verbatim from langgraph-python (the parity north-star). The demo now exposes the full headless surface CopilotKit provides: useRenderTool / useDefaultRenderTool, useComponent, useConfigureSuggestions, useAttachments, plus useRenderToolCall and useRenderActivityMessage rendered inside a hand-rolled chat shell built on useAgent + useCopilotKit. Workspace plumbing required to make the verbatim port runnable: - Add shadcn primitives (avatar, badge, button, card, scroll-area, separator, textarea) and `cn` util at src/components/ui/* + src/lib/utils.ts. - Add lucide-react, radix-ui, react-markdown, remark-gfm, clsx, tailwind-merge, class-variance-authority to package.json. - Extend globals.css with Tailwind v4 @theme tokens that map onto the shadcn CSS variables (background/foreground/card/muted/border/etc.) so utilities like `bg-background` actually generate styles. - Register a `headless-complete` HttpAgent on the existing MCP Apps runtime route so the LP frontend's `agent="headless-complete"` resolves to the ADK backend path `/headless_complete` (mapped to _simple_chat in registry.py). The ADK `_simple_chat` agent is left unchanged — it already covers the behaviour the demo exercises (frontend tools, MCP Apps tool calls). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0c91f14 commit ee905c2

38 files changed

Lines changed: 2300 additions & 521 deletions

showcase/integrations/google-adk/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@
2020
"@hashbrownai/react": "0.5.0-beta.4",
2121
"@json-render/core": "0.18.0",
2222
"@json-render/react": "0.18.0",
23+
"class-variance-authority": "^0.7.1",
24+
"clsx": "^2.1.1",
25+
"lucide-react": "^1.14.0",
2326
"next": "^15.5.15",
2427
"openai": "^5.9.0",
28+
"radix-ui": "^1.4.3",
2529
"react": "^19.0.0",
2630
"react-dom": "^19.0.0",
31+
"react-markdown": "^10.1.0",
2732
"recharts": "^2.15.0",
33+
"remark-gfm": "^4.0.1",
34+
"tailwind-merge": "^3.5.0",
2835
"zod": "^3.24.0"
2936
},
3037
"devDependencies": {

showcase/integrations/google-adk/src/app/api/copilotkit-mcp-apps/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ const mcpAppsAgent: AbstractAgent = new HttpAgent({
2323
url: `${AGENT_URL}/mcp_apps`,
2424
});
2525

26+
// headless-complete shares this runtime because its cell also exercises
27+
// MCP Apps rendering (via useRenderActivityMessage). The backend path
28+
// `/headless_complete` is mounted by the ADK agent_server from the
29+
// registry entry of the same name (mapped to _simple_chat).
30+
const headlessCompleteAgent: AbstractAgent = new HttpAgent({
31+
url: `${AGENT_URL}/headless_complete`,
32+
});
33+
2634
// @region[runtime-mcpapps-config]
2735
// The `mcpApps.servers` config is all you need server-side. The runtime
2836
// auto-applies the MCP Apps middleware to every registered agent: on each
@@ -35,6 +43,7 @@ const runtime = new CopilotRuntime({
3543
// fixed in source, pending release.
3644
agents: {
3745
mcp_apps: mcpAppsAgent,
46+
"headless-complete": headlessCompleteAgent,
3847
},
3948
mcpApps: {
4049
servers: [
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"use client";
2+
3+
/**
4+
* Compact chip rendered above the composer for each pending attachment.
5+
* Shows a thumbnail (image) or icon (document), the filename, and an X
6+
* button to remove. Doubles as the in-message preview rendered alongside
7+
* a sent user message in `chat/message-user.tsx`.
8+
*/
9+
10+
import React from "react";
11+
import { File, Loader2, X } from "lucide-react";
12+
import type { Attachment } from "@copilotkit/shared";
13+
import { Button } from "@/components/ui/button";
14+
import { cn } from "@/lib/utils";
15+
16+
export function AttachmentChip({
17+
attachment,
18+
onRemove,
19+
}: {
20+
attachment: Attachment;
21+
onRemove?: (id: string) => void;
22+
}) {
23+
const isImage = attachment.type === "image";
24+
const isUploading = attachment.status === "uploading";
25+
const src = isImage ? attachmentSrc(attachment) : undefined;
26+
27+
return (
28+
<div
29+
className={cn(
30+
"group relative inline-flex items-center gap-2 rounded-lg border bg-card px-2 py-1.5 text-xs shadow-sm",
31+
onRemove ? "pr-7" : "pr-2",
32+
)}
33+
>
34+
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground">
35+
{isUploading ? (
36+
<Loader2 className="h-4 w-4 animate-spin" />
37+
) : isImage && src ? (
38+
<img
39+
src={src}
40+
alt={attachment.filename ?? "attachment"}
41+
className="h-full w-full object-cover"
42+
/>
43+
) : (
44+
<File className="h-4 w-4" />
45+
)}
46+
</div>
47+
<div className="flex min-w-0 flex-col leading-tight">
48+
<span className="max-w-[180px] truncate font-medium text-foreground">
49+
{attachment.filename ?? "attachment"}
50+
</span>
51+
<span className="text-[10px] text-muted-foreground">
52+
{isUploading ? "uploading…" : prettyBytes(attachment.size)}
53+
</span>
54+
</div>
55+
{onRemove && (
56+
<Button
57+
type="button"
58+
size="icon"
59+
variant="ghost"
60+
aria-label="Remove attachment"
61+
onClick={() => onRemove(attachment.id)}
62+
className="absolute right-0.5 top-1/2 h-5 w-5 -translate-y-1/2"
63+
>
64+
<X className="h-3 w-3" />
65+
</Button>
66+
)}
67+
</div>
68+
);
69+
}
70+
71+
function attachmentSrc(att: Attachment): string | undefined {
72+
const src = att.source;
73+
if (src.type === "url") return src.value;
74+
if (src.type === "data") {
75+
return `data:${src.mimeType};base64,${src.value}`;
76+
}
77+
return undefined;
78+
}
79+
80+
function prettyBytes(size?: number): string {
81+
if (!size) return "";
82+
if (size < 1024) return `${size}B`;
83+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
84+
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
85+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"use client";
2+
3+
/**
4+
* Configures `useAttachments` for the headless-complete demo. Inlines
5+
* uploads as base64 (no external storage), accepts images and PDFs, and
6+
* caps each file at 20MB.
7+
*
8+
* The hook returns the full attachment pipeline: `fileInputRef` for the
9+
* hidden `<input type="file">`, `containerRef` for paste support,
10+
* drag/drop handlers, the live attachment list, plus `consumeAttachments`
11+
* which is called at submit time and clears the queue.
12+
*/
13+
14+
import { useCallback } from "react";
15+
import {
16+
useAttachments,
17+
type UseAttachmentsReturn,
18+
} from "@copilotkit/react-core/v2";
19+
import type { AttachmentUploadResult } from "@copilotkit/shared";
20+
21+
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024;
22+
const ACCEPT_MIME = "image/*,application/pdf";
23+
24+
export function useAttachmentsConfig(): UseAttachmentsReturn {
25+
const onUpload = useCallback(fileToBase64, []);
26+
27+
return useAttachments({
28+
config: {
29+
enabled: true,
30+
accept: ACCEPT_MIME,
31+
maxSize: MAX_FILE_SIZE_BYTES,
32+
onUpload,
33+
onUploadFailed: (err) => {
34+
console.warn("[headless-complete] attachment rejected", err);
35+
},
36+
},
37+
});
38+
}
39+
40+
function fileToBase64(file: File): Promise<AttachmentUploadResult> {
41+
return new Promise((resolve, reject) => {
42+
const reader = new FileReader();
43+
reader.onerror = () =>
44+
reject(reader.error ?? new Error(`FileReader failed for ${file.name}`));
45+
reader.onload = () => {
46+
const result = reader.result;
47+
if (typeof result !== "string") {
48+
reject(new Error(`Unexpected FileReader result for ${file.name}`));
49+
return;
50+
}
51+
const commaIdx = result.indexOf(",");
52+
const base64 = commaIdx >= 0 ? result.slice(commaIdx + 1) : result;
53+
resolve({
54+
type: "data",
55+
value: base64,
56+
mimeType: file.type || "application/octet-stream",
57+
metadata: { filename: file.name, size: file.size },
58+
});
59+
};
60+
reader.readAsDataURL(file);
61+
});
62+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"use client";
2+
3+
/**
4+
* Hand-rolled chat shell.
5+
*
6+
* Owns the read/write loop:
7+
* - `useAgent` — message list, isRunning, addMessage, abortRun, setMessages
8+
* - `useCopilotKit` — `runAgent({ agent })` to dispatch a turn
9+
*
10+
* Renders the message tree via `<MessageList />` (which uses
11+
* `useRenderToolCall` and `useRenderActivityMessage` internally), shows
12+
* a typing indicator while the agent is thinking, and composes the
13+
* suggestion bar + composer (with attachments) at the bottom.
14+
*
15+
* The visual chrome is identical to `headless-simple/chat.tsx` — same
16+
* outer wrapper, same `Card` shell, same bubble palette — so the two
17+
* demos read as a pair.
18+
*/
19+
20+
import React, { useCallback, useState } from "react";
21+
import { useAgent, useCopilotKit } from "@copilotkit/react-core/v2";
22+
import type { Attachment, InputContent } from "@copilotkit/shared";
23+
import { Card, CardContent } from "@/components/ui/card";
24+
import { ScrollArea } from "@/components/ui/scroll-area";
25+
import { Separator } from "@/components/ui/separator";
26+
import { cn } from "@/lib/utils";
27+
import { useAttachmentsConfig } from "../attachments/use-attachments-config";
28+
import { useAutoScroll } from "../hooks/use-auto-scroll";
29+
import { useTypingIndicator } from "../hooks/use-typing-indicator";
30+
import { Composer } from "./composer";
31+
import { EmptyState } from "./empty-state";
32+
import { Header } from "./header";
33+
import { MessageList } from "./message-list";
34+
import { SuggestionBar } from "./suggestion-bar";
35+
import { TypingIndicator } from "./typing-indicator";
36+
37+
export function Chat({ agentId }: { agentId: string }) {
38+
const { agent } = useAgent({ agentId });
39+
const { copilotkit } = useCopilotKit();
40+
41+
const {
42+
attachments,
43+
fileInputRef,
44+
containerRef,
45+
handleFileUpload,
46+
handleDragOver,
47+
handleDragLeave,
48+
handleDrop,
49+
dragOver,
50+
removeAttachment,
51+
consumeAttachments,
52+
} = useAttachmentsConfig();
53+
54+
const [input, setInput] = useState("");
55+
const messages = agent.messages;
56+
const { listRef, bottomRef, stickRef } = useAutoScroll(
57+
messages,
58+
agent.isRunning,
59+
);
60+
61+
// Send pipeline: consume any ready attachments at submit time, build
62+
// the multimodal `content` array if needed, then dispatch the run.
63+
const sendText = useCallback(
64+
(text: string) => {
65+
const trimmed = text.trim();
66+
// Consume queued uploads first so they get sent even if the user
67+
// didn't type any text alongside them.
68+
const ready = consumeAttachments();
69+
if (!trimmed && ready.length === 0) return;
70+
if (agent.isRunning) return;
71+
72+
stickRef.current = true;
73+
74+
const content = buildContent(trimmed, ready);
75+
agent.addMessage({
76+
id: crypto.randomUUID(),
77+
role: "user",
78+
content,
79+
});
80+
void copilotkit
81+
.runAgent({ agent })
82+
.catch((err) =>
83+
console.error("[headless-complete] runAgent failed", err),
84+
);
85+
},
86+
[agent, copilotkit, consumeAttachments],
87+
);
88+
89+
const handleSend = useCallback(() => {
90+
sendText(input);
91+
setInput("");
92+
}, [input, sendText]);
93+
94+
const handleSuggestion = useCallback(
95+
(text: string) => {
96+
sendText(text);
97+
},
98+
[sendText],
99+
);
100+
101+
const handleReset = useCallback(() => {
102+
if (agent.isRunning) {
103+
try {
104+
agent.abortRun();
105+
} catch {
106+
// no-op: some transports don't support abort
107+
}
108+
}
109+
agent.setMessages([]);
110+
setInput("");
111+
stickRef.current = true;
112+
}, [agent]);
113+
114+
const showTypingIndicator = useTypingIndicator(messages, agent.isRunning);
115+
116+
const hasUploadingAttachment = attachments.some(
117+
(a) => a.status === "uploading",
118+
);
119+
const hasReadyAttachment = attachments.some((a) => a.status === "ready");
120+
const sendDisabled =
121+
agent.isRunning ||
122+
hasUploadingAttachment ||
123+
(!input.trim() && !hasReadyAttachment);
124+
const canReset = messages.length > 0 || agent.isRunning;
125+
const hasMessages = messages.length > 0;
126+
127+
return (
128+
<div className="flex h-screen w-full justify-center bg-background p-4 sm:p-6">
129+
<Card className="flex h-full w-full max-w-3xl flex-col gap-0 overflow-hidden border-border py-0 shadow-2xl shadow-black/10">
130+
<Header onReset={handleReset} canReset={canReset} />
131+
132+
<CardContent className="flex min-h-0 flex-1 flex-col p-0">
133+
{!hasMessages ? (
134+
// Render empty state outside the ScrollArea so flex-1 + justify-
135+
// center actually fills the available height. Radix ScrollArea
136+
// wraps its content in a `display: table` div that breaks
137+
// `h-full` propagation, so a centered empty state inside it
138+
// hugs the top of the viewport.
139+
<div className="flex min-h-0 flex-1 flex-col">
140+
<EmptyState onPick={handleSuggestion} />
141+
</div>
142+
) : (
143+
<ScrollArea className="min-h-0 flex-1">
144+
<div
145+
ref={listRef}
146+
className={cn("flex flex-col gap-4 px-4 py-4 sm:px-6")}
147+
>
148+
<MessageList messages={messages} />
149+
{showTypingIndicator && <TypingIndicator />}
150+
<div ref={bottomRef} />
151+
</div>
152+
</ScrollArea>
153+
)}
154+
155+
{hasMessages && (
156+
<SuggestionBar
157+
agentId={agentId}
158+
isRunning={agent.isRunning}
159+
onPick={handleSuggestion}
160+
/>
161+
)}
162+
163+
<Separator className="bg-border/60" />
164+
165+
<Composer
166+
value={input}
167+
onChange={setInput}
168+
onSend={handleSend}
169+
disabled={sendDisabled}
170+
isRunning={agent.isRunning}
171+
attachments={attachments}
172+
onRemoveAttachment={removeAttachment}
173+
onFileChange={handleFileUpload}
174+
fileInputRef={fileInputRef}
175+
containerRef={containerRef}
176+
onDragOver={handleDragOver}
177+
onDragLeave={handleDragLeave}
178+
onDrop={handleDrop}
179+
dragOver={dragOver}
180+
/>
181+
</CardContent>
182+
</Card>
183+
</div>
184+
);
185+
}
186+
187+
// Build the user message body. If no attachments, send a plain string
188+
// (legacy shape). If attachments are present, send the multimodal array
189+
// — text first, then each attachment as its own InputContent part.
190+
function buildContent(
191+
text: string,
192+
attachments: Attachment[],
193+
): string | InputContent[] {
194+
if (attachments.length === 0) return text;
195+
const parts: InputContent[] = [];
196+
if (text) parts.push({ type: "text", text });
197+
for (const att of attachments) {
198+
parts.push({
199+
type: att.type,
200+
source: att.source,
201+
metadata: {
202+
...(att.filename ? { filename: att.filename } : {}),
203+
...att.metadata,
204+
},
205+
} as InputContent);
206+
}
207+
return parts;
208+
}

0 commit comments

Comments
 (0)