branch:
client.tsx
22502 bytesRaw
/**
 * Gatekeeper Example — Client
 *
 * Split-panel layout:
 *   Left:  AI chat (talk to the agent, ask it to query/modify the database)
 *   Right: Approval queue + customer data (see pending actions, approve/reject/revert)
 *
 * The chat and the approval queue are connected through the Agent's state sync.
 * When the agent proposes an action, it appears in the queue in real-time.
 * When a human approves/rejects, the database updates and the state syncs back.
 *
 * This demonstrates the Gatekeeper pattern's key UX property: the human always
 * has a clear view of what the agent wants to do, and full control over whether
 * it happens.
 */

import { Suspense, useCallback, useState, useEffect, useRef } 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,
  TrashIcon,
  CheckCircleIcon,
  XCircleIcon,
  GearIcon,
  ShieldCheckIcon,
  ClockIcon,
  ArrowCounterClockwiseIcon,
  DatabaseIcon,
  EyeIcon,
  WarningIcon,
  MagnifyingGlassIcon
} from "@phosphor-icons/react";
import { Streamdown } from "streamdown";
import type { GatekeeperState, ActionEntry, CustomerRecord } from "./server";

// ─── Helpers ───────────────────────────────────────────────────────────────

function getMessageText(message: UIMessage): string {
  return message.parts
    .filter((part) => part.type === "text")
    .map((part) => (part as { type: "text"; text: string }).text)
    .join("");
}

function timeAgo(dateStr: string): string {
  const seconds = Math.floor(
    (Date.now() - new Date(dateStr + "Z").getTime()) / 1000
  );
  if (seconds < 60) return "just now";
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return `${Math.floor(seconds / 86400)}d ago`;
}

// ─── Action Queue Panel ────────────────────────────────────────────────────

/**
 * The approval queue UI. This is the human's view into the Gatekeeper.
 *
 * Each pending action shows:
 * - What it does (title + description)
 * - The exact SQL that will run (full transparency)
 * - Approve / Reject buttons
 *
 * Approved actions show a Revert button if the action is revertable.
 * The entire history serves as an audit log.
 */
function ActionQueue({
  actions,
  onApprove,
  onReject,
  onRevert
}: {
  actions: ActionEntry[];
  onApprove: (id: number) => void;
  onReject: (id: number) => void;
  onRevert: (id: number) => void;
}) {
  const pending = actions.filter((a) => a.state === "pending");
  const resolved = actions.filter((a) => a.state !== "pending");

  return (
    <div className="space-y-4">
      {/* Pending actions — these need human attention */}
      {pending.length > 0 && (
        <div>
          <div className="flex items-center gap-2 mb-3">
            <WarningIcon size={16} className="text-kumo-warning" />
            <Text size="sm" bold>
              Pending Approval ({pending.length})
            </Text>
          </div>
          <div className="space-y-3">
            {pending.map((action) => (
              <Surface
                key={action.id}
                className="p-4 rounded-xl ring-2 ring-kumo-warning"
              >
                <div className="flex items-start justify-between gap-2 mb-2">
                  <Text size="sm" bold>
                    {action.title}
                  </Text>
                  <Badge variant="outline">Pending</Badge>
                </div>
                <p className="text-xs text-kumo-secondary mb-2">
                  {action.description}
                </p>
                <div className="bg-kumo-elevated rounded-lg p-2 mb-3 font-mono">
                  <p className="text-xs text-kumo-secondary">{action.sql}</p>
                </div>
                <div className="flex gap-2">
                  <Button
                    variant="primary"
                    size="sm"
                    icon={<CheckCircleIcon size={14} />}
                    onClick={() => onApprove(action.id)}
                  >
                    Approve
                  </Button>
                  <Button
                    variant="secondary"
                    size="sm"
                    icon={<XCircleIcon size={14} />}
                    onClick={() => onReject(action.id)}
                  >
                    Reject
                  </Button>
                </div>
              </Surface>
            ))}
          </div>
        </div>
      )}

      {/* Resolved actions — the audit log */}
      {resolved.length > 0 && (
        <div>
          <div className="flex items-center gap-2 mb-3">
            <ClockIcon size={16} className="text-kumo-inactive" />
            <Text size="sm" bold>
              History
            </Text>
          </div>
          <div className="space-y-2">
            {resolved.map((action) => (
              <Surface key={action.id} className="p-3 rounded-lg">
                <div className="flex items-start justify-between gap-2">
                  <div className="flex items-center gap-2 min-w-0">
                    {action.type === "observation" ? (
                      <EyeIcon
                        size={14}
                        className="text-kumo-inactive shrink-0"
                      />
                    ) : (
                      <DatabaseIcon
                        size={14}
                        className="text-kumo-inactive shrink-0"
                      />
                    )}
                    <Text size="xs">{action.title}</Text>
                  </div>
                  <div className="flex items-center gap-2 shrink-0">
                    {action.state === "approved" && (
                      <Badge variant="primary">Approved</Badge>
                    )}
                    {action.state === "rejected" && (
                      <Badge variant="destructive">Rejected</Badge>
                    )}
                    {action.state === "reverted" && (
                      <Badge variant="secondary">Reverted</Badge>
                    )}
                  </div>
                </div>

                {/* Show revert button for approved, revertable actions */}
                {action.state === "approved" &&
                  action.canRevert &&
                  action.type === "action" && (
                    <div className="mt-2 flex justify-end">
                      <Button
                        variant="secondary"
                        size="sm"
                        icon={<ArrowCounterClockwiseIcon size={14} />}
                        onClick={() => onRevert(action.id)}
                      >
                        Revert
                      </Button>
                    </div>
                  )}

                {action.resolvedAt && (
                  <p className="text-xs text-kumo-secondary mt-1">
                    {timeAgo(action.resolvedAt)}
                  </p>
                )}
              </Surface>
            ))}
          </div>
        </div>
      )}

      {actions.length === 0 && (
        <Empty
          icon={<ShieldCheckIcon size={32} />}
          title="No actions yet"
          description="Ask the agent to modify the database — actions will appear here for approval"
        />
      )}
    </div>
  );
}

