branch:
client.tsx
14268 bytesRaw
import "./styles.css";
import { createRoot } from "react-dom/client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import type {
  AgentCard,
  Message,
  Task,
  TaskStatusUpdateEvent
} from "@a2a-js/sdk";
import {
  Button,
  Badge,
  Empty,
  InputArea,
  Surface,
  Text
} from "@cloudflare/kumo";
import { ModeToggle, PoweredByAgents } from "@cloudflare/agents-ui";
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import {
  InfoIcon,
  PaperPlaneRightIcon,
  RobotIcon,
  SpinnerIcon
} from "@phosphor-icons/react";

// -- Lightweight A2A client using fetch (no SDK bundling needed) --

type A2AEvent = Message | Task | TaskStatusUpdateEvent;

async function fetchAgentCard(baseUrl: string): Promise<AgentCard> {
  const res = await fetch(`${baseUrl}/.well-known/agent-card.json`);
  if (!res.ok) throw new Error(`Failed to fetch agent card: ${res.status}`);
  return res.json();
}

async function sendMessage(
  url: string,
  message: Message
): Promise<Message | Task> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: crypto.randomUUID(),
      method: "message/send",
      params: { message }
    })
  });
  const json = (await res.json()) as {
    error?: { message: string };
    result: Message | Task;
  };
  if (json.error) throw new Error(json.error.message);
  return json.result;
}

async function* streamMessage(
  url: string,
  message: Message
): AsyncGenerator<A2AEvent, void, undefined> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: crypto.randomUUID(),
      method: "message/stream",
      params: { message }
    })
  });

  if (!res.ok) throw new Error(`Stream request failed: ${res.status}`);

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const parts = buffer.split("\n\n");
    buffer = parts.pop()!;

    for (const part of parts) {
      for (const line of part.split("\n")) {
        if (line.startsWith("data: ")) {
          const data = JSON.parse(line.slice(6));
          if (data.result) yield data.result as A2AEvent;
        }
      }
    }
  }
}

// -- UI types --

interface ChatEntry {
  id: string;
  role: "user" | "agent";
  text: string;
  taskId?: string;
  status?: string;
}

// -- Components --

function getTextFromMessage(msg: Message): string {
  return msg.parts
    .filter((p) => p.kind === "text")
    .map((p) => (p as { kind: "text"; text: string }).text)
    .join("");
}

function StatusBadge({ state }: { state: string }) {
  const variant =
    state === "completed"
      ? "primary"
      : state === "failed" || state === "canceled"
        ? "destructive"
        : "secondary";

  return <Badge variant={variant}>{state}</Badge>;
}

