/** * 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, useState } from "react"; import { addressed } 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; }; 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 [text, setText] = useState(""); 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); if (index < 0) return current; let next = [...current]; let entry = next[index]!; let tools = entry.tools ?? []; let existing = tools.findIndex(item => item.id === frame.activity.id); next[index] = { ...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); }), wire.on("chat:queue", frame => setQueue(frame.waiting)), ]; return () => { for (let unsubscribe of off) unsubscribe(); }; }, [wire]); let submit = (to: Wire.Destination) => { let value = text.trim(); if (!value || !wire || !connected) return; wire.send("chat:send", { text: value, to }); setText(""); }; return (
wire?.send("chat:unqueue", { id })} queued={queue} /> {!!waiting && waiting > 0 && (
{waiting === 1 ? "A question is waiting" : `${waiting} questions are waiting`}
)}