branch:
client.tsx
15030 bytesRaw
/**
 * Sub-Agents Example — Client
 *
 * Chat on the left. Right panel shows the most recent analysis round:
 * three perspective cards (Technical, Business, Skeptic) and the synthesis.
 * Each card updates as its facet completes, showing parallel execution.
 */

import { Suspense, useCallback, useState, useEffect, useRef } from "react";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { isToolUIPart } 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,
  GearIcon,
  UsersThreeIcon,
  LightbulbIcon,
  ChartBarIcon,
  WarningCircleIcon,
  ArrowsInIcon
} from "@phosphor-icons/react";
import { Streamdown } from "streamdown";
import type {
  SubagentState,
  AnalysisRound,
  PerspectiveId
  // PERSPECTIVES
} from "./server";

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

// ─── Perspective Card ──────────────────────────────────────────────────────

const perspectiveConfig: Record<
  PerspectiveId,
  { icon: React.ReactNode; color: string }
> = {
  technical: {
    icon: <GearIcon size={16} />,
    color: "text-kumo-brand"
  },
  business: {
    icon: <ChartBarIcon size={16} />,
    color: "text-kumo-positive"
  },
  skeptic: {
    icon: <WarningCircleIcon size={16} />,
    color: "text-kumo-warning"
  }
};

function PerspectiveCard({
  perspectiveId,
  name,
  analysis
}: {
  perspectiveId: PerspectiveId;
  name: string;
  analysis: string | null;
}) {
  const config =
    perspectiveConfig[perspectiveId] ?? perspectiveConfig.technical;

  return (
    <Surface className="rounded-lg overflow-hidden">
      <div className="flex items-center gap-2 px-3 py-2 border-b border-kumo-line">
        <span className={config.color}>{config.icon}</span>
        <Text size="xs" bold>
          {name}
        </Text>
        {analysis ? (
          <Badge variant="primary">Done</Badge>
        ) : (
          <Badge variant="outline">Thinking...</Badge>
        )}
      </div>
      <div className="px-3 py-2">
        {analysis ? (
          <Streamdown className="sd-theme text-sm" controls={false}>
            {analysis}
          </Streamdown>
        ) : (
          <div className="flex items-center gap-2 py-4 justify-center">
            <GearIcon size={14} className="text-kumo-inactive animate-spin" />
            <Text size="xs" variant="secondary">
              Analyzing...
            </Text>
          </div>
        )}
      </div>
    </Surface>
  );
}

// ─── Analysis Panel ────────────────────────────────────────────────────────

function AnalysisPanel({ analyses }: { analyses: AnalysisRound[] }) {
  if (analyses.length === 0) {
    return (
      <Empty
        icon={<UsersThreeIcon size={32} />}
        title="No analyses yet"
        description='Ask a question — e.g. "Should we rewrite our backend in Rust?"'
      />
    );
  }

  const latest = analyses[0];

  return (
    <div className="space-y-3">
      {/* Question */}
      <div className="px-1">
        <Text size="xs" variant="secondary" bold>
          Question
        </Text>
        <Text size="sm" bold>
          {latest.question}
        </Text>
      </div>

      {/* Three perspective cards */}
      {(["technical", "business", "skeptic"] as PerspectiveId[]).map((pid) => {
        const result = latest.perspectives.find((p) => p.perspectiveId === pid);
        const names: Record<PerspectiveId, string> = {
          technical: "Technical Expert",
          business: "Business Analyst",
          skeptic: "Devil's Advocate"
        };
        return (
          <PerspectiveCard
            key={pid}
            perspectiveId={pid}
            name={names[pid]}
            analysis={result?.analysis ?? null}
          />
        );
      })}

      {/* Synthesis */}
      {latest.synthesis && (
        <Surface className="rounded-lg overflow-hidden ring-2 ring-kumo-brand">
          <div className="flex items-center gap-2 px-3 py-2 border-b border-kumo-line">
            <ArrowsInIcon size={16} className="text-kumo-brand" />
            <Text size="xs" bold>
              Synthesis
            </Text>
            <Badge variant="primary">Combined</Badge>
          </div>
          <div className="px-3 py-2">
            <Streamdown className="sd-theme text-sm" controls={false}>
              {latest.synthesis}
            </Streamdown>
          </div>
        </Surface>
      )}

      {/* History */}
      {analyses.length > 1 && (
        <div className="pt-2">
          <Text size="xs" variant="secondary" bold>
            Previous ({analyses.length - 1})
          </Text>
          <div className="space-y-1 mt-1">
            {analyses.slice(1).map((round) => (
              <Surface key={round.id} className="px-3 py-2 rounded-lg">
                <Text size="xs">{round.question}</Text>
                <Text size="xs" variant="secondary">
                  {round.perspectives.length} perspectives
                  {round.synthesis ? " + synthesis" : ""}
                </Text>
              </Surface>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Main ──────────────────────────────────────────────────────────────────

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

  const agent = useAgent<SubagentState>({
    agent: "CoordinatorAgent",
    onOpen: useCallback(() => setConnectionStatus("connected"), []),
    onClose: useCallback(() => setConnectionStatus("disconnected"), []),
    onError: useCallback(
      (error: Event) => console.error("WebSocket error:", error),
      []
    ),
    onStateUpdate: useCallback(
      (state: SubagentState) => setAgentState(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]);

  return (
    <div className="flex h-screen bg-kumo-elevated">
      {/* Left: Chat */}
      <div className="flex flex-col flex-1 min-w-0 border-r border-kumo-line">
        <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>
                Sub-Agents
              </Text>
              <Badge variant="secondary">
                <UsersThreeIcon size={12} weight="bold" className="mr-1" />
                Multi-Perspective
              </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>

        <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={<LightbulbIcon size={32} />}
                title="Ask a question for multi-perspective analysis"
                description={`Try: "Should we migrate to microservices?" or "Is AI going to replace software engineers?"`}
              />
            )}

            {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>
                    )
                  )}

                  {message.parts
                    .filter((part) => isToolUIPart(part))
                    .map((part) => {
                      if (!isToolUIPart(part)) return null;

                      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">
                                <UsersThreeIcon
                                  size={14}
                                  className="text-kumo-brand"
                                />
                                <Text size="xs" variant="secondary">
                                  3 perspectives analyzed + synthesis complete
                                </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">
                                  Analyzing from 3 perspectives...
                                </Text>
                              </div>
                            </Surface>
                          </div>
                        );
                      }

                      return null;
                    })}
                </div>
              );
            })}

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

        <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: "Should we build or buy our auth system?"'
                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: Analysis panels */}
      <div className="w-[440px] flex flex-col bg-kumo-base shrink-0">
        <div className="flex items-center gap-2 px-4 py-3 border-b border-kumo-line">
          <UsersThreeIcon size={16} />
          <Text size="sm" bold>
            Perspectives
          </Text>
        </div>
        <div className="flex-1 overflow-y-auto p-4">
          {agentState ? (
            <AnalysisPanel analyses={agentState.analyses} />
          ) : (
            <div className="flex items-center justify-center h-32">
              <Text variant="secondary">Connecting...</Text>
            </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>
      }
    >
      <ChatPanel />
    </Suspense>
  );
}