branch:
client.tsx
22837 bytesRaw
import {
Suspense,
useCallback,
useState,
useEffect,
useRef,
useImperativeHandle,
forwardRef
} from "react";
import { useAgent } from "agents/react";
import { useAgentChat } 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,
StopIcon,
TrashIcon,
GearIcon,
FolderIcon,
FileIcon,
FolderOpenIcon,
ArrowCounterClockwiseIcon,
InfoIcon,
TerminalIcon
} from "@phosphor-icons/react";
import { Streamdown } from "streamdown";
const STORAGE_KEY = "workspace-chat-user-id";
function getUserId(): string {
if (typeof window === "undefined") return "default";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
const id = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, id);
return id;
}
function getMessageText(message: UIMessage): string {
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { type: "text"; text: string }).text)
.join("");
}
type FileEntry = {
name: string;
type: "file" | "directory" | "symlink";
size: number;
path: string;
};
type FileBrowserHandle = { refresh: () => void };
const FileBrowser = forwardRef<
FileBrowserHandle,
{
agent: { call: (method: string, args: unknown[]) => Promise<unknown> };
isConnected: boolean;
}
>(function FileBrowser({ agent, isConnected }, ref) {
const [currentPath, setCurrentPath] = useState("/");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState<{
path: string;
content: string;
} | null>(null);
const [info, setInfo] = useState<{
fileCount: number;
directoryCount: number;
totalBytes: number;
} | null>(null);
const loadDir = useCallback(
async (path: string) => {
if (!isConnected) return;
setLoading(true);
try {
const result = (await agent.call("listFiles", [
path
])) as unknown as Array<{
name: string;
type: "file" | "directory" | "symlink";
size: number;
path: string;
}>;
setEntries(result);
setCurrentPath(path);
} catch {
setEntries([]);
} finally {
setLoading(false);
}
},
[agent, isConnected]
);
const loadInfo = useCallback(async () => {
if (!isConnected) return;
try {
const result = (await agent.call("getWorkspaceInfo", [])) as unknown as {
fileCount: number;
directoryCount: number;
totalBytes: number;
};
setInfo(result);
} catch {
// ignore
}
}, [agent, isConnected]);
useEffect(() => {
if (isConnected) {
loadDir(currentPath);
loadInfo();
}
}, [isConnected]); // eslint-disable-line react-hooks/exhaustive-deps
const refresh = useCallback(() => {
loadDir(currentPath);
loadInfo();
}, [loadDir, loadInfo, currentPath]);
useImperativeHandle(ref, () => ({ refresh }), [refresh]);
const navigateTo = (path: string) => {
setSelectedFile(null);
loadDir(path);
loadInfo();
};
const openFile = useCallback(
async (path: string) => {
if (!isConnected) return;
try {
const content = (await agent.call("readFileContent", [
path
])) as unknown as string | null;
setSelectedFile({
path,
content: content ?? "(empty file)"
});
} catch {
setSelectedFile({ path, content: "(error reading file)" });
}
},
[agent, isConnected]
);
const parentPath =
currentPath === "/"
? null
: currentPath.split("/").slice(0, -1).join("/") || "/";
const dirs = entries.filter((e) => e.type === "directory");
const files = entries.filter((e) => e.type !== "directory");
return (
<div className="flex flex-col h-full">
<div className="px-3 py-2 border-b border-kumo-line flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<FolderOpenIcon size={14} className="text-kumo-accent shrink-0" />
<span className="text-xs font-mono text-kumo-default truncate">
{currentPath}
</span>
</div>
<Button
variant="ghost"
size="sm"
shape="square"
aria-label="Refresh"
icon={<ArrowCounterClockwiseIcon size={12} />}
onClick={refresh}
disabled={loading}
/>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-4 text-center">
<Text size="xs" variant="secondary">
Loading...
</Text>
</div>
) : entries.length === 0 && currentPath === "/" ? (
<div className="p-4 text-center">
<Empty
icon={<FolderIcon size={24} />}
title="Workspace is empty"
description="Ask the AI to create some files"
/>
</div>
) : (
<div className="py-1">
{parentPath !== null && (
<button
type="button"
onClick={() => navigateTo(parentPath)}
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-kumo-elevated text-left"
>
<FolderIcon size={14} className="text-kumo-accent shrink-0" />
<span className="text-xs text-kumo-subtle">..</span>
</button>
)}
{dirs.map((entry) => (
<button
type="button"
key={entry.path}
onClick={() => navigateTo(entry.path)}
className="w-full px-3 py-1.5 flex items-center gap-2 hover:bg-kumo-elevated text-left"
>
<FolderIcon size={14} className="text-kumo-accent shrink-0" />
<span className="text-xs text-kumo-default truncate">
{entry.name}
</span>
</button>
))}
{files.map((entry) => (
<button
type="button"
key={entry.path}
onClick={() => openFile(entry.path)}
className={`w-full px-3 py-1.5 flex items-center gap-2 hover:bg-kumo-elevated text-left ${
selectedFile?.path === entry.path ? "bg-kumo-elevated" : ""
}`}
>
<FileIcon size={14} className="text-kumo-subtle shrink-0" />
<span className="text-xs text-kumo-default truncate flex-1">
{entry.name}
</span>
<span className="text-[10px] text-kumo-inactive shrink-0">
{entry.size > 1024
? `${(entry.size / 1024).toFixed(1)}K`
: `${entry.size}B`}
</span>
</button>
))}
</div>
)}
</div>
{selectedFile && (
<div className="border-t border-kumo-line flex flex-col max-h-[40%]">
<div className="px-3 py-1.5 flex items-center justify-between border-b border-kumo-line bg-kumo-elevated">
<span className="text-[10px] font-mono text-kumo-default truncate">
{selectedFile.path.split("/").pop()}
</span>
<button
type="button"
onClick={() => setSelectedFile(null)}
className="text-kumo-inactive hover:text-kumo-default text-xs"
>
×
</button>
</div>
<pre className="flex-1 overflow-auto px-3 py-2 text-[11px] leading-relaxed font-mono text-kumo-default bg-kumo-base whitespace-pre-wrap break-all">
{selectedFile.content}
</pre>
</div>
)}
{info && (info.fileCount > 0 || info.directoryCount > 0) && (
<div className="px-3 py-2 border-t border-kumo-line">
<span className="text-[10px] text-kumo-inactive">
{info.fileCount} file{info.fileCount !== 1 ? "s" : ""},{" "}
{info.directoryCount} dir{info.directoryCount !== 1 ? "s" : ""},{" "}
{info.totalBytes > 1024
? `${(info.totalBytes / 1024).toFixed(1)} KB`
: `${info.totalBytes} B`}
</span>
</div>
)}
</div>
);
});
function Chat() {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileBrowserRef = useRef<FileBrowserHandle>(null);
const agent = useAgent({
agent: "WorkspaceChatAgent",
name: getUserId(),
onOpen: useCallback(() => setConnectionStatus("connected"), []),
onClose: useCallback(() => setConnectionStatus("disconnected"), []),
onError: useCallback(
(error: Event) => console.error("WebSocket error:", error),
[]
)
});
const { messages, sendMessage, clearHistory, stop, status } = useAgentChat({
agent
});
const isStreaming = status === "streaming";
const isConnected = connectionStatus === "connected";
const prevStreamingRef = useRef(false);
useEffect(() => {
if (prevStreamingRef.current && !isStreaming) {
fileBrowserRef.current?.refresh();
}
prevStreamingRef.current = isStreaming;
}, [isStreaming]);
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 h-screen bg-kumo-elevated">
{/* Sidebar — File Browser */}
<div className="w-64 border-r border-kumo-line bg-kumo-base flex flex-col shrink-0">
<div className="px-3 py-3 border-b border-kumo-line">
<div className="flex items-center gap-2">
<TerminalIcon size={16} className="text-kumo-accent" />
<span className="text-sm font-semibold text-kumo-default">
Workspace
</span>
</div>
</div>
<FileBrowser
ref={fileBrowserRef}
agent={
agent as unknown as {
call: (method: string, args: unknown[]) => Promise<unknown>;
}
}
isConnected={isConnected}
/>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="px-5 py-3 bg-kumo-base border-b border-kumo-line">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-kumo-default">
Workspace Chat
</h1>
<Badge variant="secondary">
<TerminalIcon size={12} weight="bold" className="mr-1" />
AI + Files
</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>
{/* Explainer */}
<div className="px-5 pt-4">
<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>
Workspace Chat
</Text>
<span className="mt-1 block">
<Text size="xs" variant="secondary">
An AI assistant with a persistent virtual filesystem. Ask it
to create files, write code, explore the workspace, or use
the isolate-backed state runtime for multi-file refactors.
Files persist across conversations.
</Text>
</span>
</div>
</div>
</Surface>
</div>
{/* Messages */}
<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={<TerminalIcon size={32} />}
title="Start building"
description='Try "Create a hello world HTML page" or "Use the state runtime to rename foo to bar across /src/**/*.ts"'
/>
)}
{messages.map((message, index) => {
const isUser = message.role === "user";
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
if (isUser) {
return (
<div key={message.id} 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>
);
}
return (
<div key={message.id} className="space-y-2">
{message.parts.map((part, partIndex) => {
if (part.type === "text") {
if (!part.text) return null;
const isLastTextPart = message.parts
.slice(partIndex + 1)
.every((p) => p.type !== "text");
return (
<div key={partIndex} 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">
<Streamdown
className="sd-theme"
controls={false}
isAnimating={
isLastAssistant && isLastTextPart && isStreaming
}
>
{part.text}
</Streamdown>
</div>
</div>
);
}
if (part.type === "reasoning") {
if (!part.text) return null;
return (
<div key={partIndex} className="flex justify-start">
<Surface className="max-w-[85%] px-4 py-2.5 rounded-xl ring ring-kumo-line opacity-70">
<div className="flex items-center gap-2 mb-1">
<GearIcon
size={14}
className="text-kumo-inactive"
/>
<Text size="xs" variant="secondary" bold>
Thinking
</Text>
</div>
<div className="whitespace-pre-wrap text-xs text-kumo-subtle italic">
{part.text}
</div>
</Surface>
</div>
);
}
if (!isToolUIPart(part)) return null;
const toolName = getToolName(part);
if (part.state === "output-available") {
const inputStr = JSON.stringify(part.input, null, 2);
const outputStr = JSON.stringify(part.output, null, 2);
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-2">
<GearIcon
size={14}
className="text-kumo-inactive"
/>
<Text size="xs" variant="secondary" bold>
{toolName}
</Text>
<Badge variant="secondary">Done</Badge>
</div>
<div className="space-y-1.5">
<div>
<span className="block text-[10px] text-kumo-inactive uppercase tracking-wide mb-0.5">
Input
</span>
<div className="font-mono max-h-28 overflow-y-auto bg-kumo-elevated rounded px-2 py-1">
<Text size="xs" variant="secondary">
{inputStr}
</Text>
</div>
</div>
<div>
<span className="block text-[10px] text-kumo-inactive uppercase tracking-wide mb-0.5">
Output
</span>
<div className="font-mono max-h-32 overflow-y-auto">
<Text size="xs" variant="secondary">
{outputStr}
</Text>
</div>
</div>
</div>
</Surface>
</div>
);
}
if (
part.state === "input-available" ||
part.state === "input-streaming"
) {
const inputStr =
part.input && Object.keys(part.input).length > 0
? JSON.stringify(part.input, null, 2)
: null;
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 animate-spin"
/>
<Text size="xs" variant="secondary" bold>
{toolName}
</Text>
<Text size="xs" variant="secondary">
running…
</Text>
</div>
{inputStr && (
<div className="font-mono max-h-28 overflow-y-auto bg-kumo-elevated rounded px-2 py-1 mt-1">
<Text size="xs" variant="secondary">
{inputStr}
</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='Try: "Plan edits for /src/config.json and apply them with the state runtime"'
disabled={!isConnected || isStreaming}
rows={2}
className="flex-1 !ring-0 focus:!ring-0 !shadow-none !bg-transparent !outline-none"
/>
{isStreaming ? (
<Button
type="button"
variant="secondary"
shape="square"
aria-label="Stop streaming"
onClick={stop}
icon={<StopIcon size={18} weight="fill" />}
className="mb-0.5"
/>
) : (
<Button
type="submit"
variant="primary"
shape="square"
aria-label="Send message"
disabled={!input.trim() || !isConnected}
icon={<PaperPlaneRightIcon size={18} />}
className="mb-0.5"
/>
)}
</div>
</form>
<div className="flex justify-center pb-3">
<PoweredByAgents />
</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>
);
}