/** The shared chat, grouped for reading rather than event delivery. */ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { ChevronIcon, CloseIcon, LoaderIcon, SignInIcon } from "@chopin/icons"; import { parseChildDocumentPath } from "@chopin/protocol/document-url"; import { AgentFace, Face, MotionDisclosure, MotionDisclosureIcon, useCardMeta, } from "@chopin/editor"; 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, duration, group, summarize, toolCopy } from "./model"; import { clearSourceHighlight, highlightSource } from "../conversation-plan/source"; import type { ChatDestination } from "../conversation-plan/source"; import { motionContract } from "../motion-contract"; import { motionImmediately } from "../motion-input"; import type { Chat, ConversationPlan } from "@chopin/protocol"; import type { 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"; type PlanMarkers = { 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" }); } function ToolRun({ tools }: { tools: Chat.Activity[] }) { let [open, setOpen] = useState(false); let contentId = useId(); let summary = summarize(tools); let motion = motionContract("collapse"); if (summary.state === "running") { return (
); } return (
    {tools.map(tool => (
  • {toolCopy(tool.name)} {tool.status === "failed" && ( Failed )} {tool.took === undefined ? "—" : duration(tool.took)}
  • ))}
); } function SystemEntry({ item }: { item: Extract }) { let readyPath = /^Research is ready\. \[Open the research document\]\((\/documents\/\S+)\)\.$/ .exec(item.text)?.[1]; let linked = readyPath !== undefined && parseChildDocumentPath(readyPath) !== undefined; 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" ? : ; } function MessageBody( { handle, message, onWithdraw, ...markers }: { handle: string; message: Message; onWithdraw: (id: string) => void; } & PlanMarkers, ) { let text = displayText(message.text) ? message.text : message.author.kind === "member" ? "Ask Planner" : ""; return (
{text && (
{message.streaming && ▍}
{message.queued && message.author.kind === "member" && message.author.handle === handle && ( )}
)} {message.tools && message.tools.length > 0 && } {markers.sourceDestination?.source.messageId === message.id && (

Source: “{markers.sourceDestination.source.quote}”

)} {!message.queued && markers.onCardLink && markers.onRetryAnalysis && ( )}
); } function MessageGroup( { group: item, handle, onWithdraw, ...markers }: { group: Extract; handle: string; onWithdraw: (id: string) => void; } & PlanMarkers, ) { let first = item.messages[0]!; let name = item.author.kind === "agent" ? "Planner" : capitalize(item.author.handle); return (
{item.author.kind === "agent" ? : }
{name} {item.queued ? "queued" : when(first.ts!)}
{item.messages.map(message => ( ))}
); } export function Transcript( { active, canEdit, conversationPlan, conversationPlanJobs, decisions, researchOffers, entries, handle, onCardLink, onAddExcerpt, onRetryAnalysis, onRetryJob, onWithdraw, queued, sourceDestination, working, }: { active: boolean; canEdit?: boolean; 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; entries: Chat.Entry[]; handle: string; onWithdraw: (id: string) => void; queued: Chat.Waiting[]; working?: Pick; sourceDestination?: ChatDestination; }, ) { let bottom = useRef(null); let scroller = useRef(null); let pinned = useRef(true); let sourceOwner = useRef({}); let groups = researchTranscript( group(entries, queued, working), researchOffers ? conversationPlan?.researchOffers ?? [] : [], ); 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); } } useEffect(() => { if (active && pinned.current) bottom.current?.scrollIntoView({ block: "end" }); }, [active, entries, queued]); 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; 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 < 40; }} >
{groups.map(item => item.kind === "research" ? ( ) : item.kind === "system" ? decisions && item.decision && item.ts !== undefined ? ( & { decision: NonNullable; }} key={item.id} 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} /> ) : : ( ) )}
); }