branch:
client.tsx
52183 bytesRaw
/**
 * Assistant — Client
 *
 * Left sidebar: orchestrator + spawned agents hierarchy.
 * Main area: chat for the active agent.
 *
 * Data sources:
 *   - Agent list: from Agent state sync (useAgent onStateUpdate)
 *   - Chat messages & streaming: useChat with custom AgentChatTransport
 *   - Agent CRUD + navigation: via agent.call() RPC + WS messages
 *
 * The AgentChatTransport bridges the AI SDK's useChat hook with the Agent
 * WebSocket connection: sendMessages() triggers the server-side RPC, then
 * pipes WS stream-event messages into a ReadableStream<UIMessageChunk>
 * that useChat consumes and renders.
 */

import "./styles.css";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import {
  Suspense,
  useCallback,
  useState,
  useEffect,
  useRef,
  useMemo
} from "react";
import { useAgent } from "agents/react";
import { applyChunkToParts } from "@cloudflare/think/message-builder";
import { AgentChatTransport } from "@cloudflare/think/transport";
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import type { MCPServersState } from "agents";
import {
  Button,
  Badge,
  InputArea,
  Empty,
  Surface,
  Text
} from "@cloudflare/kumo";
import {
  ConnectionIndicator,
  ModeToggle,
  PoweredByAgents,
  type ConnectionStatus
} from "@cloudflare/agents-ui";
import {
  PaperPlaneRightIcon,
  ChatTextIcon,
  BroomIcon,
  InfoIcon,
  FolderIcon,
  GearIcon,
  PlugsConnectedIcon,
  WrenchIcon,
  SignInIcon,
  TrashIcon,
  XIcon,
  RobotIcon,
  ArrowLeftIcon,
  CircleNotchIcon,
  CheckCircleIcon,
  WarningCircleIcon,
  ArrowSquareOutIcon,
  FileIcon,
  CaretRightIcon
} from "@phosphor-icons/react";
import { Streamdown } from "streamdown";
import type { AppState, AgentInfo, FileInfo } from "./server";

const ORCHESTRATOR_ID = "orchestrator";

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

function getMessageText(msg: UIMessage): string {
  return msg.parts
    .filter((p): p is { type: "text"; text: string } => p.type === "text")
    .map((p) => p.text)
    .join("");
}

// ─── Agent status helpers ─────────────────────────────────────────────────

function AgentStatusIcon({ status }: { status: AgentInfo["status"] }) {
  switch (status) {
    case "working":
      return (
        <CircleNotchIcon size={12} className="text-kumo-accent animate-spin" />
      );
    case "done":
      return <CheckCircleIcon size={12} className="text-kumo-success" />;
    case "error":
      return <WarningCircleIcon size={12} className="text-kumo-danger" />;
    default:
      return null;
  }
}

// ─── Agent Sidebar ────────────────────────────────────────────────────────