// ─── Customer Table ────────────────────────────────────────────────────────

/**
 * Shows the current state of the customer database.
 * Updates in real-time as actions are approved/reverted.
 */
function CustomerTable({ customers }: { customers: CustomerRecord[] }) {
  if (customers.length === 0) {
    return (
      <Empty
        icon={<DatabaseIcon size={32} />}
        title="No customers"
        description="The database is empty"
      />
    );
  }

  return (
    <div className="overflow-x-auto">
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b border-kumo-line">
            <th className="text-left py-2 px-2 font-medium text-kumo-secondary">
              Name
            </th>
            <th className="text-left py-2 px-2 font-medium text-kumo-secondary">
              Email
            </th>
            <th className="text-left py-2 px-2 font-medium text-kumo-secondary">
              Tier
            </th>
            <th className="text-left py-2 px-2 font-medium text-kumo-secondary">
              Region
            </th>
          </tr>
        </thead>
        <tbody>
          {customers.map((c) => (
            <tr key={c.id} className="border-b border-kumo-line/50">
              <td className="py-2 px-2">{c.name}</td>
              <td className="py-2 px-2 text-kumo-secondary">{c.email}</td>
              <td className="py-2 px-2">
                <Badge variant={c.tier === "Gold" ? "primary" : "secondary"}>
                  {c.tier}
                </Badge>
              </td>
              <td className="py-2 px-2 text-kumo-secondary">{c.region}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ─── Chat Panel ────────────────────────────────────────────────────────────

function ChatPanel() {
  const [connectionStatus, setConnectionStatus] =
    useState<ConnectionStatus>("connecting");
  const [input, setInput] = useState("");
  const [gatekeeperState, setGatekeeperState] =
    useState<GatekeeperState | null>(null);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const agent = useAgent<GatekeeperState>({
    agent: "GatekeeperAgent",
    onOpen: useCallback(() => setConnectionStatus("connected"), []),
    onClose: useCallback(() => setConnectionStatus("disconnected"), []),
    onError: useCallback(
      (error: Event) => console.error("WebSocket error:", error),
      []
    ),
    onStateUpdate: useCallback(
      (state: GatekeeperState) => setGatekeeperState(state),
      []
    )
  });

  const { messages, sendMessage, clearHistory, 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]);

  // Approval queue actions — call the agent's @callable methods via RPC
  const handleApprove = useCallback(
    (id: number) => agent.call("approveAction", [id]),
    [agent]
  );
  const handleReject = useCallback(
    (id: number) => agent.call("rejectAction", [id]),
    [agent]
  );
  const handleRevert = useCallback(
    (id: number) => agent.call("revertAction", [id]),
    [agent]
  );

  // Active tab for the right panel
  const [rightTab, setRightTab] = useState<"queue" | "data">("queue");
  const pendingCount =
    gatekeeperState?.actions.filter((a) => a.state === "pending").length ?? 0;

  return (
    <div className="flex h-screen bg-kumo-elevated">
      {/* ── Left Panel: Chat ─────────────────────────────────────── */}
      <div className="flex flex-col flex-1 min-w-0 border-r border-kumo-line">
        {/* Header */}
        <header className="px-5 py-4 bg-kumo-base border-b border-kumo-line">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <Text size="lg" bold>
                Gatekeeper
              </Text>
              <Badge variant="secondary">
                <ShieldCheckIcon size={12} weight="bold" className="mr-1" />
                Approval Queue
              </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>

        {/* Messages */}
        <div className="flex-1 overflow-y-auto">
          <div className="max-w-2xl mx-auto px-5 py-6 space-y-5">
            {messages.length === 0 && (
              <Empty
                icon={<MagnifyingGlassIcon size={32} />}
                title="Talk to the database agent"
                description={`Try: "Show me all Gold tier customers" or "Upgrade all East region customers to Gold"`}
              />
            )}

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

              return (
                <div key={message.id} className="space-y-2">
                  {isUser ? (
                    <div 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>
                  ) : (
                    getMessageText(message) && (
                      <div className="flex justify-start">
                        <div className="max-w-[85%] rounded-2xl rounded-bl-md bg-kumo-base text-kumo-default leading-relaxed overflow-hidden">
                          <Streamdown
                            className="sd-theme px-4 py-2.5"
                            controls={false}
                            isAnimating={isLastAssistant && isStreaming}
                          >
                            {getMessageText(message)}
                          </Streamdown>
                        </div>
                      </div>
                    )
                  )}

                  {/* Tool call indicators */}
                  {message.parts
                    .filter((part) => isToolUIPart(part))
                    .map((part) => {
                      if (!isToolUIPart(part)) return null;
                      const toolName = getToolName(part);

                      if (part.state === "output-available") {
                        return (
                          <div
                            key={part.toolCallId}
                            className="flex justify-start"
                          >
                            <Surface className="max-w-[85%] px-3 py-2 rounded-lg ring ring-kumo-line">
                              <div className="flex items-center gap-2">
                                {toolName === "queryDatabase" ? (
                                  <EyeIcon
                                    size={14}
                                    className="text-kumo-positive"
                                  />
                                ) : (
                                  <ShieldCheckIcon
                                    size={14}
                                    className="text-kumo-warning"
                                  />
                                )}
                                <Text size="xs" variant="secondary">
                                  {toolName === "queryDatabase"
                                    ? "Query executed (auto-approved)"
                                    : "Action queued for approval"}
                                </Text>
                              </div>
                            </Surface>
                          </div>
                        );
                      }

                      if (
                        part.state === "input-available" ||
                        part.state === "input-streaming"
                      ) {
                        return (
                          <div
                            key={part.toolCallId}
                            className="flex justify-start"
                          >
                            <Surface className="max-w-[85%] px-3 py-2 rounded-lg ring ring-kumo-line">
                              <div className="flex items-center gap-2">
                                <GearIcon
                                  size={14}
                                  className="text-kumo-inactive animate-spin"
                                />
                                <Text size="xs" variant="secondary">
                                  Running {toolName}...
                                </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-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='Try: "Show me customers in the West region"'
                disabled={!isConnected || isStreaming}
                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() || !isConnected || isStreaming}
                icon={<PaperPlaneRightIcon size={18} />}
                loading={isStreaming}
                className="mb-0.5"
              />
            </div>
          </form>
          <div className="flex justify-center pb-3">
            <PoweredByAgents />
          </div>
        </div>
      </div>

      {/* ── Right Panel: Approval Queue + Data ───────────────────── */}
      <div className="w-[420px] flex flex-col bg-kumo-base shrink-0">
        {/* Tab bar */}
        <div className="flex border-b border-kumo-line">
          <button
            onClick={() => setRightTab("queue")}
            className={`flex-1 py-3 px-4 text-sm font-medium transition-colors relative ${
              rightTab === "queue"
                ? "text-kumo-default"
                : "text-kumo-secondary hover:text-kumo-default"
            }`}
          >
            <span className="flex items-center justify-center gap-2">
              <ShieldCheckIcon size={16} />
              Actions
              {pendingCount > 0 && (
                <Badge variant="destructive">{pendingCount}</Badge>
              )}
            </span>
            {rightTab === "queue" && (
              <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-kumo-brand" />
            )}
          </button>
          <button
            onClick={() => setRightTab("data")}
            className={`flex-1 py-3 px-4 text-sm font-medium transition-colors relative ${
              rightTab === "data"
                ? "text-kumo-default"
                : "text-kumo-secondary hover:text-kumo-default"
            }`}
          >
            <span className="flex items-center justify-center gap-2">
              <DatabaseIcon size={16} />
              Customers
              {gatekeeperState && (
                <Badge variant="secondary">
                  {gatekeeperState.customers.length}
                </Badge>
              )}
            </span>
            {rightTab === "data" && (
              <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-kumo-brand" />
            )}
          </button>
        </div>

        {/* Tab content */}
        <div className="flex-1 overflow-y-auto p-4">
          {rightTab === "queue" && gatekeeperState && (
            <ActionQueue
              actions={gatekeeperState.actions}
              onApprove={handleApprove}
              onReject={handleReject}
              onRevert={handleRevert}
            />
          )}
          {rightTab === "data" && gatekeeperState && (
            <CustomerTable customers={gatekeeperState.customers} />
          )}
          {!gatekeeperState && (
            <div className="flex items-center justify-center h-32">
              <Text variant="secondary">Connecting...</Text>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// ─── App Root ──────────────────────────────────────────────────────────────

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