branch:
ChatRoomsDemo.tsx
13824 bytesRaw
import { useAgent } from "agents/react";
import { nanoid } from "nanoid";
import { useState, useEffect, useRef, useCallback } from "react";
import { Button, Input, Surface, Empty, Badge, Text } from "@cloudflare/kumo";
import { DemoWrapper } from "../../layout";
import {
  LogPanel,
  ConnectionStatus,
  CodeExplanation,
  type CodeSection
} from "../../components";
import { useLogs } from "../../hooks";
import type { LobbyAgent, LobbyState, RoomInfo } from "./lobby-agent";
import type { RoomAgent, RoomState, ChatMessage } from "./room-agent";

const codeSections: CodeSection[] = [
  {
    title: "Lobby + Room agent architecture",
    description:
      "A single LobbyAgent tracks all rooms. Each room is a separate RoomAgent instance. The lobby creates rooms via getAgentByName and the room handles its own members and messages.",
    code: `// lobby-agent.ts
class LobbyAgent extends Agent<Env> {
  @callable()
  async createRoom(roomId: string) {
    const room = await getAgentByName(this.env.RoomAgent, roomId);
    await room.initialize({ name: roomId });
    this.setState({
      ...this.state,
      rooms: [...this.state.rooms, roomId],
    });
  }
}

// room-agent.ts
class RoomAgent extends Agent<Env> {
  @callable()
  sendMessage(userId: string, text: string) {
    const msg = { id: crypto.randomUUID(), userId, text };
    this.broadcast(JSON.stringify({ type: "chat_message", message: msg }));
  }
}`
  },
  {
    title: "Connect to multiple agents from one page",
    description:
      "Use multiple useAgent hooks — one for the lobby, one for the current room. Set enabled: false to defer the connection until a room is selected.",
    code: `const lobby = useAgent({ agent: "lobby-agent", name: "main" });

const room = useAgent({
  agent: "room-agent",
  name: currentRoom || "unused",
  enabled: !!currentRoom, // only connect when a room is selected
});`
  }
];

