/** * 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. */ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { SendAction } from "@chopin/editor"; import { MENTION } from "@chopin/protocol/address"; import { ArchiveIcon, InfoIcon, LoaderIcon, LockIcon, PlusIcon, WarningIcon } from "@chopin/icons"; import { DraftInput } from "./draft-input"; import type { DraftInputHandle } from "./draft-input"; import { ModeSwitch } from "./mode-switch"; import "./composer.css"; import { MentionPicker } from "./mention-picker"; import { chatAuthors, filterMentions, insertMention, mentionCandidates, mentionKeyAction, mentionTrigger, mentionTriggerKey, } from "./mentions"; import { referenceOptionId, ReferencePicker, referencePickerKeyAction, useReferencePicker, } from "./reference-picker"; import { acknowledgeDraft, addressedOutsideReferences, boundedChatError, chatSendPayload, insertReference, MAX_REFERENCES, prepareDraftSubmission, reconcileReferenceDrafts, referenceTrigger, referenceTriggerKey, reviseComposerDraft, } from "./references"; import { RunStack } from "./run-card"; import { Transcript } from "./transcript"; import type { TranscriptDecisions } from "./transcript"; import type { CardLink } from "../conversation-plan/links"; import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-overview"; import type { ChatDestination } from "../conversation-plan/source"; import type { ResearchOfferControls } from "./research-offer"; import plannerStop from "../assets/icons/planner-stop.svg"; import plannerResume from "../assets/icons/planner-resume.svg"; import type { Chat as Wire, ConversationPlan } from "@chopin/protocol"; import type { Repository } from "../api"; import type { MentionCandidate } from "./mentions"; import type { ComposerDraft, ReferenceTarget } from "./references"; import type { Wire as Socket } from "../wire"; export type ChatProps = { wire: Socket | undefined; handle: string; connected: boolean; readonly?: boolean; archived?: boolean; referencesEnabled: boolean; repository: Pick; room: string; sendAcknowledgements: boolean; /** People connected to this document, for `@` suggestions. */ people?: readonly string[]; /** 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; conversationPlan?: ConversationPlan.State; conversationPlanJobs?: ConversationPlan.Job[]; onCardLink?: (link: CardLink) => void; onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; onRetryAnalysis?: ( messageId: string, actionId: string, lane?: "decision" | "research", ) => Promise; onRetryJob?: (jobId: string) => Promise; decisions?: TranscriptDecisions; researchOffers?: ResearchOfferControls; sourceDestination?: ChatDestination; /** Opens Decisions, where a waiting workflow's questions are. */ onShowDecisions?: () => void; }; /** How many runs are still live, and how many are paused and resumable. */ export function runCounts(runs: Wire.Runs | undefined): { active: number; paused: number } { let list = runs ?? []; return { active: list.filter(run => run.status === "running" || run.status === "waiting").length, paused: list.filter(run => run.status === "paused").length, }; } export function Chat( { active = true, agent = true, readonly = false, archived = false, connected, handle, onActivity, conversationPlan, conversationPlanJobs, onCardLink, onAddExcerpt, onRetryAnalysis, onRetryJob, decisions, researchOffers, sourceDestination, people = [], onShowDecisions, referencesEnabled, repository, room, sendAcknowledgements, wire, }: ChatProps, ) { let [entries, setEntries] = useState([]); let [queue, setQueue] = useState([]); let [busy, setBusy] = useState(false); let [turn, setTurn] = useState(); let [runs, setRuns] = useState(); let counts = runCounts(runs); 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 [mentionCursor, setMentionCursor] = useState<{ key?: string; index: number }>({ index: 0 }); let textarea = useRef(null); let [mode, setMode] = useState(false); let [historyKey, setHistoryKey] = useState(0); let pendingCaret = useRef(undefined); let submission = useRef(undefined); let draftRef = useRef(draft); draftRef.current = draft; let pickerId = useId(); let mentionPickerId = useId(); let instructionsId = useId(); let cueId = 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 && !readonly && !archived; let connectionLost = !connected && ["reconnecting", "closed"].includes(wire?.status ?? ""); let effectiveMode = agent && (mode || addressedOutsideReferences(draft.text, draft.references)); 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 authors = chatAuthors(entries); let mention = composerReady && !submitting && !detected ? mentionTrigger(draft.text, selection.start, selection.end) : undefined; let mentionKey = mention ? mentionTriggerKey(mention) : undefined; let mentionOptions = mention && !draft.references.some(reference => reference.start < mention.end && reference.end > mention.start ) ? filterMentions( mentionCandidates({ authors, people, planner: agent, self: handle }), mention.query, ) : []; let mentionOpen = mentionOptions.length > 0 && mentionKey !== dismissedPicker; let mentionActive = mentionCursor.key === mentionKey ? Math.min(mentionCursor.index, mentionOptions.length - 1) : 0; let activeMention: MentionCandidate | undefined = mentionOpen ? mentionOptions[mentionActive] : undefined; 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); setRuns(frame.runs); // 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); setRuns(frame.runs); }), 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( typeof caret === "number" ? caret : caret.start, typeof caret === "number" ? caret : caret.end, ); }); 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); setHistoryKey(current => current + 1); }; let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; if (!current.text.trim()) return; let submitted = prepareDraftSubmission(current); let prefix = effectiveMode && !addressedOutsideReferences(submitted.text, submitted.references) ? `${MENTION} ` : ""; let payload = chatSendPayload( prefix + submitted.text, submitted.references.map(reference => ({ ...reference, start: reference.start + prefix.length, end: reference.end + prefix.length, })), 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 toggleMode = () => { if (!composerReady || submitting || !agent) return; let current = draftRef.current; let at = selection.start; let end = selection.end; if (effectiveMode) { let removals = [...current.text.matchAll(/(^|[^\w@])@chopin\b/gi)] .map(match => ({ start: match.index + match[1]!.length, end: match.index + match[0].length, })) .filter(edit => !current.references.some(reference => edit.start < reference.end && edit.end > reference.start ) ); let next = current.text; let references = current.references; for (let edit of removals.toReversed()) { let revised = next.slice(0, edit.start) + next.slice(edit.end); references = reconcileReferenceDrafts(next, revised, references, edit); if (edit.start < at) at -= Math.min(edit.end - edit.start, at - edit.start); if (edit.start < end) end -= Math.min(edit.end - edit.start, end - edit.start); next = revised; } setDraft({ text: next, references }); setSelection({ start: at, end }); setMode(false); } else { setMode(true); setDraft({ text: current.text, references: current.references }); } setSendError(undefined); setDismissedPicker(triggerKey ?? mentionKey); pendingCaret.current = { start: at, end }; }; 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); pendingCaret.current = next.caret; }; let chooseMention = (candidate: MentionCandidate) => { if (!mention) return; let next = insertMention(draft.text, mention, candidate); if (candidate.kind === "planner") setMode(true); setDraft(current => reviseComposerDraft( current, next.text, reconcileReferenceDrafts( current.text, next.text, current.references, { start: mention.start, end: mention.end }, ), ) ); setSelection({ start: next.caret, end: next.caret }); setSendError(undefined); setDismissedPicker(undefined); pendingCaret.current = next.caret; }; let moveMention = (to: (index: number) => number) => setMentionCursor({ key: mentionKey, index: to(mentionActive) }); return (
wire?.send("chat:unqueue", { id })} queued={queue} working={connected && synchronized.current === wire && turn ? turn : undefined} /> {agent && !!runs?.length && (
wire?.send("chat:pause-run", { runId })} onResume={runId => wire?.send("chat:resume-run", { runId })} onShowDecisions={onShowDecisions} runs={runs} />
)}
{referencesEnabled && (

Type # to reference a document.

)} {!readonly && !archived && (sendError || !composerReady || !agent) && (
{sendError ? : connectionLost ? : !composerReady ? : } {sendError ?? (!composerReady ? connected ? "Synchronizing…" : connectionLost ? "Connection lost" : "Connecting…" : "Chopin unavailable")} {sendError ? ( ) : !composerReady && wire && ( )}
)}
{pickerOpen && trigger && ( )} {mentionOpen && ( setMentionCursor({ key: mentionKey, index })} onSelect={chooseMention} options={mentionOptions} /> )} {readonly || archived ? (
{archived ? : } {archived ? "Document archived" : "Read-only access"}

{archived ? "Restore this document to send messages." : "You need write access to send messages."}

) : ( <> candidate.login, ), ]} onChange={event => { let next = event.currentTarget.value; setDraft(current => reviseComposerDraft( current, next, event.currentTarget.references ?? reconcileReferenceDrafts(current.text, next, current.references), ) ); setSendError(undefined); setDismissedPicker(undefined); setSelection({ start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd, }); }} onKeyDown={event => { let composing = event.nativeEvent.isComposing || event.keyCode === 229; if (!composing && event.key === "Tab" && !event.shiftKey) { setDismissedPicker(mentionKey ?? triggerKey); return; } if ( !composing && event.key === "Tab" && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey && agent && composerReady && !submitting ) { event.preventDefault(); if (!event.repeat) toggleMode(); return; } let mentionAction = mentionOpen ? mentionKeyAction({ key: event.key, keyCode: event.keyCode, isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, }, true) : undefined; if (mentionAction === "next") { moveMention(index => (index + 1) % mentionOptions.length); event.preventDefault(); return; } if (mentionAction === "previous") { moveMention(index => (index - 1 + mentionOptions.length) % mentionOptions.length ); event.preventDefault(); return; } if (mentionAction === "dismiss") { setDismissedPicker(mentionKey); event.preventDefault(); event.stopPropagation(); return; } if (mentionAction === "select" && activeMention) { chooseMention(activeMention); event.preventDefault(); return; } let action = pickerOpen ? referencePickerKeyAction({ key: event.key, keyCode: event.keyCode, isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey, }, activeOption !== undefined) : undefined; if (action === "next") { picker.setActive(value => picker.options.length === 0 ? 0 : (value + 1) % picker.options.length ); event.preventDefault(); return; } if (action === "previous") { picker.setActive(value => picker.options.length === 0 ? 0 : (value - 1 + picker.options.length) % picker.options.length ); event.preventDefault(); return; } if (action === "dismiss") { setDismissedPicker(triggerKey); event.preventDefault(); event.stopPropagation(); return; } if (action === "select" && activeOption) { chooseReference(activeOption); event.preventDefault(); return; } if (composing) return; if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); submit(); }} onSelect={event => setSelection({ start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd, })} placeholder={effectiveMode ? "Ask Chopin…" : "Message your collaborators…"} ref={textarea} value={draft.text} />
{referencesEnabled && ( )}
{agent && (busy || counts.active > 0) && ( )} {agent && !busy && !counts.active && counts.paused > 0 && ( )}
)}
); }