branch:
client.tsx
10964 bytesRaw
/** React client — name form + authenticated chat UI. */

import {
  useCallback,
  useState,
  useEffect,
  useRef,
  type FormEvent
} from "react";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import {
  Banner,
  Button,
  Input,
  InputArea,
  Label,
  Surface,
  Text
} from "@cloudflare/kumo";
import {
  ConnectionIndicator,
  ModeToggle,
  PoweredByAgents,
  type ConnectionStatus
} from "@cloudflare/agents-ui";
import {
  PaperPlaneRightIcon,
  SignOutIcon,
  ShieldCheckIcon,
  LockKeyIcon,
  TrashIcon,
  InfoIcon
} from "@phosphor-icons/react";
import {
  fetchToken,
  getToken,
  getUserName,
  clearAuth,
  isTokenExpired
} from "./auth-client";

// ── Name form ────────────────────────────────────────────────────────────────

function NameForm({ onSuccess }: { onSuccess: () => void }) {
  const [name, setName] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const handleSubmit = useCallback(
    async (e: FormEvent) => {
      e.preventDefault();
      setError(null);
      setLoading(true);

      try {
        await fetchToken(name.trim());
        onSuccess();
      } catch {
        setError("Failed to authenticate");
      } finally {
        setLoading(false);
      }
    },
    [name, onSuccess]
  );

  return (
    <div className="flex flex-col min-h-screen bg-kumo-base">
      {/* Header */}
      <header className="px-5 py-4 border-b border-kumo-line">
        <div className="flex items-center justify-end">
          <ModeToggle />
        </div>
      </header>

      {/* Content */}
      <div className="flex-1 flex items-center justify-center py-12">
        <div className="w-full max-w-lg px-6">
          <Surface className="px-10 py-12 rounded-2xl ring ring-kumo-line">
            <form onSubmit={handleSubmit}>
              <div className="mb-10">
                <div className="flex items-center gap-3 mb-3">
                  <div className="flex items-center justify-center w-10 h-10 rounded-full bg-kumo-brand/10">
                    <LockKeyIcon
                      size={20}
                      weight="bold"
                      className="text-kumo-brand"
                    />
                  </div>
                  <Text variant="heading1">Auth Agent</Text>
                </div>
                <Text variant="secondary">
                  Enter your name to get a JWT and connect to the agent.
                </Text>
              </div>

              <div className="flex flex-col gap-2.5">
                <Label>Name</Label>
                <Input
                  size="lg"
                  placeholder="Your name"
                  aria-label="Name"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  autoComplete="name"
                  required
                />
              </div>

              {error && (
                <div className="mt-6">
                  <Banner variant="error">{error}</Banner>
                </div>
              )}

              <div className="border-t border-kumo-line my-8" />

              <Button
                type="submit"
                variant="primary"
                size="lg"
                className="w-full"
                loading={loading}
                disabled={!name.trim() || loading}
              >
                Connect
              </Button>
            </form>
          </Surface>
        </div>
      </div>

      {/* Footer */}
      <div className="flex justify-center pb-3">
        <PoweredByAgents />
      </div>
    </div>
  );
}

// ── Chat view (authenticated) ────────────────────────────────────────────────

function getMessageText(message: {
  parts: Array<{ type: string; text?: string }>;
}): string {
  return message.parts
    .filter((p) => p.type === "text")
    .map((p) => p.text ?? "")
    .join("");
}

