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 }; isConnected: boolean; } >(function FileBrowser({ agent, isConnected }, ref) { const [currentPath, setCurrentPath] = useState("/"); const [entries, setEntries] = useState([]); 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 (
{currentPath}
{loading ? (
Loading...
) : entries.length === 0 && currentPath === "/" ? (
} title="Workspace is empty" description="Ask the AI to create some files" />
) : (
{parentPath !== null && ( )} {dirs.map((entry) => ( ))} {files.map((entry) => ( ))}
)}
{selectedFile && (
{selectedFile.path.split("/").pop()}
            {selectedFile.content}
          
)} {info && (info.fileCount > 0 || info.directoryCount > 0) && (
{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`}
)}
); }); function Chat() { const [connectionStatus, setConnectionStatus] = useState("connecting"); const [input, setInput] = useState(""); const messagesEndRef = useRef(null); const fileBrowserRef = useRef(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 (
{/* Sidebar — File Browser */}
Workspace
Promise; } } isConnected={isConnected} />
{/* Main Chat Area */}
{/* Header */}

Workspace Chat

AI + Files
{/* Explainer */}
Workspace Chat 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.
{/* Messages */}
{messages.length === 0 && ( } 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 (
{getMessageText(message)}
); } return (
{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 (
{part.text}
); } if (part.type === "reasoning") { if (!part.text) return null; return (
Thinking
{part.text}
); } 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 (
{toolName} Done
Input
{inputStr}
Output
{outputStr}
); } 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 (
{toolName} running…
{inputStr && (
{inputStr}
)}
); } return null; })}
); })}
{/* Input */}
{ e.preventDefault(); send(); }} className="max-w-3xl mx-auto px-5 py-4" >
{ 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 ? (
); } export default function App() { return ( Loading...
} > ); }