/** The shared chat, grouped for reading rather than event delivery. */ import { useCallback, useEffect, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { CloseIcon, MessageIcon, SignInIcon, SparkleIcon, WarningIcon } from "@chopin/icons"; import { parseChildDocumentPath } from "@chopin/protocol/document-url"; import { Face, useCardMeta } from "@chopin/editor"; import { EmptyState } from "@chopin/visuals"; import { ChopinMark } from "./agent-mark"; import { MessageMarkdown } from "./markdown"; import { MessageMarkers } from "../conversation-plan/markers"; import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-overview"; import type { CardLink } from "../conversation-plan/links"; import { capitalize, displayText, group, workAnnouncement, workPhase } from "./model"; import { WorkProgress } from "./work-progress"; import { clearSourceHighlight, highlightSource } from "../conversation-plan/source"; import type { ChatDestination } from "../conversation-plan/source"; import type { Chat, ConversationPlan } from "@chopin/protocol"; import type { CompletedWork, Group, Message } from "./model"; import type { CardMetaStore, QuestionnaireStore } from "@chopin/editor"; import type { Transport } from "@chopin/question/react"; import { ActivityLine, DecisionPrompt } from "./decision-entry"; import { ScopedChoicePrompt } from "./scoped-choice-entry"; import { ResearchOfferCard } from "./research-offer"; import type { ResearchOfferControls } from "./research-offer"; import { researchTranscript } from "./research-transcript"; import type { ResearchTranscriptItem } from "./research-transcript"; type PlanMarkers = { decisions?: TranscriptDecisions; canEdit?: boolean; conversationPlanJobs?: ConversationPlan.Job[]; onCardLink?: (link: CardLink) => void; onAddExcerpt?: (action: ExcerptCorrectionAction) => Promise; onRetryAnalysis?: ( messageId: string, actionId: string, lane?: "decision" | "research", ) => Promise; onRetryJob?: (jobId: string) => Promise; sourceDestination?: ChatDestination; conversationPlan?: ConversationPlan.State; researchOffers?: ResearchOfferControls; }; export type TranscriptDecisions = { questions: QuestionnaireStore; meta: CardMetaStore; wire?: Transport; connected: boolean; canEdit: boolean; onOpenCard: (questionnaireId: string) => void; }; function when(ts: number): string { return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } const READY = /^Research is ready\. \[Open the research document\]\((\/documents\/\S+)\)\.$/; function readyChild(text: string): string | undefined { let path = READY.exec(text)?.[1]; return path ? parseChildDocumentPath(path)?.childSlug : undefined; } function SystemEntry( { enter, item }: { enter: boolean; item: Extract }, ) { let readyPath = READY.exec(item.text)?.[1]; let linked = readyPath !== undefined && parseChildDocumentPath(readyPath) !== undefined; let Icon = readyPath ? SparkleIcon : item.text.startsWith("Research could not be completed") ? WarningIcon : SignInIcon; return (
{linked ? ( ) : (

{displayText(item.text)}

)}
); } function DecisionSystemEntry( { conversationPlan, decisions, item, latest }: { conversationPlan?: ConversationPlan.State; decisions: TranscriptDecisions; item: Extract & { decision: NonNullable }; latest: boolean; }, ) { let id = item.decision.questionnaireId; let values = useSyncExternalStore( decisions.questions.subscribe, decisions.questions.snapshot, decisions.questions.snapshot, ); let value = values.find(entry => entry.id === id)?.value; let meta = useCardMeta(decisions.meta, id); let entry: Chat.Entry & { decision: NonNullable } = { id: item.id, author: { kind: "system" }, text: item.text, ts: item.ts!, decision: item.decision, }; let props = { entry, latest, value, meta, wire: decisions.wire, connected: decisions.connected, canEdit: decisions.canEdit, onOpenCard: decisions.onOpenCard, }; if (entry.decision.kind === "scoped-choice") { return ( ); } return entry.decision.kind === "prompt" ? : ; } /** A leading @chopin is the Planner address; the prefix is drawn from `to`, so it is not shown twice. */ let LEADING_MENTION = /^\s*@chopin\b[^\S\n]*/i; function MessageBody( { enter, handle, message, onWithdraw, ...markers }: { enter: boolean; handle: string; message: Message; onWithdraw: (id: string) => void; } & PlanMarkers, ) { let toPlanner = message.to === "planner" && message.author.kind === "member"; let lead = toPlanner ? LEADING_MENTION.exec(message.text)?.[0].length ?? 0 : 0; let text = toPlanner ? message.text.slice(lead) : message.text; let references = lead ? message.references?.filter(item => item.start >= lead) .map(item => ({ ...item, start: item.start - lead, end: item.end - lead })) : message.references; if (!toPlanner && !displayText(text) && message.author.kind === "member") text = "Ask Chopin"; return (
{(message.working || message.workDisconnected || !!message.tools?.length) && ( )} {markers.sourceDestination?.source.messageId === message.id && (
); } function MessageGroup( { enter, entering, group: item, handle, onWithdraw, ...markers }: { /** The whole group entered; `entering` holds messages that joined it later. */ enter: boolean; entering: ReadonlySet; group: Extract; handle: string; onWithdraw: (id: string) => void; } & PlanMarkers, ) { let first = item.messages[0]!; let active = item.messages.find(message => message.working); let name = item.author.kind === "agent" ? "Chopin" : capitalize(item.author.handle); return (
{item.continued ? null : item.author.kind === "agent" ? : }
{!item.continued && (
{name} {item.queued ? "queued" : active ? `Started at ${when(active.ts!)}` : when(first.ts!)}
)} {item.messages.map(message => ( ))}
); } export function Transcript( { active, canEdit, completedWork, conversationPlan, conversationPlanJobs, decisions, empty, researchOffers, emptyHint, entries, handle, live, onCardLink, onAddExcerpt, onRetryAnalysis, onRetryJob, onWithdraw, queued, sourceDestination, suspendedWork, working, }: { active: boolean; canEdit?: boolean; completedWork?: CompletedWork[]; 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; empty?: string; emptyHint?: { planner: boolean; references: boolean }; researchOffers?: ResearchOfferControls; entries: Chat.Entry[]; handle: string; /** False until the current connection's history has arrived. */ live?: boolean; onWithdraw: (id: string) => void; queued: Chat.Waiting[]; suspendedWork?: CompletedWork; working?: Pick; sourceDestination?: ChatDestination; }, ) { let scroller = useRef(null); let stack = useRef(null); let pinned = useRef(true); // Growth follows only a reader resting on the last line, not one a few pixels up. let flush = useRef(true); let previous = useRef<{ groups: Set; atoms: Set; planned: boolean }>(undefined); let entered = useRef(new Set()); let places = useRef(new Map()); let sourceOwner = useRef({}); let groups = researchTranscript( group(entries, queued, working, completedWork, suspendedWork), researchOffers ? conversationPlan?.researchOffers ?? [] : [], ); let messages = groups.flatMap(item => item.kind === "messages" ? item.messages : []); // A turn keeps one row from its placeholder through completion, so it enters once. let turns = new Map( [...completedWork ?? [], ...suspendedWork ? [suspendedWork] : []].map(work => [ work.anchorId, work.turnId, ]), ); let keyed = groups.map(item => { if (item.kind === "research") return { item, key: `research:${item.offer.id}` }; if (item.kind === "system") return { item, key: item.id }; let first = item.messages[0]!; let turn = first.working ? working?.id : turns.get(first.id); return { item, key: turn ? `turn:${turn}` : `${item.queued ? "queued" : "sent"}-${first.id}` }; }); let atoms = new Set( groups.flatMap(item => item.kind === "messages" ? item.messages.map(message => message.id) : [item.kind === "research" ? `research:${item.offer.id}` : item.id] ), ); let before = previous.current; let entering = new Set(before ? [...atoms].filter(atom => !before.atoms.has(atom)) : []); // History, reconnects and document switches arrive in bulk; only live arrivals move. let animate = !!live && !!before && entering.size <= 3; // Marks stay for the row's lifetime: dropping one on the next render would cut its fade short. if (animate) { for (let { item, key } of keyed) { if (!before!.groups.has(key)) { if (item.kind !== "research" || before!.planned) entered.current.add(`row:${key}`); } else if (item.kind === "messages") { for (let message of item.messages) { if (entering.has(message.id)) entered.current.add(message.id); } } } } let enters = (key: string) => entered.current.has(`row:${key}`); let readySubscribe = useCallback( (listener: () => void) => researchOffers?.store.subscribe(listener) ?? (() => {}), [researchOffers?.store], ); // A ready request linked from a visible offer card already says so there. let readySnapshot = () => groups.flatMap(item => { if (item.kind !== "research") return []; let id = researchOffers?.links[item.offer.id]?.researchRequestId; let slug = id ? researchOffers?.store.get(id)?.child?.slug : undefined; return slug ? [slug] : []; }).join("\n"); let readySlugs = useSyncExternalStore(readySubscribe, readySnapshot, readySnapshot); let announced = new Set(readySlugs ? readySlugs.split("\n") : []); let currentWork = messages.find(message => message.working); let phase = currentWork ? workPhase( currentWork.tools ?? [], !!currentWork.workStreaming || !!currentWork.streaming, true, !!currentWork.workResponseSeen, ) : undefined; let announcement = ""; if (phase) announcement = workAnnouncement(phase); else if (suspendedWork) { let hasDetails = !!messages.find(message => message.id === suspendedWork.anchorId)?.tools ?.length; announcement = hasDetails ? "Chopin connection lost. Work details remain available." : "Chopin connection lost."; } else if (completedWork?.length) { let anchorId = completedWork.at(-1)?.anchorId; let hasDetails = !!messages.find(message => message.id === anchorId)?.tools?.length; announcement = hasDetails ? "Chopin turn ended. Work details remain available." : "Chopin turn ended."; } let latestPrompt = new Map(); let latestScoped = new Map(); for (let entry of entries) { if (entry.decision?.kind === "prompt") { latestPrompt.set(entry.decision.questionnaireId, entry.id); } if (entry.decision?.kind === "scoped-choice") { latestScoped.set(entry.decision.proposalId, entry.id); } } useLayoutEffect(() => { // Your own message always comes into view, wherever you had scrolled. if ( messages.some(message => entering.has(message.id) && message.author.kind === "member" && message.author.handle === handle ) ) pinned.current = true; // Rows that arrived behind a hidden Chat (the phone tab) are not news when it opens. if (!active || !scroller.current?.getClientRects().length) { entered.current.clear(); for (let row of stack.current?.querySelectorAll("[data-chat-enter]") ?? []) { row.removeAttribute("data-chat-enter"); } } let rows = new Set(keyed.map(entry => entry.key)); previous.current = { groups: rows, atoms, planned: !!conversationPlan }; for (let mark of entered.current) { if (!atoms.has(mark) && !rows.has(mark.slice(4))) entered.current.delete(mark); } }); // An offer that follows a newer message slides from where it was last painted rather than // teleporting. Positions are kept content-relative so the reader's own scrolling is not motion. useLayoutEffect(() => { let element = scroller.current; if (!element) return; let scrolled = element.scrollTop; if (active && pinned.current) element.scrollTop = element.scrollHeight; let reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; let cards = () => [ ...element.querySelectorAll("[data-chat-placement]"), ]; let place = (card: HTMLElement) => card.getBoundingClientRect().top - element.getBoundingClientRect().top; for (let card of cards()) { let last = places.current.get(card.dataset.chatOffer!); if (!last || last.at === card.dataset.chatPlacement || reduce) continue; for (let running of card.getAnimations()) running.cancel(); let offset = last.top - scrolled - place(card); if (Math.abs(offset) < 1) continue; let tokens = getComputedStyle(card); let duration = tokens.getPropertyValue("--acc-expand").trim(); card.animate([{ transform: `translateY(${offset}px)` }, { transform: "none" }], { duration: parseFloat(duration) * (duration.endsWith("ms") ? 1 : 1000), easing: tokens.getPropertyValue("--motion-move"), }); } let frame = requestAnimationFrame(() => { places.current = new Map( cards().map(card => [card.dataset.chatOffer!, { at: card.dataset.chatPlacement!, top: place(card) + element.scrollTop, }]), ); }); return () => cancelAnimationFrame(frame); }); useLayoutEffect(() => { let element = scroller.current; if (active && pinned.current && element) element.scrollTop = element.scrollHeight; }, [active, entries, queued]); // Rows grow after they mount (streaming, work details, offer cards) and the pane resizes // (phone keyboard, composer, window); a reader at the bottom stays on the newest line. useLayoutEffect(() => { let element = scroller.current; if (!active || !element || !stack.current) return; let observer = new ResizeObserver(() => { if (flush.current) element.scrollTop = element.scrollHeight; }); observer.observe(stack.current); observer.observe(element); return () => observer.disconnect(); }, [active]); useEffect(() => { if (!active || !sourceDestination) return; let message = Array.from( scroller.current?.querySelectorAll("[data-chat-message-id]") ?? [], ) .find(element => element.dataset.chatMessageId === sourceDestination.source.messageId); if (!message) return; pinned.current = false; flush.current = false; message.scrollIntoView({ block: "center", inline: "nearest" }); let exact = highlightSource(sourceOwner.current, message, sourceDestination.source); message.dataset.sourceExact = String(exact); return () => { clearSourceHighlight(sourceOwner.current); delete message.dataset.sourceExact; }; }, [active, sourceDestination]); return (
{ let element = event.currentTarget; let distance = element.scrollHeight - element.scrollTop - element.clientHeight; pinned.current = distance < 48; flush.current = distance <= 2; }} > {announcement}
{empty && (

{empty}

)} {keyed.map(({ item, key }) => item.kind === "research" ? (
) : item.kind === "system" ? decisions && item.decision && item.ts !== undefined ? ( & { decision: NonNullable; }} key={key} latest={item.decision.kind === "prompt" ? latestPrompt.get(item.decision.questionnaireId) === item.id : item.decision.kind === "scoped-choice" ? latestScoped.get(item.decision.proposalId) === item.id : true} /> ) : announced.has(readyChild(item.text) ?? "") ? null : : ( ) )}
{emptyHint && (
0 || undefined} className={`pointer-events-none absolute inset-0 flex items-center justify-center px-6 transition-opacity motion-reduce:transition-none ${ groups.length > 0 ? "opacity-0" : "opacity-100" }`} data-chat-empty="" > Talk it through with your collaborators. {emptyHint.planner && ( <> {" "}Mention{" "} @chopin{" "} to ask the Planner {emptyHint.references ? ", or " : "."} )} {emptyHint.references && ( <> {emptyHint.planner ? "" : " Use "} #{" "} to point at a document. )} } />
)}
); }