branch:
client.tsx
25992 bytesRaw
import { Suspense, useCallback, useState, useEffect, useRef } from "react";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { isToolUIPart, isReasoningUIPart, 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,
  PlayIcon,
  CodeIcon,
  InfoIcon,
  RocketLaunchIcon,
  ArrowRightIcon,
  BrainIcon,
  WarningCircleIcon,
  CaretDownIcon,
  BrowserIcon,
  ArrowClockwiseIcon
} from "@phosphor-icons/react";
import type { AppState } from "./server";

const STORAGE_KEY = "worker-bundler-playground-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("");
}

// ─── Request Tester Panel ──────────────────────────────────────────────────

function RequestTester({
  onTest,
  disabled
}: {
  onTest: (
    method: string,
    path: string,
    body?: string
  ) => Promise<{ status: number; body: string }>;
  disabled: boolean;
}) {
  const [method, setMethod] = useState("GET");
  const [path, setPath] = useState("/");
  const [body, setBody] = useState("");
  const [response, setResponse] = useState<{
    status: number;
    body: string;
  } | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleTest = async () => {
    setLoading(true);
    setError(null);
    setResponse(null);
    try {
      const result = await onTest(
        method,
        path,
        method !== "GET" && method !== "HEAD" ? body : undefined
      );
      setResponse(result);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally {
      setLoading(false);
    }
  };

  return (
    <Surface className="rounded-xl ring ring-kumo-line p-4 space-y-3">
      <div className="flex items-center gap-2">
        <PlayIcon size={16} weight="bold" className="text-kumo-accent" />
        <Text size="sm" bold>
          Test App
        </Text>
      </div>

      <div className="flex gap-2">
        <select
          value={method}
          onChange={(e) => setMethod(e.target.value)}
          className="px-2 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default font-mono"
        >
          <option value="GET">GET</option>
          <option value="POST">POST</option>
          <option value="PUT">PUT</option>
          <option value="PATCH">PATCH</option>
          <option value="DELETE">DELETE</option>
        </select>
        <input
          type="text"
          value={path}
          onChange={(e) => setPath(e.target.value)}
          placeholder="/"
          className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive font-mono focus:outline-none focus:ring-1 focus:ring-kumo-accent"
        />
        <Button
          variant="primary"
          size="sm"
          icon={<ArrowRightIcon size={14} />}
          onClick={handleTest}
          disabled={disabled || loading}
        >
          {loading ? "..." : "Send"}
        </Button>
      </div>

      {method !== "GET" && method !== "HEAD" && (
        <textarea
          value={body}
          onChange={(e) => setBody(e.target.value)}
          placeholder='{"key": "value"}'
          rows={3}
          className="w-full px-3 py-2 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive font-mono resize-none focus:outline-none focus:ring-1 focus:ring-kumo-accent"
        />
      )}

      {error && (
        <div className="px-3 py-2 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-950 dark:text-red-300">
          {error}
        </div>
      )}

      {response && (
        <div className="space-y-1">
          <div className="flex items-center gap-2">
            <Badge variant={response.status < 400 ? "primary" : "destructive"}>
              {response.status}
            </Badge>
          </div>
          <pre className="px-3 py-2 rounded-lg bg-kumo-elevated text-kumo-default text-xs font-mono whitespace-pre-wrap overflow-auto max-h-48 border border-kumo-line">
            {response.body}
          </pre>
        </div>
      )}
    </Surface>
  );
}

// ─── Source Code Preview ───────────────────────────────────────────────────

function SourcePreview({ source }: { source: Record<string, string> }) {
  const keys = Object.keys(source);
  const keysKey = keys.join(",");
  const [activeFile, setActiveFile] = useState(keys[0]);

  // Reset active file when source files change
  useEffect(() => {
    if (!keys.includes(activeFile)) {
      setActiveFile(keys[0]);
    }
  }, [keysKey, activeFile]);

  return (
    <Surface className="rounded-xl ring ring-kumo-line overflow-hidden">
      <div className="flex items-center gap-1 px-3 py-2 border-b border-kumo-line overflow-x-auto">
        {Object.keys(source).map((path) => (
          <button
            key={path}
            onClick={() => setActiveFile(path)}
            className={[
              "px-2 py-1 text-xs font-mono rounded-md whitespace-nowrap transition-colors",
              activeFile === path
                ? "bg-kumo-accent text-white"
                : "text-kumo-subtle hover:bg-kumo-elevated"
            ].join(" ")}
          >
            {path}
          </button>
        ))}
      </div>
      <pre className="px-4 py-3 text-xs font-mono whitespace-pre-wrap overflow-auto max-h-64 text-kumo-default leading-relaxed">
        {source[activeFile]}
      </pre>
    </Surface>
  );
}

// ─── Reasoning Trace ───────────────────────────────────────────────────────

function ReasoningTrace({
  text,
  isStreaming
}: {
  text: string;
  isStreaming: boolean;
}) {
  const [expanded, setExpanded] = useState(false);

  if (!text) return null;

  return (
    <div className="flex justify-start">
      <Surface className="max-w-[85%] rounded-xl ring ring-kumo-line overflow-hidden">
        <button
          onClick={() => setExpanded(!expanded)}
          className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-kumo-elevated/50 transition-colors"
        >
          <BrainIcon
            size={14}
            weight="fill"
            className={
              isStreaming ? "text-purple-500 animate-pulse" : "text-purple-400"
            }
          />
          <Text size="xs" variant="secondary" bold>
            {isStreaming ? "Thinking..." : "Reasoning"}
          </Text>
          <CaretDownIcon
            size={12}
            className={[
              "ml-auto text-kumo-inactive transition-transform",
              expanded ? "rotate-180" : ""
            ].join(" ")}
          />
        </button>
        {expanded && (
          <div className="px-3 pb-2">
            <pre className="text-xs font-mono text-kumo-subtle whitespace-pre-wrap leading-relaxed max-h-48 overflow-auto">
              {text}
            </pre>
          </div>
        )}
      </Surface>
    </div>
  );
}

// ─── Tool Call Part ────────────────────────────────────────────────────────

function ToolCallPart({
  part
}: {
  part: {
    toolCallId: string;
    state: string;
    input?: unknown;
    output?: unknown;
    errorText?: string;
  };
}) {
  const [expanded, setExpanded] = useState(false);
  const toolName = getToolName(part as Parameters<typeof getToolName>[0]);

  const isRunning =
    part.state === "input-available" || part.state === "input-streaming";
  const isDone = part.state === "output-available";
  const isError = part.state === "output-error";

  const icon = isRunning ? (
    <GearIcon size={14} className="text-kumo-inactive animate-spin" />
  ) : isError ? (
    <WarningCircleIcon size={14} className="text-red-500" />
  ) : toolName === "generateApp" ? (
    <CodeIcon size={14} className="text-kumo-accent" />
  ) : (
    <PlayIcon size={14} className="text-kumo-accent" />
  );

  const label = isRunning
    ? toolName === "generateApp"
      ? "Building app..."
      : "Sending request..."
    : isError
      ? `${toolName} failed`
      : toolName === "generateApp"
        ? "App built"
        : "Request sent";

  return (
    <div className="flex justify-start">
      <Surface className="max-w-[85%] rounded-xl ring ring-kumo-line overflow-hidden">
        <button
          onClick={() => setExpanded(!expanded)}
          className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-kumo-elevated/50 transition-colors"
        >
          {icon}
          <Text size="xs" variant="secondary" bold>
            {label}
          </Text>
          {isDone && <Badge variant="primary">Done</Badge>}
          {isError && <Badge variant="destructive">Error</Badge>}
          <CaretDownIcon
            size={12}
            className={[
              "ml-auto text-kumo-inactive transition-transform",
              expanded ? "rotate-180" : ""
            ].join(" ")}
          />
        </button>
        {expanded && (
          <div className="px-3 pb-2 space-y-2">
            {part.input != null && (
              <div>
                <Text size="xs" variant="secondary" bold>
                  Input
                </Text>
                <pre className="mt-1 px-2 py-1.5 rounded bg-kumo-elevated text-xs font-mono whitespace-pre-wrap max-h-32 overflow-auto text-kumo-default">
                  {typeof part.input === "string"
                    ? part.input
                    : JSON.stringify(part.input, null, 2)}
                </pre>
              </div>
            )}
            {isDone && part.output != null && (
              <div>
                <Text size="xs" variant="secondary" bold>
                  Output
                </Text>
                <pre className="mt-1 px-2 py-1.5 rounded bg-kumo-elevated text-xs font-mono whitespace-pre-wrap max-h-32 overflow-auto text-kumo-default">
                  {typeof part.output === "string"
                    ? part.output
                    : JSON.stringify(part.output, null, 2)}
                </pre>
              </div>
            )}
            {isError && part.errorText && (
              <div className="px-2 py-1.5 rounded bg-red-50 text-red-700 text-xs dark:bg-red-950 dark:text-red-300">
                {part.errorText}
              </div>
            )}
          </div>
        )}
      </Surface>
    </div>
  );
}

// ─── Main Chat ─────────────────────────────────────────────────────────────

function Chat() {
  const [connectionStatus, setConnectionStatus] =
    useState<ConnectionStatus>("connecting");
  const [input, setInput] = useState("");
  const [appState, setAppState] = useState<AppState | null>(null);
  const [sidebarTab, setSidebarTab] = useState<"preview" | "source" | "test">(
    "preview"
  );
  const [buildKey, setBuildKey] = useState(0);
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const agent = useAgent({
    agent: "WorkerPlayground",
    name: getUserId(),
    onOpen: useCallback(() => setConnectionStatus("connected"), []),
    onClose: useCallback(() => setConnectionStatus("disconnected"), []),
    onError: useCallback(
      (error: Event) => console.error("WebSocket error:", error),
      []
    ),
    onStateUpdate: useCallback((state: AppState) => {
      if (state?.built) {
        setAppState(state);
        setBuildKey((k) => k + 1);
        setSidebarTab("preview");
      } else {
        setAppState(null);
      }
    }, [])
  });

  const { messages, sendMessage, clearHistory, stop, status } = useAgentChat({
    agent
  });

  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]);

  const handleManualTest = async (
    method: string,
    path: string,
    body?: string
  ): Promise<{ status: number; body: string }> => {
    const result = await agent.call("testApp", [method, path, body]);
    return result as { status: number; body: string };
  };

  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-6xl mx-auto flex items-center justify-between">
          <div className="flex items-center gap-3">
            <h1 className="text-lg font-semibold text-kumo-default">
              Worker Bundler Playground
            </h1>
            <Badge variant="secondary">
              <RocketLaunchIcon size={12} weight="bold" className="mr-1" />
              AI + Worker Bundler
            </Badge>
          </div>
          <div className="flex items-center gap-3">
            <ConnectionIndicator status={connectionStatus} />
            <ModeToggle />
            <Button
              variant="secondary"
              icon={<TrashIcon size={16} />}
              onClick={() => {
                clearHistory();
                setAppState(null);
                agent.call("clearWorkspace", []);
              }}
            >
              Clear
            </Button>
          </div>
        </div>
      </header>

      {/* Body: chat on left, preview on right */}
      <div className="flex-1 flex overflow-hidden">
        {/* Chat Panel */}
        <div className="flex-1 flex flex-col min-w-0">
          <div className="flex-1 overflow-y-auto">
            <div className="max-w-2xl mx-auto px-5 py-6 space-y-5">
              {messages.length === 0 && (
                <div className="space-y-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>
                          Worker Bundler Playground
                        </Text>
                        <span className="mt-1 block">
                          <Text size="xs" variant="secondary">
                            Describe an app and the AI will generate server code
                            and static assets, bundle, and load it. Test with
                            HTTP requests right here.
                          </Text>
                        </span>
                      </div>
                    </div>
                  </Surface>
                  <Empty
                    icon={<CodeIcon size={32} />}
                    title="Describe your app"
                    description={
                      '"Build a landing page with a counter API" or ' +
                      '"Make a todo app with HTML frontend and JSON API"'
                    }
                  />
                </div>
              )}

              {messages.map((message) => {
                const isUser = message.role === "user";

                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;
                        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 whitespace-pre-wrap">
                              {part.text}
                            </div>
                          </div>
                        );
                      }

                      if (part.type === "step-start") {
                        return (
                          <div
                            key={`step-${partIndex}`}
                            className="border-t border-kumo-line/40 my-1"
                          />
                        );
                      }

                      if (isReasoningUIPart(part)) {
                        return (
                          <ReasoningTrace
                            key={partIndex}
                            text={part.text}
                            isStreaming={part.state === "streaming"}
                          />
                        );
                      }

                      if (!isToolUIPart(part)) return null;
                      return <ToolCallPart key={part.toolCallId} part={part} />;
                    })}
                  </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-2xl 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="Describe an app to build..."
                  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>

        {/* Right Panel: Preview + Source + Tester */}
        <div className="w-[480px] shrink-0 border-l border-kumo-line bg-kumo-base hidden lg:flex lg:flex-col">
          {!appState ? (
            <div className="flex flex-col items-center justify-center h-full text-center px-4">
              <CodeIcon size={48} className="text-kumo-inactive mb-4" />
              <Text size="sm" variant="secondary">
                Generated app code and test panel will appear here.
              </Text>
            </div>
          ) : (
            <>
              {/* Build Status Bar */}
              <div className="flex items-center gap-2 px-4 py-2 border-b border-kumo-line">
                <Badge variant="primary">
                  <RocketLaunchIcon size={12} className="mr-1" />
                  Built
                </Badge>
                {appState.assetCount != null && appState.assetCount > 0 && (
                  <Badge variant="secondary">
                    {appState.assetCount} asset
                    {appState.assetCount !== 1 ? "s" : ""}
                  </Badge>
                )}
              </div>

              {appState.warnings && appState.warnings.length > 0 && (
                <div className="mx-4 mt-2 px-3 py-2 rounded-lg bg-yellow-50 text-yellow-700 text-xs dark:bg-yellow-950 dark:text-yellow-300">
                  {appState.warnings.join("\n")}
                </div>
              )}

              {/* Tab Bar */}
              <div className="flex items-center gap-1 px-4 py-2 border-b border-kumo-line">
                <button
                  onClick={() => setSidebarTab("preview")}
                  className={[
                    "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
                    sidebarTab === "preview"
                      ? "bg-kumo-accent text-white"
                      : "text-kumo-subtle hover:bg-kumo-elevated"
                  ].join(" ")}
                >
                  <BrowserIcon size={14} />
                  Preview
                </button>
                <button
                  onClick={() => setSidebarTab("source")}
                  className={[
                    "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
                    sidebarTab === "source"
                      ? "bg-kumo-accent text-white"
                      : "text-kumo-subtle hover:bg-kumo-elevated"
                  ].join(" ")}
                >
                  <CodeIcon size={14} />
                  Source
                </button>
                <button
                  onClick={() => setSidebarTab("test")}
                  className={[
                    "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors",
                    sidebarTab === "test"
                      ? "bg-kumo-accent text-white"
                      : "text-kumo-subtle hover:bg-kumo-elevated"
                  ].join(" ")}
                >
                  <PlayIcon size={14} />
                  Test
                </button>

                {sidebarTab === "preview" && (
                  <button
                    onClick={() => setBuildKey((k) => k + 1)}
                    className="ml-auto p-1.5 rounded-md text-kumo-subtle hover:bg-kumo-elevated transition-colors"
                    title="Reload preview"
                  >
                    <ArrowClockwiseIcon size={14} />
                  </button>
                )}
              </div>

              {/* Tab Content */}
              <div className="flex-1 overflow-y-auto">
                {sidebarTab === "preview" && (
                  <iframe
                    ref={iframeRef}
                    key={buildKey}
                    src={`/preview/${encodeURIComponent(getUserId())}/`}
                    className="w-full h-full border-0"
                    title="App preview"
                  />
                )}

                {sidebarTab === "source" && (
                  <div className="p-4">
                    {appState.source && (
                      <SourcePreview
                        source={{
                          ...appState.source,
                          ...(appState.assets
                            ? Object.fromEntries(
                                Object.entries(appState.assets).map(
                                  ([k, v]) => [`[asset] ${k}`, v]
                                )
                              )
                            : {})
                        }}
                      />
                    )}
                  </div>
                )}

                {sidebarTab === "test" && (
                  <div className="p-4">
                    <RequestTester
                      onTest={handleManualTest}
                      disabled={!appState.built}
                    />
                  </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>
  );
}