/** 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 {
AgentFace,
Face,
MotionDisclosure,
MotionDisclosureIcon,
useCardMeta,
} from "@chopin/editor";
import { MessageMarkdown } from "./markdown";
import { capitalize, displayText, duration, group, summarize, toolCopy } from "./model";
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";
type PlanMarkers = {
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 (
{summary.name}
{summary.completed} done
);
}
return (
{tools.map(tool => (
-
{toolCopy(tool.name)}
{tool.status === "failed" && (
Failed
)}
{tool.took === undefined ? "—" : duration(tool.took)}
))}
);
}
function SystemEntry({ item }: { item: Extract }) {
return (
);
}
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 &&
}
{!message.queued && markers.researchOffers && markers.conversationPlan?.researchOffers
?.filter(offer => offer.source.messageId === message.id)
.map(offer => (
))}
);
}
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,
conversationPlan,
decisions,
researchOffers,
entries,
handle,
onWithdraw,
queued,
working,
}: {
active: boolean;
canEdit?: boolean;
conversationPlan?: ConversationPlan.State;
decisions?: TranscriptDecisions;
researchOffers?: ResearchOfferControls;
entries: Chat.Entry[];
handle: string;
onWithdraw: (id: string) => void;
queued: Chat.Waiting[];
working?: Pick;
},
) {
let bottom = useRef(null);
let pinned = useRef(true);
let groups = group(entries, queued, working);
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]);
return (
{
let element = event.currentTarget;
let distance = element.scrollHeight - element.scrollTop - element.clientHeight;
pinned.current = distance < 40;
}}
>
{groups.map(item =>
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}
/>
)
:
: (
)
)}
);
}