branch:
client.tsx
16282 bytesRaw
import {
Suspense,
useCallback,
useState,
useEffect,
useRef,
useMemo
} from "react";
import { useAgent } from "agents/react";
import {
useAgentChat,
type AITool,
type OnToolCallCallback
} from "@cloudflare/ai-chat/react";
import { isToolUIPart, getToolName } from "ai";
import type { UIMessage } from "ai";
import {
Button,
Badge,
InputArea,
Empty,
Surface,
Text
} from "@cloudflare/kumo";
import {
ConnectionIndicator,
ModeToggle,
PoweredByAgents,
type ConnectionStatus
} from "@cloudflare/agents-ui";
import {
PaperPlaneRightIcon,
TrashIcon,
GearIcon,
PlugIcon,
InfoIcon,
ToggleLeftIcon,
ToggleRightIcon
} from "@phosphor-icons/react";
/**
* Available tools that a "third-party developer" could register.
* In a real SDK, these would be passed as props to a chat widget.
*/
const AVAILABLE_TOOLS: Record<
string,
{ tool: AITool; label: string; description: string }
> = {
getPageTitle: {
label: "getPageTitle",
description: "Returns the current page title from the browser",
tool: {
description: "Get the current page title from the user's browser",
parameters: { type: "object", properties: {}, required: [] },
execute: async () => ({ title: document.title })
}
},
getCurrentTime: {
label: "getCurrentTime",
description: "Returns the user's local time and timezone",
tool: {
description: "Get the user's current local time and timezone",
parameters: { type: "object", properties: {}, required: [] },
execute: async () => ({
time: new Date().toLocaleTimeString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
}
},
getScreenInfo: {
label: "getScreenInfo",
description: "Returns screen dimensions and pixel ratio",
tool: {
description: "Get the user's screen dimensions and device pixel ratio",
parameters: { type: "object", properties: {}, required: [] },
execute: async () => ({
width: window.innerWidth,
height: window.innerHeight,
pixelRatio: window.devicePixelRatio
})
}
},
getColorScheme: {
label: "getColorScheme",
description: "Returns the user's preferred color scheme",
tool: {
description: "Get whether the user prefers light or dark mode",
parameters: { type: "object", properties: {}, required: [] },
execute: async () => ({
scheme: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
current: document.documentElement.getAttribute("data-mode") || "unknown"
})
}
}
};
function getMessageText(message: UIMessage): string {
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { type: "text"; text: string }).text)
.join("");
}
function Chat() {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// Track which tools are enabled — simulates an SDK user toggling tools
const [enabledTools, setEnabledTools] = useState<Set<string>>(
new Set(Object.keys(AVAILABLE_TOOLS))
);
const toggleTool = useCallback((name: string) => {
setEnabledTools((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}, []);
// Build the active tools record from enabled set
const activeTools = useMemo(() => {
const tools: Record<string, AITool> = {};
for (const name of enabledTools) {
const entry = AVAILABLE_TOOLS[name];
if (entry) {
tools[name] = entry.tool;
}
}
return Object.keys(tools).length > 0 ? tools : undefined;
}, [enabledTools]);
const agent = useAgent({
agent: "DynamicToolsAgent",
onOpen: useCallback(() => setConnectionStatus("connected"), []),
onClose: useCallback(() => setConnectionStatus("disconnected"), []),
onError: useCallback(
(error: Event) => console.error("WebSocket error:", error),
[]
)
});
const { messages, sendMessage, clearHistory, status } = useAgentChat({
agent,
// Dynamic tools — schemas are sent to the server automatically
tools: activeTools,
// Execute tool calls routed back from the server
onToolCall: useCallback<OnToolCallCallback>(
async ({ toolCall, addToolOutput }) => {
const tool = activeTools?.[toolCall.toolName];
if (tool?.execute) {
const output = await tool.execute(toolCall.input);
addToolOutput({ toolCallId: toolCall.toolCallId, output });
}
},
[activeTools]
)
});
const isStreaming = status === "streaming";
const isConnected = connectionStatus === "connected";
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
sendMessage({ role: "user", parts: [{ type: "text", text }] });
}, [input, isStreaming, sendMessage]);
return (
<div className="flex flex-col h-screen bg-kumo-elevated">
{/* Header */}
<header className="px-5 py-4 bg-kumo-base border-b border-kumo-line">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-kumo-default">
Dynamic Tools
</h1>
<Badge variant="secondary">
<PlugIcon size={12} weight="bold" className="mr-1" />
SDK Pattern
</Badge>
</div>
<div className="flex items-center gap-3">
<ConnectionIndicator status={connectionStatus} />
<ModeToggle />
<Button
variant="secondary"
icon={<TrashIcon size={16} />}
onClick={clearHistory}
>
Clear
</Button>
</div>
</div>
</header>
<div className="flex-1 flex overflow-hidden">
{/* Tool sidebar */}
<aside className="w-72 border-r border-kumo-line bg-kumo-base overflow-y-auto p-4 space-y-4 shrink-0">
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<div className="flex gap-3">
<InfoIcon
size={20}
weight="bold"
className="text-kumo-accent shrink-0 mt-0.5"
/>
<div>
<Text size="sm" bold>
Dynamic Tool Registration
</Text>
<span className="mt-1 block">
<Text size="xs" variant="secondary">
Toggle tools on/off to simulate an SDK where third-party
developers register tools at runtime. The server accepts
whatever tools the client sends.
</Text>
</span>
</div>
</div>
</Surface>
<div>
<span className="mb-2 block">
<Text size="sm" bold>
Available Tools
</Text>
</span>
<div className="space-y-2">
{Object.entries(AVAILABLE_TOOLS).map(([name, entry]) => {
const enabled = enabledTools.has(name);
return (
<button
key={name}
type="button"
onClick={() => toggleTool(name)}
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
enabled
? "border-kumo-accent bg-kumo-accent/5"
: "border-kumo-line bg-kumo-base opacity-60"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="flex items-center gap-1.5">
<Text size="xs" bold>
{entry.label}
</Text>
</span>
{enabled ? (
<ToggleRightIcon
size={20}
weight="fill"
className="text-kumo-accent"
/>
) : (
<ToggleLeftIcon
size={20}
className="text-kumo-inactive"
/>
)}
</div>
<Text size="xs" variant="secondary">
{entry.description}
</Text>
</button>
);
})}
</div>
</div>
<div className="pt-2 border-t border-kumo-line">
<Text size="xs" variant="secondary">
{enabledTools.size} of {Object.keys(AVAILABLE_TOOLS).length} tools
active
</Text>
</div>
</aside>
{/* Chat area */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-5 py-6 space-y-5">
{messages.length === 0 && (
<Empty
icon={<PlugIcon size={32} />}
title="Dynamic tools are ready"
description='Toggle tools in the sidebar, then ask something like "What page am I on?", "What time is it?", or "What is my screen size?"'
/>
)}
{messages.map((message, index) => {
const isUser = message.role === "user";
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
return (
<div key={message.id} className="space-y-2">
{isUser ? (
<div className="flex justify-end">
<div className="max-w-[85%] px-4 py-2.5 rounded-2xl rounded-br-md bg-kumo-contrast text-kumo-inverse leading-relaxed">
{getMessageText(message)}
</div>
</div>
) : (
<div className="flex justify-start">
<div className="max-w-[85%] px-4 py-2.5 rounded-2xl rounded-bl-md bg-kumo-base text-kumo-default leading-relaxed">
<div className="whitespace-pre-wrap">
{getMessageText(message)}
{isLastAssistant && isStreaming && (
<span className="inline-block w-0.5 h-[1em] bg-kumo-brand ml-0.5 align-text-bottom animate-blink-cursor" />
)}
</div>
</div>
</div>
)}
{/* Tool parts */}
{message.parts
.filter((part) => isToolUIPart(part))
.map((part) => {
if (!isToolUIPart(part)) return null;
const toolName = getToolName(part);
if (part.state === "output-available") {
return (
<div
key={part.toolCallId}
className="flex justify-start"
>
<Surface className="max-w-[85%] px-4 py-2.5 rounded-xl ring ring-kumo-line">
<div className="flex items-center gap-2 mb-1">
<GearIcon
size={14}
className="text-kumo-inactive"
/>
<Text size="xs" variant="secondary" bold>
{toolName}
</Text>
<Badge variant="secondary">Done</Badge>
</div>
<div className="font-mono">
<Text size="xs" variant="secondary">
{JSON.stringify(part.output, null, 2)}
</Text>
</div>
</Surface>
</div>
);
}
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
return (
<div
key={part.toolCallId}
className="flex justify-start"
>
<Surface className="max-w-[85%] px-4 py-2.5 rounded-xl ring ring-kumo-line">
<div className="flex items-center gap-2">
<GearIcon
size={14}
className="text-kumo-inactive animate-spin"
/>
<Text size="xs" variant="secondary">
Running {toolName}...
</Text>
</div>
</Surface>
</div>
);
}
return null;
})}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="border-t border-kumo-line bg-kumo-base">
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
className="max-w-3xl mx-auto px-5 py-4"
>
<div className="flex items-end gap-3 rounded-xl border border-kumo-line bg-kumo-base p-3 shadow-sm focus-within:ring-2 focus-within:ring-kumo-ring focus-within:border-transparent transition-shadow">
<InputArea
value={input}
onValueChange={setInput}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder={
enabledTools.size > 0
? 'Try "What page am I on?" or "What time is it?"'
: "No tools enabled — toggle some in the sidebar"
}
disabled={!isConnected || isStreaming}
rows={2}
className="flex-1 !ring-0 focus:!ring-0 !shadow-none !bg-transparent !outline-none"
/>
<Button
type="submit"
variant="primary"
shape="square"
aria-label="Send message"
disabled={!input.trim() || !isConnected || isStreaming}
icon={<PaperPlaneRightIcon size={18} />}
loading={isStreaming}
className="mb-0.5"
/>
</div>
</form>
<div className="flex justify-center pb-3">
<PoweredByAgents />
</div>
</div>
</div>
</div>
</div>
);
}
export default function App() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center h-screen text-kumo-inactive">
Loading...
</div>
}
>
<Chat />
</Suspense>
);
}