function ChatView({ onSignOut }: { onSignOut: () => void }) {
  const [wsStatus, setWsStatus] = useState<ConnectionStatus>("connecting");
  const [input, setInput] = useState("");
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const userName = getUserName() ?? "user";

  const handleOpen = useCallback(() => setWsStatus("connected"), []);
  const handleClose = useCallback(() => {
    if (isTokenExpired()) {
      clearAuth();
      onSignOut();
      return;
    }
    setWsStatus("disconnected");
  }, [onSignOut]);

  const agent = useAgent({
    agent: "ChatAgent",
    name: userName,
    onOpen: handleOpen,
    onClose: handleClose,
    query: async () => ({
      token: getToken() || ""
    })
  });

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

  const isStreaming = status === "streaming";
  const isConnected = wsStatus === "connected";

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

  const send = useCallback(async () => {
    const text = input.trim();
    if (!text || isStreaming) return;
    setInput("");
    try {
      await sendMessage({
        role: "user",
        parts: [{ type: "text", text }]
      });
    } catch (err) {
      console.error("Failed to send message:", err);
    }
  }, [input, isStreaming, sendMessage]);

  const handleSignOut = useCallback(() => {
    clearAuth();
    onSignOut();
  }, [onSignOut]);

  return (
    <div className="h-screen flex flex-col bg-kumo-base">
      {/* Header */}
      <header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-kumo-line">
        <div className="flex items-center gap-3">
          <ShieldCheckIcon
            size={20}
            weight="bold"
            className="text-kumo-brand"
          />
          <Text variant="heading3">Auth Agent</Text>
          <ConnectionIndicator status={wsStatus} />
        </div>
        <div className="flex items-center gap-3">
          <ModeToggle />
          <Button
            variant="ghost"
            size="sm"
            icon={<TrashIcon size={16} />}
            onClick={clearHistory}
            title="Clear chat history"
          />
          <Button
            variant="secondary"
            size="sm"
            icon={<SignOutIcon size={16} />}
            onClick={handleSignOut}
          >
            Sign out
          </Button>
        </div>
      </header>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto">
        <div className="max-w-3xl mx-auto px-6 py-6 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>
                  Authenticated Agent
                </Text>
                <span className="mt-1 block">
                  <Text size="xs" variant="secondary">
                    Connected as {userName}. Your JWT is verified on every
                    WebSocket connection. The agent knows your name from the
                    token claims.
                  </Text>
                </span>
              </div>
            </div>
          </Surface>

          {messages.map((message, index) => {
            const isUser = message.role === "user";
            const text = getMessageText(message);
            const isLastAssistant = !isUser && index === messages.length - 1;

            if (isUser) {
              return (
                <div key={message.id} className="flex justify-end">
                  <div className="max-w-[80%] px-4 py-2.5 rounded-2xl rounded-br-sm bg-kumo-contrast text-kumo-inverse text-sm leading-relaxed whitespace-pre-wrap">
                    {text}
                  </div>
                </div>
              );
            }

            return (
              <div key={message.id} className="flex justify-start">
                <Surface className="max-w-[80%] px-4 py-2.5 rounded-2xl rounded-bl-sm ring ring-kumo-line text-sm leading-relaxed whitespace-pre-wrap">
                  {text}
                  {isLastAssistant && isStreaming && (
                    <span className="inline-block w-0.5 h-[1em] bg-kumo-brand ml-0.5 align-text-bottom animate-blink-cursor" />
                  )}
                </Surface>
              </div>
            );
          })}

          <div ref={messagesEndRef} />
        </div>
      </div>

      {/* Input */}
      <div className="border-t border-kumo-line">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            send();
          }}
          className="max-w-3xl mx-auto px-6 py-4"
        >
          <Surface className="flex items-end gap-3 rounded-xl ring ring-kumo-line p-3 focus-within:ring-kumo-interact transition-shadow">
            <InputArea
              value={input}
              onValueChange={setInput}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  send();
                }
              }}
              placeholder="Type a message..."
              disabled={!isConnected || isStreaming}
              rows={2}
              className="flex-1 ring-0! focus:ring-0! shadow-none! bg-transparent! outline-none!"
            />
            <button
              type="submit"
              aria-label="Send message"
              disabled={!input.trim() || !isConnected || isStreaming}
              className="shrink-0 mb-0.5 w-10 h-10 flex items-center justify-center rounded-lg bg-kumo-brand text-white disabled:opacity-40 disabled:cursor-not-allowed hover:brightness-110 transition-all"
            >
              <PaperPlaneRightIcon size={18} />
            </button>
          </Surface>
        </form>
        <div className="flex justify-center pb-3">
          <PoweredByAgents />
        </div>
      </div>
    </div>
  );
}

// ── App root ─────────────────────────────────────────────────────────────────

function App() {
  const [isAuthenticated, setIsAuthenticated] = useState(() => {
    if (isTokenExpired()) {
      clearAuth();
      return false;
    }
    return true;
  });

  if (isAuthenticated) {
    return <ChatView onSignOut={() => setIsAuthenticated(false)} />;
  }

  return <NameForm onSuccess={() => setIsAuthenticated(true)} />;
}

export default function AppWrapper() {
  return <App />;
}