function AgentSidebar({
  agents,
  activeAgentId,
  onSwitch,
  onDelete,
  onClear,
  onRename
}: {
  agents: AgentInfo[];
  activeAgentId: string | null;
  onSwitch: (id: string) => void;
  onDelete: (id: string) => void;
  onClear: (id: string) => void;
  onRename: (id: string, name: string) => void;
}) {
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editName, setEditName] = useState("");

  const orchestrator = agents.find((a) => a.id === ORCHESTRATOR_ID);
  const subAgents = agents.filter((a) => a.id !== ORCHESTRATOR_ID);

  return (
    <div className="flex flex-col h-full">
      <div className="px-4 py-3 border-b border-kumo-line flex items-center justify-between">
        <div className="flex items-center gap-2">
          <RobotIcon size={18} className="text-kumo-brand" />
          <Text size="sm" bold>
            Agents
          </Text>
        </div>
      </div>

      <div className="flex-1 overflow-y-auto p-2 space-y-1">
        {/* Orchestrator — always at top */}
        {orchestrator && (
          <div
            // oxlint-disable-next-line prefer-tag-over-role
            role="button"
            tabIndex={0}
            className={`group rounded-lg px-3 py-2 cursor-pointer transition-colors w-full text-left ${
              orchestrator.id === activeAgentId
                ? "bg-kumo-tint ring-1 ring-kumo-ring"
                : "hover:bg-kumo-tint/50"
            }`}
            onClick={() => onSwitch(orchestrator.id)}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                onSwitch(orchestrator.id);
              }
            }}
          >
            <div className="flex items-center justify-between">
              <div className="flex items-center gap-2 min-w-0">
                <RobotIcon
                  size={14}
                  className={
                    orchestrator.id === activeAgentId
                      ? "text-kumo-brand"
                      : "text-kumo-inactive"
                  }
                />
                <Text size="sm" bold>
                  Orchestrator
                </Text>
              </div>
              {orchestrator.messageCount > 0 && (
                <Badge variant="secondary">{orchestrator.messageCount}</Badge>
              )}
            </div>
            {orchestrator.id === activeAgentId && (
              <div className="flex items-center gap-1 mt-1.5">
                <Button
                  variant="secondary"
                  size="sm"
                  onClick={(e) => {
                    e.stopPropagation();
                    onClear(orchestrator.id);
                  }}
                >
                  Clear
                </Button>
              </div>
            )}
          </div>
        )}

        {/* Sub-agents section */}
        {subAgents.length > 0 && (
          <>
            <div className="px-3 pt-3 pb-1">
              <span className="text-xs font-medium text-kumo-subtle uppercase tracking-wider">
                Spawned Agents
              </span>
            </div>
            {subAgents.map((ag) => {
              const isActive = ag.id === activeAgentId;
              return (
                <div
                  key={ag.id}
                  // oxlint-disable-next-line prefer-tag-over-role
                  role="button"
                  tabIndex={0}
                  className={`group rounded-lg px-3 py-2 cursor-pointer transition-colors w-full text-left ${
                    isActive
                      ? "bg-kumo-tint ring-1 ring-kumo-ring"
                      : "hover:bg-kumo-tint/50"
                  }`}
                  onClick={() => onSwitch(ag.id)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      onSwitch(ag.id);
                    }
                  }}
                >
                  <div className="flex items-center justify-between">
                    <div className="flex items-center gap-2 min-w-0">
                      <ChatTextIcon
                        size={14}
                        className={
                          isActive ? "text-kumo-brand" : "text-kumo-inactive"
                        }
                      />
                      {editingId === ag.id ? (
                        <input
                          className="flex-1 text-sm bg-transparent border-b border-kumo-line text-kumo-default outline-none"
                          value={editName}
                          onChange={(e) => setEditName(e.target.value)}
                          onKeyDown={(e) => {
                            e.stopPropagation();
                            if (e.key === "Enter") {
                              onRename(ag.id, editName);
                              setEditingId(null);
                            }
                            if (e.key === "Escape") {
                              setEditingId(null);
                            }
                          }}
                          onBlur={() => {
                            onRename(ag.id, editName);
                            setEditingId(null);
                          }}
                          onClick={(e) => e.stopPropagation()}
                        />
                      ) : (
                        <Text size="sm" bold>
                          {ag.name}
                        </Text>
                      )}
                    </div>
                    <div className="flex items-center gap-1.5">
                      <AgentStatusIcon status={ag.status} />
                      {ag.messageCount > 0 && editingId !== ag.id && (
                        <Badge variant="secondary">{ag.messageCount}</Badge>
                      )}
                    </div>
                  </div>

                  {ag.lastTaskDescription && (
                    <span className="block text-xs text-kumo-subtle mt-0.5 truncate">
                      {ag.lastTaskDescription}
                    </span>
                  )}

                  <div
                    className={`flex items-center gap-1 mt-1.5 ${
                      isActive
                        ? "opacity-100"
                        : "opacity-0 group-hover:opacity-100"
                    } transition-opacity`}
                  >
                    <Button
                      variant="secondary"
                      size="sm"
                      onClick={(e) => {
                        e.stopPropagation();
                        setEditingId(ag.id);
                        setEditName(ag.name);
                      }}
                    >
                      Rename
                    </Button>
                    <Button
                      variant="secondary"
                      size="sm"
                      onClick={(e) => {
                        e.stopPropagation();
                        onClear(ag.id);
                      }}
                    >
                      Clear
                    </Button>
                    <Button
                      variant="destructive"
                      size="sm"
                      onClick={(e) => {
                        e.stopPropagation();
                        onDelete(ag.id);
                      }}
                    >
                      Delete
                    </Button>
                  </div>
                </div>
              );
            })}
          </>
        )}
      </div>
    </div>
  );
}

// ─── Messages ──────────────────────────────────────────────────────────────

// ─── Orchestrator tool names for special rendering ────────────────────────

const ORCHESTRATOR_TOOLS = new Set([
  "spawn_agent",
  "delegate_task",
  "hand_off"
]);

