/** * 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 chat — 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 one Send action can follow the * message's own signal rather than asking its author to select a destination. */ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { SendAction } from "@chopin/editor"; import { MENTION } from "@chopin/protocol/address"; import { referenceOptionId, ReferencePicker, referencePickerKeyAction, useReferencePicker, } from "./reference-picker"; import { acknowledgeDraft, beforeInputSelection, boundedChatError, chatSendPayload, insertReference, MAX_REFERENCES, prepareDraftSubmission, reconcileReferenceDrafts, referenceTrigger, referenceTriggerKey, reviseComposerDraft, } from "./references"; import { Transcript } from "./transcript"; import { TerminalAlert } from "../terminal-alert"; import plannerStop from "../assets/icons/planner-stop.svg"; import type { Chat as Wire } from "@chopin/protocol"; import type { Repository } from "../api"; import type { ComposerDraft, ReferenceTarget } from "./references"; import type { Wire as Socket } from "../wire"; export type ChatProps = { wire: Socket | undefined; handle: string; connected: boolean; referencesEnabled: boolean; repository: Pick; room: string; sendAcknowledgements: boolean; /** Hosted mode keeps the shared chat while repository-scoped agent work is disabled. */ agent?: boolean; active?: boolean; onActivity?: (event: { type: "message" | "working"; busy: boolean }) => void; }; export function Chat( { active = true, agent = true, connected, handle, onActivity, referencesEnabled, repository, room, sendAcknowledgements, wire, }: ChatProps, ) { let [entries, setEntries] = useState([]); let [queue, setQueue] = useState([]); let [busy, setBusy] = useState(false); let [turn, setTurn] = useState(); let [draft, setDraft] = useState({ text: "", references: [], }); let [submitting, setSubmitting] = useState(false); let [sendError, setSendError] = useState(); let [selection, setSelection] = useState({ start: 0, end: 0 }); let [dismissedPicker, setDismissedPicker] = useState(); let textarea = useRef(null); let pendingCaret = useRef(undefined); let pendingEdit = useRef<{ start: number; end: number } | undefined>(undefined); let submission = useRef(undefined); let draftRef = useRef(draft); draftRef.current = draft; let pickerId = useId(); let instructionsId = useId(); let synchronized = useRef(undefined); let activity = useRef(onActivity); let reportedBusy = useRef(false); activity.current = onActivity; // A socket opens before its fresh transcript arrives, and reconnects reuse // the same Wire. Only that transcript makes this composer current. if (!connected) synchronized.current = undefined; let composerReady = connected && synchronized.current === wire; let detected = referencesEnabled && composerReady && !submitting ? referenceTrigger(draft.text, selection.start, selection.end) : undefined; let trigger = detected && !draft.references.some(reference => reference.start < detected.end && reference.end > detected.start ) ? detected : undefined; let triggerKey = trigger ? referenceTriggerKey(trigger) : undefined; let pickerOpen = !submitting && trigger !== undefined && triggerKey !== dismissedPicker; let atReferenceLimit = draft.references.length >= MAX_REFERENCES; let picker = useReferencePicker( pickerOpen && !atReferenceLimit && referencesEnabled ? trigger : undefined, repository, room, ); let activeOption = picker.options.length === 0 ? undefined : picker.options[Math.min(picker.active, picker.options.length - 1)]; useEffect(() => { if (!wire) return; // History seeds `seen`; only later message frames are arrivals. let loaded = false; let seen = new Set(); let response = (agent: boolean, value: string) => { if (!agent || !value.trim()) return; setTurn(current => current && !current.responded ? { ...current, responded: true } : current); }; // 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; synchronized.current = wire; seen = new Set(frame.entries.map(entry => entry.id)); setEntries(frame.entries); setQueue(frame.queued); setBusy(frame.busy); reportedBusy.current = frame.busy; setTurn(frame.turn); // History is not unread, but a turn already in progress still needs // a signal outside a closed Chat destination. activity.current?.({ type: "working", busy: frame.busy }); }), wire.on("chat:message", frame => { if (loaded && !seen.has(frame.entry.id)) { activity.current?.({ type: "message", busy: reportedBusy.current }); } 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; }); response(frame.entry.author.kind === "agent", frame.entry.text); }), wire.on("chat:delta", frame => { setEntries(current => current.map(entry => entry.id === frame.id ? { ...entry, text: entry.text + frame.text } : entry ) ); response(true, frame.text); }), 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 => { if (loaded && reportedBusy.current !== frame.busy) { activity.current?.({ type: "working", busy: frame.busy }); } reportedBusy.current = frame.busy; setBusy(frame.busy); setTurn(frame.turn); }), wire.on("chat:queue", frame => setQueue(frame.waiting)), ]; return () => { for (let unsubscribe of off) unsubscribe(); }; }, [wire]); useLayoutEffect(() => { if (pendingCaret.current === undefined) return; let caret = pendingCaret.current; pendingCaret.current = undefined; textarea.current?.focus(); textarea.current?.setSelectionRange(caret, caret); }); let restoreComposerFocus = () => { requestAnimationFrame(() => textarea.current?.focus()); }; let clearSubmittedDraft = (submitted: ComposerDraft) => { if (draftRef.current !== submitted) return; let cleared = acknowledgeDraft(submitted, submitted); draftRef.current = cleared; setDraft(cleared); setSelection({ start: 0, end: 0 }); setDismissedPicker(undefined); }; let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; if (!current.text.trim()) return; let submitted = prepareDraftSubmission(current); let payload = chatSendPayload( submitted.text, submitted.references, agent, submitted.requestId, referencesEnabled, ); if (!payload) return; let token = {}; submission.current = token; draftRef.current = submitted; setDraft(submitted); setSubmitting(true); setSendError(undefined); if (!sendAcknowledgements) { wire.send("chat:send", payload); clearSubmittedDraft(submitted); submission.current = undefined; setSubmitting(false); restoreComposerFocus(); return; } void wire.ask("chat:send", payload).then(() => { clearSubmittedDraft(submitted); }, error => { setSendError(boundedChatError(error)); }).finally(() => { if (submission.current !== token) return; submission.current = undefined; setSubmitting(false); restoreComposerFocus(); }); }; let chooseReference = (target: ReferenceTarget) => { if (!trigger) return; let next = insertReference(draft.text, draft.references, trigger, target); setDraft(current => reviseComposerDraft(current, next.text, next.references)); setSelection({ start: next.caret, end: next.caret }); setSendError(undefined); setDismissedPicker(undefined); pendingEdit.current = undefined; pendingCaret.current = next.caret; }; return (
wire?.send("chat:unqueue", { id })} queued={queue} working={connected && synchronized.current === wire && turn ? turn : undefined} />
{pickerOpen && trigger && ( )} {referencesEnabled && (

Type # to reference a document.

)}