function Chat() {
  const [agentCard, setAgentCard] = useState<AgentCard | null>(null);
  const [messages, setMessages] = useState<ChatEntry[]>([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const contextIdRef = useRef<string>(crypto.randomUUID());

  // Discover agent on mount
  useEffect(() => {
    fetchAgentCard(window.location.origin)
      .then(setAgentCard)
      .catch((err) => setError(`Agent discovery failed: ${err.message}`));
  }, []);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const send = useCallback(async () => {
    const text = input.trim();
    if (!text || isLoading || !agentCard) return;

    setInput("");
    setError(null);

    const userEntry: ChatEntry = {
      id: crypto.randomUUID(),
      role: "user",
      text
    };
    setMessages((prev) => [...prev, userEntry]);
    setIsLoading(true);

    const userMessage: Message = {
      contextId: contextIdRef.current,
      kind: "message",
      messageId: crypto.randomUUID(),
      parts: [{ kind: "text", text }],
      role: "user"
    };

    try {
      if (agentCard.capabilities.streaming) {
        // Use streaming
        let agentText = "";
        let taskStatus = "";
        let taskId = "";
        const agentEntryId = crypto.randomUUID();

        // Add placeholder
        setMessages((prev) => [
          ...prev,
          { id: agentEntryId, role: "agent", text: "", status: "working" }
        ]);

        for await (const event of streamMessage(agentCard.url, userMessage)) {
          if (event.kind === "message" && (event as Message).role === "agent") {
            agentText = getTextFromMessage(event as Message);
            taskId = (event as Message).taskId || taskId;
          } else if (event.kind === "status-update") {
            const update = event as TaskStatusUpdateEvent;
            taskStatus = update.status.state;
            taskId = update.taskId || taskId;
            if (update.status.message) {
              agentText = getTextFromMessage(update.status.message);
            }
          } else if (event.kind === "task") {
            taskId = (event as Task).id;
            taskStatus = (event as Task).status.state;
          }

          setMessages((prev) =>
            prev.map((m) =>
              m.id === agentEntryId
                ? {
                    ...m,
                    text: agentText,
                    status: taskStatus,
                    taskId
                  }
                : m
            )
          );
        }
      } else {
        // Non-streaming fallback
        const result = await sendMessage(agentCard.url, userMessage);
        let agentText = "";
        let taskId = "";
        let status = "";

        if (result.kind === "message") {
          agentText = getTextFromMessage(result);
        } else if (result.kind === "task") {
          const task = result as Task;
          taskId = task.id;
          status = task.status.state;
          if (task.status.message) {
            agentText = getTextFromMessage(task.status.message);
          }
        }

        setMessages((prev) => [
          ...prev,
          {
            id: crypto.randomUUID(),
            role: "agent",
            text: agentText,
            taskId,
            status
          }
        ]);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setIsLoading(false);
    }
  }, [input, isLoading, agentCard]);

  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-3xl mx-auto flex items-center justify-between">
          <div className="flex items-center gap-3">
            <RobotIcon size={24} weight="bold" className="text-kumo-accent" />
            <h1 className="text-lg font-semibold text-kumo-default">
              {agentCard?.name || "A2A Agent"}
            </h1>
            <Badge variant="secondary">A2A Protocol</Badge>
          </div>
          <ModeToggle />
        </div>
      </header>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto">
        <div className="max-w-3xl mx-auto px-5 py-6 space-y-5">
          {/* Explainer */}
          <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>
                  Agent-to-Agent (A2A) Protocol
                </Text>
                <span className="mt-1 block">
                  <Text size="xs" variant="secondary">
                    This demo exposes a Cloudflare Agent as an A2A-compliant
                    server. The browser acts as an A2A client, discovering the
                    agent via its Agent Card and communicating over JSON-RPC
                    with SSE streaming. Any A2A client can connect at{" "}
                    <code className="font-mono text-kumo-accent">
                      /.well-known/agent-card.json
                    </code>
                  </Text>
                </span>
              </div>
            </div>
          </Surface>

          {/* Agent card info */}
          {agentCard && (
            <Surface className="p-3 rounded-xl ring ring-kumo-line">
              <div className="flex items-center gap-2 flex-wrap">
                <Badge variant="primary">v{agentCard.protocolVersion}</Badge>
                {agentCard.capabilities.streaming && (
                  <Badge variant="secondary">Streaming</Badge>
                )}
                {agentCard.skills?.map((skill) => (
                  <Badge key={skill.id} variant="secondary">
                    {skill.name}
                  </Badge>
                ))}
                <span className="text-xs text-kumo-subtle ml-auto font-mono">
                  {agentCard.url}
                </span>
              </div>
            </Surface>
          )}

          {messages.length === 0 && (
            <Empty
              icon={<RobotIcon size={32} />}
              title="Start a conversation"
              description='This AI agent communicates via the A2A protocol. Try "Explain A2A in simple terms" or "Write a haiku about cloud computing"'
            />
          )}

          {messages.map((entry) => {
            if (entry.role === "user") {
              return (
                <div key={entry.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">
                    {entry.text}
                  </div>
                </div>
              );
            }

            return (
              <div key={entry.id} className="flex justify-start">
                <div className="max-w-[85%] space-y-2">
                  <div className="px-4 py-2.5 rounded-2xl rounded-bl-md bg-kumo-base text-kumo-default leading-relaxed">
                    {entry.text ? (
                      <div className="whitespace-pre-wrap">
                        {entry.text}
                        {isLoading && entry.status === "working" && (
                          <span className="inline-block w-0.5 h-[1em] bg-kumo-brand ml-0.5 align-text-bottom animate-blink-cursor" />
                        )}
                      </div>
                    ) : (
                      <div className="flex items-center gap-2">
                        <SpinnerIcon
                          size={14}
                          className="animate-spin text-kumo-subtle"
                        />
                        <span className="text-sm text-kumo-subtle">
                          Thinking...
                        </span>
                      </div>
                    )}
                  </div>
                  {entry.status && (
                    <div className="flex items-center gap-2 px-1">
                      <StatusBadge state={entry.status} />
                      {entry.taskId && (
                        <span className="text-[10px] font-mono text-kumo-inactive truncate max-w-48">
                          {entry.taskId}
                        </span>
                      )}
                    </div>
                  )}
                </div>
              </div>
            );
          })}

          {error && (
            <Surface className="p-3 rounded-xl ring ring-red-300">
              <Text size="xs" variant="secondary">
                {error}
              </Text>
            </Surface>
          )}

          <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={
                agentCard
                  ? "Send a message via A2A protocol..."
                  : "Discovering agent..."
              }
              disabled={!agentCard || isLoading}
              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() || !agentCard || isLoading}
              icon={
                isLoading ? (
                  <SpinnerIcon size={18} className="animate-spin" />
                ) : (
                  <PaperPlaneRightIcon size={18} />
                )
              }
              className="mb-0.5"
            />
          </div>
        </form>
        <div className="flex justify-center pb-3">
          <PoweredByAgents />
        </div>
      </div>
    </div>
  );
}

function App() {
  return (
    <ThemeProvider>
      <Suspense
        fallback={
          <div className="flex items-center justify-center h-screen text-kumo-inactive">
            Loading...
          </div>
        }
      >
        <Chat />
      </Suspense>
    </ThemeProvider>
  );
}

const root = document.getElementById("root")!;
createRoot(root).render(<App />);