function DelegationCard({
  toolName,
  input,
  output,
  state,
  onNavigate
}: {
  toolName: string;
  input: Record<string, unknown>;
  output: unknown;
  state: string;
  onNavigate: (agentId: string) => void;
}) {
  const out = output as Record<string, unknown> | null | undefined;
  const agentId = (out?.agentId ?? input?.agent_id) as string | undefined;
  const isRunning = state !== "output-available";

  if (toolName === "spawn_agent") {
    return (
      <div className="flex items-center gap-2">
        <RobotIcon size={14} className="text-kumo-accent" />
        <Text size="xs" bold>
          Spawning: {(input.name as string) ?? "agent"}
        </Text>
        {isRunning ? (
          <CircleNotchIcon
            size={12}
            className="animate-spin text-kumo-accent"
          />
        ) : agentId ? (
          <Button
            variant="secondary"
            size="sm"
            onClick={() => onNavigate(agentId)}
          >
            <ArrowSquareOutIcon size={12} className="mr-1" />
            Open
          </Button>
        ) : null}
      </div>
    );
  }

  if (toolName === "delegate_task") {
    const isDelegated = !isRunning && out?.status === "delegated";
    return (
      <div className="space-y-1">
        <div className="flex items-center gap-2">
          <GearIcon
            size={14}
            className={
              isRunning ? "animate-spin text-kumo-accent" : "text-kumo-inactive"
            }
          />
          <Text size="xs" bold>
            {isDelegated ? "Delegated to agent" : "Delegating to agent"}
          </Text>
          {isRunning && <Badge variant="secondary">sending</Badge>}
          {isDelegated && <Badge variant="secondary">background</Badge>}
        </div>
        {isDelegated && out?.task != null && (
          <span className="block text-xs text-kumo-subtle mt-0.5 truncate">
            {String(out.task)}
          </span>
        )}
        {isDelegated && agentId && (
          <Button
            variant="secondary"
            size="sm"
            onClick={() => onNavigate(agentId)}
          >
            <ArrowSquareOutIcon size={12} className="mr-1" />
            View agent
          </Button>
        )}
        {!isRunning && out?.error != null && (
          <span className="text-xs text-kumo-danger">{String(out.error)}</span>
        )}
      </div>
    );
  }

  if (toolName === "hand_off") {
    return (
      <div className="flex items-center gap-2">
        <ArrowSquareOutIcon size={14} className="text-kumo-accent" />
        <Text size="xs" bold>
          Handing off to {(out?.name as string) ?? "agent"}
        </Text>
        {agentId && (
          <Button
            variant="secondary"
            size="sm"
            onClick={() => onNavigate(agentId)}
          >
            Go to chat
          </Button>
        )}
      </div>
    );
  }

  // list_agents, list_available_tools — generic rendering
  return null;
}

function Messages({
  messages,
  status,
  onNavigate,
  activeAgent
}: {
  messages: UIMessage[];
  status: string;
  onNavigate: (agentId: string) => void;
  activeAgent: AgentInfo | undefined;
}) {
  const endRef = useRef<HTMLDivElement>(null);
  const isBusy = status === "submitted" || status === "streaming";
  const isOrch = activeAgent?.id === ORCHESTRATOR_ID;

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

  if (messages.length === 0 && !isBusy) {
    return (
      <>
        <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>
                {isOrch
                  ? "Orchestrator Assistant"
                  : (activeAgent?.name ?? "Agent")}
              </Text>
              <span className="mt-1 block">
                <Text size="xs" variant="secondary">
                  {isOrch
                    ? "An AI orchestrator that can spawn specialized agents, delegate tasks, or hand off conversations. It has workspace tools, MCP server support, and model routing (fast vs capable)."
                    : `A specialized agent (${activeAgent?.config.modelTier ?? "fast"} model, ${activeAgent?.config.toolAccess ?? "workspace"} tools). Chat with it directly or navigate back to the orchestrator.`}
                </Text>
              </span>
            </div>
          </div>
        </Surface>
        <Empty
          icon={isOrch ? <FolderIcon size={32} /> : <ChatTextIcon size={32} />}
          title="Start a conversation"
          description={
            isOrch
              ? 'Try "Create a researcher agent to find info about X" or "Write a package.json for a Node.js project"'
              : "Send a message to start chatting with this agent"
          }
        />
      </>
    );
  }

  return (
    <div className="space-y-4">
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.role === "user" ? (
            <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(msg)}
              </div>
            </div>
          ) : (
            <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">
                {msg.parts.map((part, i) => {
                  if (part.type === "reasoning") {
                    return (
                      <details
                        key={i}
                        className="px-4 py-2 border-b border-kumo-line"
                        open={"state" in part && part.state === "streaming"}
                      >
                        <summary className="cursor-pointer text-xs text-kumo-inactive select-none">
                          Reasoning
                        </summary>
                        <div className="mt-1 text-xs text-kumo-secondary italic whitespace-pre-wrap">
                          {part.text}
                        </div>
                      </details>
                    );
                  }
                  if (part.type.startsWith("tool-") && "toolCallId" in part) {
                    const tp = part as unknown as {
                      type: string;
                      toolCallId: string;
                      state: string;
                      input: unknown;
                      output?: unknown;
                    };
                    const toolName = tp.type.split("-").slice(1).join("-");

                    // Orchestrator tools get special delegation card rendering
                    if (ORCHESTRATOR_TOOLS.has(toolName)) {
                      return (
                        <div
                          key={i}
                          className="px-4 py-2.5 border-b border-kumo-line"
                        >
                          <DelegationCard
                            toolName={toolName}
                            input={(tp.input ?? {}) as Record<string, unknown>}
                            output={tp.output}
                            state={tp.state}
                            onNavigate={onNavigate}
                          />
                        </div>
                      );
                    }

                    return (
                      <div
                        key={i}
                        className="px-4 py-2.5 border-b border-kumo-line"
                      >
                        <div className="flex items-center gap-2">
                          <GearIcon
                            size={14}
                            className={
                              tp.state === "output-available"
                                ? "text-kumo-inactive"
                                : "text-kumo-inactive animate-spin"
                            }
                          />
                          <Text size="xs" bold>
                            {toolName}
                          </Text>
                          <Badge variant="secondary">{tp.state}</Badge>
                        </div>
                        {tp.input != null &&
                          Object.keys(tp.input as Record<string, unknown>)
                            .length > 0 && (
                            <pre className="mt-1 text-xs text-kumo-secondary overflow-auto">
                              {JSON.stringify(tp.input, null, 2)}
                            </pre>
                          )}
                        {tp.state === "output-available" &&
                          tp.output != null && (
                            <pre className="mt-1 text-xs text-kumo-brand overflow-auto">
                              {formatToolOutput(tp.output)}
                            </pre>
                          )}
                      </div>
                    );
                  }
                  if (part.type === "text") {
                    return (
                      <Streamdown
                        key={i}
                        className="sd-theme px-4 py-2.5"
                        controls={false}
                        isAnimating={
                          "state" in part && part.state === "streaming"
                        }
                      >
                        {part.text}
                      </Streamdown>
                    );
                  }
                  return null;
                })}
              </div>
            </div>
          )}
        </div>
      ))}

      {status === "submitted" && (
        <div className="flex justify-start">
          <div className="px-4 py-2.5 rounded-2xl rounded-bl-md bg-kumo-base">
            <div className="flex items-center gap-2">
              <div className="w-2 h-2 bg-kumo-brand rounded-full animate-pulse" />
              <Text size="xs" variant="secondary">
                Thinking...
              </Text>
            </div>
          </div>
        </div>
      )}

      {!isBusy && activeAgent?.status === "working" && (
        <div className="flex justify-start">
          <div className="px-4 py-2.5 rounded-2xl rounded-bl-md bg-kumo-base">
            <div className="flex items-center gap-2">
              <CircleNotchIcon
                size={14}
                className="text-kumo-accent animate-spin"
              />
              <Text size="xs" variant="secondary">
                Working on background task...
              </Text>
            </div>
          </div>
        </div>
      )}

      <div ref={endRef} />
    </div>
  );
}