export function ChatRoomsDemo() {
  const { logs, addLog, clearLogs } = useLogs();
  const [username, setUsername] = useState(() => `user-${nanoid(4)}`);
  const [rooms, setRooms] = useState<RoomInfo[]>([]);
  const [currentRoom, setCurrentRoom] = useState<string | null>(null);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [members, setMembers] = useState<string[]>([]);
  const [newMessage, setNewMessage] = useState("");
  const [newRoomName, setNewRoomName] = useState("");
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const lobby = useAgent<LobbyAgent, LobbyState>({
    agent: "lobby-agent",
    name: "main",
    onOpen: () => {
      addLog("info", "lobby_connected");
      refreshRooms();
    },
    onClose: () => addLog("info", "lobby_disconnected"),
    onError: () => addLog("error", "error", "Lobby connection error"),
    onMessage: (message) => {
      try {
        const data = JSON.parse(message.data as string);
        if (
          data.type === "room_created" ||
          data.type === "room_updated" ||
          data.type === "room_deleted"
        ) {
          refreshRooms();
        }
      } catch {
        // ignore
      }
    }
  });

  const room = useAgent<RoomAgent, RoomState>({
    agent: "room-agent",
    name: currentRoom || "unused",
    enabled: !!currentRoom,
    onOpen: async () => {
      if (currentRoom) {
        addLog("info", "room_connected", currentRoom);
        await joinRoom();
      }
    },
    onClose: () => {
      if (currentRoom) {
        addLog("info", "room_disconnected");
      }
    },
    onError: () => addLog("error", "error", "Room connection error"),
    onMessage: (message) => {
      try {
        const data = JSON.parse(message.data as string);
        handleRoomEvent(data);
      } catch {
        // ignore
      }
    }
  });

  const handleRoomEvent = (data: {
    type: string;
    userId?: string;
    message?: ChatMessage;
    memberCount?: number;
  }) => {
    if (data.type === "member_joined") {
      addLog("in", "member_joined", data.userId);
      refreshMembers();
    } else if (data.type === "member_left") {
      addLog("in", "member_left", data.userId);
      refreshMembers();
    } else if (data.type === "chat_message" && data.message) {
      addLog("in", "chat_message", data.message);
      setMessages((prev) => [...prev, data.message as ChatMessage]);
    }
  };

  const refreshRooms = useCallback(async () => {
    try {
      const result = await lobby.call("listRooms");
      setRooms(result);
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  }, [lobby, addLog]);

  const refreshMembers = async () => {
    if (!currentRoom) return;
    try {
      const result = await room.call("getMembers");
      setMembers(result);
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const joinRoom = async () => {
    try {
      await room.call("join", [username]);
      const msgs = await room.call("getMessages", [50]);
      setMessages(msgs);
      await refreshMembers();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const handleCreateRoom = async () => {
    const roomId = newRoomName.trim() || `room-${nanoid(4)}`;
    addLog("out", "call", `createRoom("${roomId}")`);
    try {
      await lobby.call("createRoom", [roomId]);
      setNewRoomName("");
      await refreshRooms();
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const handleJoinRoom = async (roomId: string) => {
    if (currentRoom) {
      try {
        await room.call("leave", [username]);
      } catch {
        // ignore
      }
    }
    setCurrentRoom(roomId);
    setMessages([]);
    setMembers([]);
    addLog("out", "join_room", roomId);
  };

  const handleLeaveRoom = async () => {
    if (currentRoom) {
      try {
        await room.call("leave", [username]);
      } catch {
        // ignore
      }
      setCurrentRoom(null);
      setMessages([]);
      setMembers([]);
      addLog("out", "leave_room");
    }
  };

  const handleSendMessage = async () => {
    if (!newMessage.trim() || !currentRoom) return;
    addLog("out", "send", newMessage);
    try {
      await room.call("sendMessage", [username, newMessage]);
      setNewMessage("");
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

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

  useEffect(() => {
    if (lobby.readyState === WebSocket.OPEN) {
      refreshRooms();
    }
  }, [lobby.readyState, refreshRooms]);

  return (
    <DemoWrapper
      title="Chat Rooms"
      description={
        <>
          A two-tier agent architecture: a single{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            LobbyAgent
          </code>{" "}
          tracks all rooms, while each room is a separate{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            RoomAgent
          </code>{" "}
          instance handling its own members and messages. The client uses two{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            useAgent
          </code>{" "}
          hooks simultaneously — one for the lobby, one for the active room.
          Create a room and start chatting.
        </>
      }
      statusIndicator={
        <ConnectionStatus
          status={
            lobby.readyState === WebSocket.OPEN ? "connected" : "connecting"
          }
        />
      }
    >
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* Lobby & Room List */}
        <div className="space-y-6">
          {/* Username */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <Input
              label="Your Username"
              type="text"
              value={username}
              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                setUsername(e.target.value)
              }
              className="w-full"
              placeholder="Enter username"
            />
          </Surface>

          {/* Lobby Connection */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            {/* Create Room */}
            <div className="flex gap-2 mb-4">
              <Input
                aria-label="Room name"
                type="text"
                value={newRoomName}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setNewRoomName(e.target.value)
                }
                className="flex-1"
                placeholder="Room name (optional)"
              />
              <Button variant="primary" onClick={handleCreateRoom}>
                Create
              </Button>
            </div>

            {/* Room List */}
            <div className="space-y-2">
              {rooms.length > 0 ? (
                rooms.map((r) => (
                  <button
                    key={r.roomId}
                    type="button"
                    onClick={() => handleJoinRoom(r.roomId)}
                    className={`w-full text-left px-3 py-2 rounded border transition-colors ${
                      currentRoom === r.roomId
                        ? "border-kumo-brand bg-kumo-elevated"
                        : "border-kumo-line hover:border-kumo-interact"
                    }`}
                  >
                    <div className="flex items-center justify-between">
                      <span className="font-medium text-kumo-default">
                        {r.roomId}
                      </span>
                      <span className="text-xs text-kumo-subtle">
                        {r.memberCount} online
                      </span>
                    </div>
                  </button>
                ))
              ) : (
                <Empty title="No rooms yet. Create one!" size="sm" />
              )}
            </div>
          </Surface>
        </div>

        {/* Chat Area */}
        <div className="lg:col-span-1 space-y-6">
          <Surface className="p-4 h-[500px] flex flex-col rounded-lg ring ring-kumo-line">
            {currentRoom ? (
              <>
                {/* Room Header */}
                <div className="flex items-center justify-between mb-4 pb-3 border-b border-kumo-line">
                  <div>
                    <Text variant="heading3">{currentRoom}</Text>
                    <span className="text-xs text-kumo-subtle">
                      {members.length} members
                    </span>
                  </div>
                  <Button
                    variant="secondary"
                    size="sm"
                    onClick={handleLeaveRoom}
                  >
                    Leave
                  </Button>
                </div>

                {/* Messages */}
                <div className="flex-1 overflow-y-auto space-y-2 mb-4">
                  {messages.length > 0 ? (
                    messages.map((msg) => (
                      <div
                        key={msg.id}
                        className={`p-2 rounded ${
                          msg.userId === username
                            ? "bg-kumo-contrast text-kumo-inverse ml-8"
                            : "bg-kumo-control text-kumo-default mr-8"
                        }`}
                      >
                        <div className="text-xs opacity-70 mb-1">
                          {msg.userId}
                        </div>
                        <div className="text-sm">{msg.text}</div>
                      </div>
                    ))
                  ) : (
                    <Empty title="No messages yet" size="sm" />
                  )}
                  <div ref={messagesEndRef} />
                </div>

                {/* Input */}
                <div className="flex gap-2">
                  <Input
                    aria-label="Chat message"
                    type="text"
                    value={newMessage}
                    onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                      setNewMessage(e.target.value)
                    }
                    onKeyDown={(e: React.KeyboardEvent) =>
                      e.key === "Enter" && handleSendMessage()
                    }
                    className="flex-1"
                    placeholder="Type a message..."
                  />
                  <Button variant="primary" onClick={handleSendMessage}>
                    Send
                  </Button>
                </div>
              </>
            ) : (
              <div className="flex-1 flex items-center justify-center text-kumo-inactive">
                Select a room to start chatting
              </div>
            )}
          </Surface>

          {/* Members */}
          {currentRoom && members.length > 0 && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-2">
                <Text variant="heading3">Members</Text>
              </div>
              <div className="flex flex-wrap gap-2">
                {members.map((member) => (
                  <Badge
                    key={member}
                    variant={member === username ? "primary" : "outline"}
                  >
                    {member}
                  </Badge>
                ))}
              </div>
            </Surface>
          )}
        </div>

        {/* Logs */}
        <div className="space-y-6">
          <LogPanel logs={logs} onClear={clearLogs} maxHeight="600px" />
        </div>
      </div>

      <CodeExplanation sections={codeSections} />
    </DemoWrapper>
  );
}