/** * The chat pane. * * Drives the agent, and shows what it is doing. The composer stays live while * a turn runs — a turn owns the plan, not the conversation — and anything sent * to the agent meanwhile is queued in order, with its author's name on it, so * nobody is silenced because a colleague prompted first. * * The agent only acts when addressed, so the composer says where a message is * going before it goes. The failure worth designing out is typing into what * looks like a prompt and being met with silence. */ import { useEffect, useRef, useState } from "react"; import { addressed, MENTION } from "@chopin/protocol/address"; import { Transcript } from "./transcript"; import type { Chat as Wire } from "@chopin/protocol"; import type { Wire as Socket } from "../wire"; export type ChatProps = { wire: Socket | undefined; handle: string; connected: boolean; /** Brings a questionnaire into view in the decisions pane. */ onReveal?: (widget: string) => void; /** Open questions, so the pane can point at the one that is blocking. */ waiting?: number; }; function Queued( { handle, onWithdraw, waiting }: { handle: string; waiting: Wire.Waiting[]; onWithdraw: (id: string) => void; }, ) { if (waiting.length === 0) return null; return (
Queued {waiting.map(item => (
@{item.handle} {item.text} {item.handle === handle && ( )}
))}
); } export function Chat({ connected, handle, onReveal, waiting, wire }: ChatProps) { let [entries, setEntries] = useState([]); let [arrived, setArrived] = useState>(new Set()); let [queue, setQueue] = useState([]); let [busy, setBusy] = useState(false); let [turn, setTurn] = useState(); let [text, setText] = useState(""); let input = useRef(null); useEffect(() => { if (!wire) return; // History seeds `seen`; only later message frames are arrivals. let loaded = false; let seen = new Set(); // Streaming arrives as deltas against an entry already in the list, so // the reducer here has to be additive rather than replacing. let off = [ wire.on("chat:history", frame => { loaded = true; seen = new Set(frame.entries.map(entry => entry.id)); setEntries(frame.entries); setArrived(new Set()); setQueue(frame.queued); setBusy(frame.busy); }), wire.on("chat:message", frame => { if (loaded && !seen.has(frame.entry.id)) { setArrived(current => new Set(current).add(frame.entry.id)); } seen.add(frame.entry.id); setEntries(current => { let index = current.findIndex(entry => entry.id === frame.entry.id); if (index < 0) return [...current, frame.entry]; let next = [...current]; next[index] = frame.entry; return next; }); }), wire.on("chat:delta", frame => { setEntries(current => current.map(entry => entry.id === frame.id ? { ...entry, text: entry.text + frame.text } : entry ) ); }), wire.on("chat:tool", frame => { setEntries(current => { let index = current.findIndex(entry => entry.id === frame.entry); let target = index < 0 ? current.length - 1 : index; if (target < 0) return current; let next = [...current]; let entry = next[target]!; let tools = entry.tools ?? []; let existing = tools.findIndex(item => item.id === frame.activity.id); next[target] = { ...entry, tools: existing < 0 ? [...tools, frame.activity] : tools.map((item, at) => at === existing ? { ...item, ...frame.activity } : item), }; return next; }); }), wire.on("chat:state", frame => { setBusy(frame.busy); setTurn(frame.turn); }), wire.on("chat:queue", frame => setQueue(frame.waiting)), ]; return () => { for (let unsubscribe of off) unsubscribe(); }; }, [wire]); let submit = () => { let value = text.trim(); if (!value || !wire || !connected) return; wire.send("chat:send", { text: value }); setText(""); }; let asking = addressed(text); return (
Planner {busy && ( working{turn && ` on @${turn}'s message`} )} {busy && ( )}
{!!waiting && waiting > 0 && ( )} wire?.send("chat:unqueue", { id })} waiting={queue} />