// ─── Workspace Panel ─────────────────────────────────────────────────────

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

interface AgentRpc {
  call(method: string, args?: unknown[]): Promise<unknown>;
}

function WorkspacePanel({
  agent,
  agentId,
  onClose
}: {
  agent: AgentRpc;
  agentId: string;
  onClose: () => void;
}) {
  const [which, setWhich] = useState<"private" | "shared">("private");
  const [currentPath, setCurrentPath] = useState("/");
  const [files, setFiles] = useState<FileInfo[]>([]);
  const [selectedFile, setSelectedFile] = useState<{
    path: string;
    content: string;
  } | null>(null);
  const [loading, setLoading] = useState(false);
  const [fileLoading, setFileLoading] = useState(false);

  // Load directory listing
  const loadDir = useCallback(
    async (path: string) => {
      setLoading(true);
      setSelectedFile(null);
      try {
        const result = (await agent.call("listWorkspaceFiles", [
          agentId,
          which,
          path
        ])) as FileInfo[];
        setFiles(result ?? []);
        setCurrentPath(path);
      } catch {
        setFiles([]);
      } finally {
        setLoading(false);
      }
    },
    [agent, agentId, which]
  );

  // Load file content
  const loadFile = useCallback(
    async (path: string) => {
      setFileLoading(true);
      try {
        const content = (await agent.call("readWorkspaceFile", [
          agentId,
          which,
          path
        ])) as string | null;
        setSelectedFile({ path, content: content ?? "(empty)" });
      } catch {
        setSelectedFile({ path, content: "(error reading file)" });
      } finally {
        setFileLoading(false);
      }
    },
    [agent, agentId, which]
  );

  // Reload on agent/tab change
  useEffect(() => {
    loadDir("/");
  }, [loadDir]);

  // Breadcrumb segments
  const segments =
    currentPath === "/" ? [] : currentPath.split("/").filter(Boolean);

  const sorted = useMemo(() => {
    const dirs = files.filter((f) => f.type === "directory");
    const rest = files.filter((f) => f.type !== "directory");
    return [...dirs, ...rest];
  }, [files]);

  return (
    <div className="w-[320px] bg-kumo-base border-l border-kumo-line shrink-0 flex flex-col h-full">
      {/* Header */}
      <div className="px-3 py-3 border-b border-kumo-line flex items-center justify-between">
        <div className="flex items-center gap-2">
          <FolderIcon size={16} className="text-kumo-brand" />
          <Text size="sm" bold>
            Workspace
          </Text>
        </div>
        <Button
          variant="ghost"
          size="sm"
          shape="square"
          aria-label="Close workspace"
          icon={<XIcon size={14} />}
          onClick={onClose}
        />
      </div>

      {/* Tabs */}
      <div className="flex border-b border-kumo-line">
        <button
          type="button"
          className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
            which === "private"
              ? "text-kumo-brand border-b-2 border-kumo-brand"
              : "text-kumo-subtle hover:text-kumo-default"
          }`}
          onClick={() => setWhich("private")}
        >
          Private
        </button>
        <button
          type="button"
          className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
            which === "shared"
              ? "text-kumo-brand border-b-2 border-kumo-brand"
              : "text-kumo-subtle hover:text-kumo-default"
          }`}
          onClick={() => setWhich("shared")}
        >
          Shared
        </button>
      </div>

      {/* Breadcrumbs */}
      <div className="px-3 py-2 border-b border-kumo-line flex items-center gap-1 text-xs overflow-x-auto">
        <button
          type="button"
          className="text-kumo-accent hover:underline shrink-0"
          onClick={() => loadDir("/")}
        >
          /
        </button>
        {segments.map((seg, i) => {
          const path = "/" + segments.slice(0, i + 1).join("/");
          const isLast = i === segments.length - 1;
          return (
            <span key={path} className="flex items-center gap-1 shrink-0">
              <CaretRightIcon size={10} className="text-kumo-inactive" />
              {isLast ? (
                <span className="text-kumo-default font-medium">{seg}</span>
              ) : (
                <button
                  type="button"
                  className="text-kumo-accent hover:underline"
                  onClick={() => loadDir(path)}
                >
                  {seg}
                </button>
              )}
            </span>
          );
        })}
      </div>

      {/* File list */}
      <div className="flex-1 overflow-y-auto">
        {loading ? (
          <div className="flex items-center justify-center py-8">
            <CircleNotchIcon
              size={16}
              className="animate-spin text-kumo-accent"
            />
          </div>
        ) : sorted.length === 0 ? (
          <div className="px-3 py-8 text-center">
            <Text size="xs" variant="secondary">
              Empty directory
            </Text>
          </div>
        ) : (
          <div className="py-1">
            {sorted.map((file) => (
              <button
                key={file.path}
                type="button"
                className={`w-full text-left px-3 py-1.5 flex items-center gap-2 hover:bg-kumo-tint/50 transition-colors ${
                  selectedFile?.path === file.path ? "bg-kumo-tint" : ""
                }`}
                onClick={() => {
                  if (file.type === "directory") {
                    loadDir(file.path);
                  } else {
                    loadFile(file.path);
                  }
                }}
              >
                {file.type === "directory" ? (
                  <FolderIcon
                    size={14}
                    weight="fill"
                    className="text-kumo-accent shrink-0"
                  />
                ) : (
                  <FileIcon size={14} className="text-kumo-inactive shrink-0" />
                )}
                <span className="text-xs text-kumo-default truncate flex-1">
                  {file.name}
                </span>
                {file.type === "file" && (
                  <span className="text-[10px] text-kumo-subtle shrink-0">
                    {formatFileSize(file.size)}
                  </span>
                )}
              </button>
            ))}
          </div>
        )}
      </div>

      {/* File viewer */}
      {selectedFile && (
        <div className="border-t border-kumo-line flex flex-col max-h-[40%]">
          <div className="px-3 py-2 border-b border-kumo-line flex items-center justify-between">
            <span className="text-xs font-medium text-kumo-default truncate">
              {selectedFile.path.split("/").pop()}
            </span>
            <Button
              variant="ghost"
              size="sm"
              shape="square"
              aria-label="Close file"
              icon={<XIcon size={12} />}
              onClick={() => setSelectedFile(null)}
            />
          </div>
          <div className="flex-1 overflow-auto">
            {fileLoading ? (
              <div className="flex items-center justify-center py-4">
                <CircleNotchIcon
                  size={14}
                  className="animate-spin text-kumo-accent"
                />
              </div>
            ) : (
              <pre className="px-3 py-2 text-xs font-mono text-kumo-secondary whitespace-pre-wrap break-all leading-relaxed">
                {selectedFile.content}
              </pre>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Messages (continued) ────────────────────────────────────────────────────

function formatToolOutput(output: unknown): string {
  if (typeof output === "string") return output;
  try {
    return JSON.stringify(output, null, 2);
  } catch {
    return String(output);
  }
}

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

function App() {
  const [connectionStatus, setConnectionStatus] =
    useState<ConnectionStatus>("connecting");
  const [agents, setAgents] = useState<AgentInfo[]>([]);
  const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
  const [input, setInput] = useState("");
  const [mcpState, setMcpState] = useState<MCPServersState>({
    prompts: [],
    resources: [],
    servers: {},
    tools: []
  });
  const [showMcpPanel, setShowMcpPanel] = useState(false);
  const [showWorkspace, setShowWorkspace] = useState(false);
  const [mcpName, setMcpName] = useState("");
  const [mcpUrl, setMcpUrl] = useState("");
  const [isAddingServer, setIsAddingServer] = useState(false);
  const mcpPanelRef = useRef<HTMLDivElement>(null);

  const setChatMessagesRef = useRef<((messages: UIMessage[]) => void) | null>(
    null
  );

  // Ref for handleSwitch so navigate handler can call it without stale closure
  const handleSwitchRef = useRef<(id: string) => Promise<void>>(undefined);

  // Track active delegation stream (server-initiated, not from useChat transport)
  const delegationRef = useRef<{
    requestId: string;
    baseMessages: UIMessage[];
    assistantMsg: UIMessage;
  } | null>(null);

  // Last messages received from server — used as base when a delegation stream starts
  const lastMessagesRef = useRef<UIMessage[]>([]);

  const handleServerMessage = useCallback((event: MessageEvent) => {
    if (typeof event.data !== "string") return;
    try {
      const msg = JSON.parse(event.data);
      if (msg.type === "messages") {
        setActiveAgentId(msg.agentId);
        setChatMessagesRef.current?.(msg.messages);
        lastMessagesRef.current = msg.messages;
        delegationRef.current = null;
      } else if (msg.type === "navigate") {
        handleSwitchRef.current?.(msg.agentId);
      } else if (msg.type === "stream-start" && msg.delegation) {
        delegationRef.current = {
          requestId: msg.requestId,
          baseMessages: lastMessagesRef.current,
          assistantMsg: {
            id: `deleg-${msg.requestId}`,
            role: "assistant",
            parts: []
          }
        };
      } else if (
        msg.type === "stream-event" &&
        delegationRef.current?.requestId === msg.requestId
      ) {
        const d = delegationRef.current;
        if (!d) return;
        const chunk = JSON.parse(msg.event);
        applyChunkToParts(d.assistantMsg.parts, chunk);
        setChatMessagesRef.current?.([
          ...d.baseMessages,
          { ...d.assistantMsg, parts: [...d.assistantMsg.parts] }
        ]);
      } else if (
        msg.type === "stream-done" &&
        delegationRef.current?.requestId === msg.requestId
      ) {
        if (msg.error && delegationRef.current) {
          const d = delegationRef.current;
          d.assistantMsg.parts.push({
            type: "text",
            text: `\n\nError: ${msg.error}`
          } as UIMessage["parts"][number]);
          setChatMessagesRef.current?.([
            ...d.baseMessages,
            { ...d.assistantMsg, parts: [...d.assistantMsg.parts] }
          ]);
        }
        delegationRef.current = null;
      }
    } catch (err) {
      console.error(`[CLIENT] handleServerMessage error`, err);
    }
  }, []);

  const agent = useAgent<AppState>({
    agent: "MyAssistant",
    onOpen: useCallback(() => setConnectionStatus("connected"), []),
    onClose: useCallback(() => setConnectionStatus("disconnected"), []),
    onError: useCallback(
      (error: Event) => console.error("WebSocket error:", error),
      []
    ),
    onStateUpdate: useCallback(
      (state: AppState) => setAgents(state.agents),
      []
    ),
    onMessage: handleServerMessage,
    onMcpUpdate: useCallback((state: MCPServersState) => {
      setMcpState(state);
    }, [])
  });

  // Auto-select orchestrator on first connect
  useEffect(() => {
    if (connectionStatus === "connected" && !activeAgentId) {
      agent.call("switchAgent", [ORCHESTRATOR_ID]).catch(() => {});
    }
  }, [connectionStatus, activeAgentId, agent]);

  // Close MCP panel when clicking outside
  useEffect(() => {
    if (!showMcpPanel) return;
    function handleClickOutside(e: MouseEvent) {
      if (
        mcpPanelRef.current &&
        !mcpPanelRef.current.contains(e.target as Node)
      ) {
        setShowMcpPanel(false);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, [showMcpPanel]);

  const handleAddServer = useCallback(async () => {
    if (!mcpName.trim() || !mcpUrl.trim()) return;
    setIsAddingServer(true);
    try {
      await agent.call("addServer", [
        mcpName.trim(),
        mcpUrl.trim(),
        window.location.origin
      ]);
      setMcpName("");
      setMcpUrl("");
    } catch (e) {
      console.error("Failed to add MCP server:", e);
    } finally {
      setIsAddingServer(false);
    }
  }, [agent, mcpName, mcpUrl]);

  const handleRemoveServer = useCallback(
    async (serverId: string) => {
      try {
        await agent.call("removeServer", [serverId]);
      } catch (e) {
        console.error("Failed to remove MCP server:", e);
      }
    },
    [agent]
  );

  const serverEntries = Object.entries(mcpState.servers);
  const mcpToolCount = mcpState.tools.length;

  const transport = useMemo(() => new AgentChatTransport(agent), [agent]);

  const {
    messages,
    setMessages: setChatMessages,
    sendMessage,
    resumeStream,
    status
  } = useChat({ transport });

  setChatMessagesRef.current = setChatMessages;

  const isConnected = connectionStatus === "connected";
  const isBusy = status === "submitted" || status === "streaming";

  const handleDelete = useCallback(
    async (id: string) => {
      transport.detach();
      await agent.call("deleteAgent", [id]);
      if (activeAgentId === id) {
        setActiveAgentId(ORCHESTRATOR_ID);
        setChatMessages([]);
      }
    },
    [agent, activeAgentId, setChatMessages, transport]
  );

  const handleClear = useCallback(
    async (id: string) => agent.call("clearAgent", [id]),
    [agent]
  );

  const handleRename = useCallback(
    async (id: string, name: string) => agent.call("renameAgent", [id, name]),
    [agent]
  );

  const handleSwitch = useCallback(
    async (id: string) => {
      transport.detach();
      await agent.call("switchAgent", [id]);
      // Skip resumeStream when viewing a delegation stream — resumeStream
      // finds no user-initiated stream and resets useChat messages, wiping
      // the delegation text we're already displaying.
      if (!delegationRef.current) {
        resumeStream();
      }
    },
    [agent, transport, resumeStream]
  );

  handleSwitchRef.current = handleSwitch;

  const send = useCallback(() => {
    const text = input.trim();
    if (!text || isBusy || !activeAgentId) return;
    setInput("");
    sendMessage({ text });
  }, [input, isBusy, activeAgentId, sendMessage]);

  const activeAgent = agents.find((a) => a.id === activeAgentId);
  const isOrchestrator = activeAgentId === ORCHESTRATOR_ID;

  return (
    <div className="flex h-screen bg-kumo-elevated">
      {/* Left: Agent sidebar */}
      <div className="w-[260px] bg-kumo-base border-r border-kumo-line shrink-0">
        <AgentSidebar
          agents={agents}
          activeAgentId={activeAgentId}
          onSwitch={handleSwitch}
          onDelete={handleDelete}
          onClear={handleClear}
          onRename={handleRename}
        />
      </div>

      {/* Main: Chat */}
      <div className="flex flex-col flex-1 min-w-0">
        <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">
              {activeAgent ? (
                <>
                  {isOrchestrator ? (
                    <RobotIcon size={20} className="text-kumo-brand" />
                  ) : (
                    <ChatTextIcon size={20} className="text-kumo-brand" />
                  )}
                  <Text size="lg" bold>
                    {activeAgent.name}
                  </Text>
                  {activeAgent.messageCount > 0 && (
                    <Badge variant="secondary">
                      {activeAgent.messageCount} messages
                    </Badge>
                  )}
                  {!isOrchestrator && (
                    <>
                      <Badge variant="secondary">
                        {activeAgent.config.modelTier}
                      </Badge>
                      <Badge variant="secondary">
                        <FolderIcon size={12} weight="bold" className="mr-1" />
                        {activeAgent.config.toolAccess}
                      </Badge>
                    </>
                  )}
                  {!isOrchestrator && (
                    <Button
                      variant="ghost"
                      size="sm"
                      icon={<ArrowLeftIcon size={14} />}
                      onClick={() => handleSwitch(ORCHESTRATOR_ID)}
                    >
                      Back
                    </Button>
                  )}
                </>
              ) : (
                <Text size="lg" bold variant="secondary">
                  Connecting...
                </Text>
              )}
            </div>
            <div className="flex items-center gap-3">
              <ConnectionIndicator status={connectionStatus} />
              <ModeToggle />
              <div className="relative" ref={mcpPanelRef}>
                <Button
                  variant="secondary"
                  size="sm"
                  icon={<PlugsConnectedIcon size={14} />}
                  onClick={() => setShowMcpPanel(!showMcpPanel)}
                >
                  MCP
                  {mcpToolCount > 0 && (
                    <Badge variant="primary" className="ml-1.5">
                      <WrenchIcon size={10} className="mr-0.5" />
                      {mcpToolCount}
                    </Badge>
                  )}
                </Button>

                {showMcpPanel && (
                  <div className="absolute right-0 top-full mt-2 w-96 z-50">
                    <Surface className="rounded-xl ring ring-kumo-line shadow-lg p-4 space-y-4">
                      <div className="flex items-center justify-between">
                        <div className="flex items-center gap-2">
                          <PlugsConnectedIcon
                            size={16}
                            className="text-kumo-accent"
                          />
                          <Text size="sm" bold>
                            MCP Servers
                          </Text>
                          {serverEntries.length > 0 && (
                            <Badge variant="secondary">
                              {serverEntries.length}
                            </Badge>
                          )}
                        </div>
                        <Button
                          variant="ghost"
                          size="sm"
                          shape="square"
                          aria-label="Close MCP panel"
                          icon={<XIcon size={14} />}
                          onClick={() => setShowMcpPanel(false)}
                        />
                      </div>

                      <form
                        onSubmit={(e) => {
                          e.preventDefault();
                          handleAddServer();
                        }}
                        className="space-y-2"
                      >
                        <input
                          type="text"
                          value={mcpName}
                          onChange={(e) => setMcpName(e.target.value)}
                          placeholder="Server name"
                          className="w-full px-3 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive focus:outline-none focus:ring-1 focus:ring-kumo-accent"
                        />
                        <div className="flex gap-2">
                          <input
                            type="text"
                            value={mcpUrl}
                            onChange={(e) => setMcpUrl(e.target.value)}
                            placeholder="https://mcp.example.com"
                            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 focus:outline-none focus:ring-1 focus:ring-kumo-accent font-mono"
                          />
                          <Button
                            type="submit"
                            variant="primary"
                            size="sm"
                            disabled={
                              isAddingServer ||
                              !mcpName.trim() ||
                              !mcpUrl.trim()
                            }
                          >
                            {isAddingServer ? "..." : "Add"}
                          </Button>
                        </div>
                      </form>

                      {serverEntries.length > 0 && (
                        <div className="space-y-2 max-h-60 overflow-y-auto">
                          {serverEntries.map(([id, server]) => (
                            <div
                              key={id}
                              className="flex items-start justify-between p-2.5 rounded-lg border border-kumo-line"
                            >
                              <div className="min-w-0 flex-1">
                                <div className="flex items-center gap-2">
                                  <span className="text-sm font-medium text-kumo-default truncate">
                                    {server.name}
                                  </span>
                                  <Badge
                                    variant={
                                      server.state === "ready"
                                        ? "primary"
                                        : server.state === "failed"
                                          ? "destructive"
                                          : "secondary"
                                    }
                                  >
                                    {server.state}
                                  </Badge>
                                </div>
                                <span className="text-xs font-mono text-kumo-subtle truncate block mt-0.5">
                                  {server.server_url}
                                </span>
                                {server.state === "failed" && server.error && (
                                  <span className="text-xs text-red-500 block mt-0.5">
                                    {server.error}
                                  </span>
                                )}
                              </div>
                              <div className="flex items-center gap-1 shrink-0 ml-2">
                                {server.state === "authenticating" &&
                                  server.auth_url && (
                                    <Button
                                      variant="primary"
                                      size="sm"
                                      icon={<SignInIcon size={12} />}
                                      onClick={() =>
                                        window.open(
                                          server.auth_url as string,
                                          "oauth",
                                          "width=600,height=800"
                                        )
                                      }
                                    >
                                      Auth
                                    </Button>
                                  )}
                                <Button
                                  variant="ghost"
                                  size="sm"
                                  shape="square"
                                  aria-label="Remove server"
                                  icon={<TrashIcon size={12} />}
                                  onClick={() => handleRemoveServer(id)}
                                />
                              </div>
                            </div>
                          ))}
                        </div>
                      )}

                      {mcpToolCount > 0 && (
                        <div className="pt-2 border-t border-kumo-line">
                          <div className="flex items-center gap-2">
                            <WrenchIcon
                              size={14}
                              className="text-kumo-subtle"
                            />
                            <span className="text-xs text-kumo-subtle">
                              {mcpToolCount} tool
                              {mcpToolCount !== 1 ? "s" : ""} available from MCP
                              servers
                            </span>
                          </div>
                        </div>
                      )}
                    </Surface>
                  </div>
                )}
              </div>
              {activeAgent && (
                <Button
                  variant={showWorkspace ? "primary" : "secondary"}
                  size="sm"
                  icon={<FolderIcon size={14} />}
                  onClick={() => setShowWorkspace(!showWorkspace)}
                >
                  Files
                </Button>
              )}
              {activeAgent && (
                <Button
                  variant="secondary"
                  size="sm"
                  icon={<BroomIcon size={14} />}
                  onClick={() => handleClear(activeAgent.id)}
                >
                  Clear
                </Button>
              )}
            </div>
          </div>
        </header>

        <div className="flex-1 overflow-y-auto">
          <div className="max-w-3xl mx-auto px-5 py-6">
            {activeAgentId ? (
              <Messages
                messages={messages}
                status={status}
                onNavigate={handleSwitch}
                activeAgent={activeAgent}
              />
            ) : (
              <Empty
                icon={<RobotIcon size={32} />}
                title="Connecting..."
                description="Waiting for connection to the orchestrator"
              />
            )}
          </div>
        </div>

        <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={
                  activeAgentId
                    ? isOrchestrator
                      ? "Ask me anything, or tell me to spawn a specialist agent..."
                      : "Chat with this agent..."
                    : "Connecting..."
                }
                disabled={!isConnected || isBusy || !activeAgentId}
                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 || isBusy || !activeAgentId
                }
                icon={<PaperPlaneRightIcon size={18} />}
                loading={isBusy}
                className="mb-0.5"
              />
            </div>
          </form>
          <div className="flex justify-center pb-3">
            <PoweredByAgents />
          </div>
        </div>
      </div>

      {/* Right: Workspace panel */}
      {showWorkspace && activeAgentId && (
        <WorkspacePanel
          agent={agent}
          agentId={activeAgentId}
          onClose={() => setShowWorkspace(false)}
        />
      )}
    </div>
  );
}

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

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