);
}
const AssistantMessage = memo(function AssistantMessage({
msg,
isStreaming,
outlineStatus,
sessionId,
language,
onConfirmOutline,
onSubmitUserReply,
researchRequestSnapshot,
}: {
msg: { content: string; capability?: string; events?: StreamEvent[] };
isStreaming?: boolean;
outlineStatus?: "editing" | "researching" | "done";
sessionId?: string | null;
language?: string;
researchRequestSnapshot?: MessageRequestSnapshot | null;
onConfirmOutline?: (
outline: Array<{ title: string; overview: string }>,
topic: string,
researchConfig?: Record | null,
requestSnapshot?: MessageRequestSnapshot | null,
) => void;
/**
* Submit a reply for a turn that is paused on ``ask_user``. Wired
* through from the page so the card's option-buttons / free-text
* input can deliver the user's selection back to the backend over
* the unified WebSocket. Triggers a same-turn resume (no new user
* bubble). Accepts either a flat string (legacy single-question) or
* a structured object with per-question ``answers`` (v2 path).
*/
onSubmitUserReply?: (
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => void;
}) {
const events = useMemo(() => msg.events ?? [], [msg.events]);
const resultEvent = useMemo(
() => msg.events?.find((event) => event.type === "result") ?? null,
[msg.events],
);
const outlinePreview = useMemo(() => {
if (msg.capability !== "deep_research" || !resultEvent) return null;
const meta = resultEvent.metadata as Record | undefined;
if (!meta?.outline_preview) return null;
return {
sub_topics: (meta.sub_topics ?? []) as Array<{
title: string;
overview: string;
}>,
topic: String(meta.topic ?? ""),
research_config: (meta.research_config ?? null) as Record<
string,
unknown
> | null,
};
}, [msg.capability, resultEvent]);
const quizQuestions = useMemo(() => {
if (msg.capability !== "deep_question") return null;
// Once the final result event lands, it's authoritative — it carries
// the canonical summary.results[]. Until then, accumulate questions
// from the live ``quiz_question_emitted`` content events so the
// QuizViewer can render each card the moment it's generated.
if (resultEvent) return extractQuizQuestions(resultEvent.metadata);
return extractStreamingQuizQuestions(msg.events ?? []);
}, [msg.capability, msg.events, resultEvent]);
const mathAnimatorResult = useMemo(() => {
if (msg.capability !== "math_animator" || !resultEvent) return null;
return extractMathAnimatorResult(resultEvent.metadata);
}, [msg.capability, resultEvent]);
const visualizeResult = useMemo(() => {
if (msg.capability !== "visualize" || !resultEvent) return null;
return extractVisualizeResult(resultEvent.metadata);
}, [msg.capability, resultEvent]);
// Detect the ``ask_user`` terminator payload: when the assistant turn
// ended via the ``ask_user`` tool, this is the question the user is
// expected to answer next. Render option chips below the message.
const askUserPayload = useMemo(
() => extractAskUserPayload(msg.events),
[msg.events],
);
// Interleaved segments for the default chat surface — text emitted
// before the ask_user call renders above the card; text emitted by
// the resumed iteration renders below it. Only walked when this
// message will actually render through the default branch (the
// research / quiz / animator / visualize branches have their own
// layout and pin the card elsewhere).
const useInlineAskUserSegments =
!outlinePreview &&
!mathAnimatorResult &&
!visualizeResult &&
!(quizQuestions && quizQuestions.length > 0);
const messageSegments = useMemo(
() => (useInlineAskUserSegments ? extractMessageSegments(msg.events) : []),
[useInlineAskUserSegments, msg.events],
);
const hasInlineAskUser =
useInlineAskUserSegments &&
messageSegments.some((seg) => seg.kind === "ask_user");
const researchInProgress =
outlineStatus === "researching" || outlineStatus === "done";
const showResearchBody =
Boolean(outlinePreview) && researchInProgress && Boolean(msg.content);
return (
<>
{/* Activity block pinned to the TOP: the status header
("DeepTutor Exploring… · 8s" → "DeepTutor responded. · 10s") with
the exploring trace nested beneath it — expanded while DeepTutor is
still working, collapsed once it settles into the final answer. */}
{outlinePreview && outlinePreview.sub_topics.length > 0 ? (
<>
{/* Layout for the merged research bubble:
1. trace rows (above, via TraceFlow)
2. ask_user Q&A summary (collapsible once research starts)
3. Outline editor (auto-collapses once locked)
4. Final report body (only after research is underway)
The Q&A intentionally sits ABOVE the outline so the user
sees the path that produced the outline before the outline
itself. */}
{askUserPayload ? (
{
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
collapsible={researchInProgress}
defaultCollapsed={researchInProgress}
/>
) : null}
onConfirmOutline?.(
items,
outlinePreview.topic,
outlinePreview.research_config,
researchRequestSnapshot,
)
}
status={outlineStatus}
/>
{showResearchBody ? (
) : null}
>
) : mathAnimatorResult ? (
) : visualizeResult ? (
) : quizQuestions && quizQuestions.length > 0 ? (
<>
{/* The quiz preface (the "I researched X, now let me quiz you on Y"
sentence the user watched stream in) rides along ABOVE the quiz
card. Without this, the streamed text
vanishes from the bubble the moment the first card appears
because the branch above is mutually exclusive with
. The body is already free of the
per-question markdown — the pipeline trims that out of
``msg.content`` since the QuizViewer renders the cards
themselves. */}
{msg.content ? (
) : null}
>
) : hasInlineAskUser ? (
// Default chat surface with one or more ask_user calls: render
// text and cards in the exact order they were streamed, so the
// pre-ask_user narration sits above the card and the resumed
// iteration's text sits below.
messageSegments.map((seg) =>
seg.kind === "text" ? (
) : (
{
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
/>
),
)
) : (
)}
{/* Non-default branches (quiz, math animator, visualize) keep
ask_user below the body. The default branch inlines the card
via ``messageSegments``; the research branch renders its own
card above the outline editor — both skip this fallback. */}
{!outlinePreview && !hasInlineAskUser && askUserPayload ? (
{
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
/>
) : null}
>
);
});
AssistantMessage.displayName = "AssistantMessage";
function CostFooter({
cost,
tokens,
calls,
}: {
cost: number;
tokens: number;
calls: number;
}) {
const { t } = useTranslation();
const formatCost = (usd: number) => {
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(2)}`;
};
const formatTokens = (n: number) => {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
};
return (
);
});
UserMessage.displayName = "UserMessage";
export const ChatMessageList = memo(function ChatMessageList({
messages,
isStreaming,
sessionId,
language,
onCopyAssistantMessage,
onRegenerateMessage,
onConfirmOutline,
onPreviewAttachment,
onDeleteTurn,
selectedBranches,
onEditMessage,
onSwitchBranch,
onSubmitUserReply,
}: {
messages: ChatMessageItem[];
isStreaming: boolean;
sessionId?: string | null;
language?: string;
onCopyAssistantMessage: (content: string) => void | Promise;
onRegenerateMessage: () => void;
onConfirmOutline?: (
outline: Array<{ title: string; overview: string }>,
topic: string,
researchConfig?: Record | null,
requestSnapshot?: MessageRequestSnapshot | null,
) => void;
onPreviewAttachment?: (attachment: MessageAttachment) => void;
onDeleteTurn?: (messageId: number) => void;
/** Edit-branching: selected sibling at each branch point. */
selectedBranches?: Record;
onEditMessage?: (messageId: number, newContent: string) => void;
onSwitchBranch?: (parentMessageId: number | null, childId: number) => void;
/**
* Deliver an ``ask_user`` reply back to the backend so the agentic
* loop resumes on the same turn. Forwarded into each
* ``AssistantMessage`` so the card UI rendered alongside the paused
* assistant bubble can submit selections / free-form text. Accepts
* either a string (legacy) or a structured object with per-question
* ``answers`` (v2).
*/
onSubmitUserReply?: (
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => void;
}) {
const { t } = useTranslation();
// Visible path: when no branching has happened the result is identical
// to the input. After an edit, sibling branches are filtered out so the
// UI shows exactly one continuous thread, with arrow nav exposed on the
// user message where branching diverges.
const { messages: visibleMessages, siblingsByMessageId } = useMemo(
() => buildVisiblePath(messages, selectedBranches),
[messages, selectedBranches],
);
// Deep-research two-turn merge.
//
// The capability runs in two BE turns: turn-1 emits rephrase +
// decompose + an outline-preview result; turn-2 (after the user
// confirms the outline) emits the research blocks + the final
// report. The user wants both turns to live in ONE assistant
// bubble so the rephrase trace, the Q&A summary, the (collapsed)
// outline editor, and the research / reporting traces are all
// visually contiguous instead of split across two bubbles.
//
// For each parent (outline-preview) msg with a followup
// deep_research msg, we synthesise a merged msg with:
//
// * events — parent.events ++ followup.events (preserving order
// so TraceFlow's call_id grouping keeps working).
// * content — followup.content (the report). The parent's
// rephrase preface is already represented inside the trace card,
// so concatenating again would duplicate it above the report.
//
// The followup is dropped from the visible row list so only the
// merged bubble renders.
const deepResearchMergeMap = useMemo(() => {
const map = new Map<
number,
{ mergedEvents: StreamEvent[]; mergedContent: string }
>();
const followupIndices = new Set();
for (let i = 0; i < visibleMessages.length; i++) {
const msg = visibleMessages[i];
if (msg.role !== "assistant" || msg.capability !== "deep_research")
continue;
const resultEv = msg.events?.find((e) => e.type === "result");
const meta = resultEv?.metadata as Record | undefined;
if (!meta?.outline_preview) continue;
const followupIdx = visibleMessages
.slice(i + 1)
.findIndex(
(m) => m.role === "assistant" && m.capability === "deep_research",
);
if (followupIdx === -1) continue;
const absoluteFollowupIdx = i + 1 + followupIdx;
const followup = visibleMessages[absoluteFollowupIdx];
const mergedEvents = [...(msg.events ?? []), ...(followup.events ?? [])];
const mergedContent = followup.content || msg.content;
map.set(i, { mergedEvents, mergedContent });
followupIndices.add(absoluteFollowupIdx);
}
return { mergedByParent: map, followupIndices };
}, [visibleMessages]);
const outlineStatusByIndex = useMemo(() => {
const map = new Map();
for (let i = 0; i < visibleMessages.length; i++) {
const msg = visibleMessages[i];
if (msg.role !== "assistant" || msg.capability !== "deep_research")
continue;
const resultEv = msg.events?.find((e) => e.type === "result");
const meta = resultEv?.metadata as Record | undefined;
if (!meta?.outline_preview) continue;
const followup = visibleMessages
.slice(i + 1)
.find(
(m) => m.role === "assistant" && m.capability === "deep_research",
);
if (followup) {
const followupResult = followup.events?.find(
(e) => e.type === "result",
);
map.set(i, followupResult ? "done" : "researching");
} else {
// The first deep_research turn only plans/rephrases/decomposes and
// returns an outline preview. While that turn is still flushing
// post-result events, the outline must already be editable; only the
// hidden follow-up turn created by "Start Research" means research is
// actually underway.
map.set(i, "editing");
}
}
return map;
}, [visibleMessages]);
const messageRows = useMemo(() => {
// System messages are backend grounding (e.g. quiz follow-up context) and
// must never be rendered as a chat bubble. Filter them out defensively in
// addition to the hydration-time filter in UnifiedChatContext.
return visibleMessages
.map((msg, index) => ({ msg, originalIndex: index }))
.filter(({ msg, originalIndex }) => {
if (msg.role === "system") return false;
// Drop deep_research followup msgs — their events were merged
// into the parent (outline-preview) bubble.
if (deepResearchMergeMap.followupIndices.has(originalIndex))
return false;
return true;
})
.map(({ msg, originalIndex }) => {
// Splice in the merged event stream when this row owns a
// deep_research two-turn pair.
const merged = deepResearchMergeMap.mergedByParent.get(originalIndex);
const effectiveMsg: ChatMessageItem = merged
? {
...msg,
events: merged.mergedEvents,
content: merged.mergedContent,
}
: msg;
if (effectiveMsg.role === "user") {
return {
msg: effectiveMsg,
originalIndex,
pairedUserMessage: null as ChatMessageItem | null,
};
}
const pairedUserMessage =
[...visibleMessages.slice(0, originalIndex)]
.reverse()
.find((previous) => previous.role === "user") ?? null;
return { msg: effectiveMsg, originalIndex, pairedUserMessage };
});
}, [visibleMessages, deepResearchMergeMap]);
const lastRenderedAssistantIndex = useMemo(() => {
for (let idx = messageRows.length - 1; idx >= 0; idx -= 1) {
if (messageRows[idx].msg.role === "assistant")
return messageRows[idx].originalIndex;
}
return -1;
}, [messageRows]);
// Auto-play (when enabled) must fire only for a reply that JUST finished
// generating — never when loading history. We capture the last-assistant
// index at the moment streaming flips off; the matching speaker button
// plays once. Switching sessions clears the marker. Uses the "adjust state
// during render" pattern (state-vs-prop comparison, like the API-key reset
// in ServiceConfigEditor) — both branches are conditional and bounded.
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
const [prevSession, setPrevSession] = useState(sessionId);
const [freshlyCompletedIndex, setFreshlyCompletedIndex] = useState<
number | null
>(null);
if (prevSession !== sessionId) {
setPrevSession(sessionId);
setPrevStreaming(false);
setFreshlyCompletedIndex(null);
} else if (prevStreaming !== isStreaming) {
setPrevStreaming(isStreaming);
if (!isStreaming && lastRenderedAssistantIndex >= 0) {
setFreshlyCompletedIndex(lastRenderedAssistantIndex);
}
}
return (
<>
{messageRows.map(({ msg, originalIndex, pairedUserMessage }) => {
const i = originalIndex;
if (msg.role === "user") {
const sib =
msg.id !== undefined ? siblingsByMessageId.get(msg.id) : undefined;
return (
);
}
const isActiveAssistant =
isStreaming && i === lastRenderedAssistantIndex;
const msgDone = !isActiveAssistant;
const showActions = msgDone && hasVisibleMarkdownContent(msg.content);
const isLastAssistant = i === lastRenderedAssistantIndex;
const showRegenerate =
showActions &&
!isStreaming &&
isLastAssistant &&
Boolean(pairedUserMessage) &&
(!pairedUserMessage?.capability ||
pairedUserMessage?.capability === "chat");
const deletableTurnUserId =
msgDone && pairedUserMessage?.id != null && onDeleteTurn
? pairedUserMessage.id
: null;
const showDelete = deletableTurnUserId != null;
const costSummary = (() => {
if (!msgDone) return null;
const resultEv = msg.events?.find((e) => e.type === "result");
if (!resultEv) return null;
const meta = resultEv.metadata?.metadata as
| Record
| undefined;
const cs = meta?.cost_summary as
| {
total_cost_usd?: number;
total_tokens?: number;
total_calls?: number;
}
| undefined;
if (!cs || !cs.total_calls) return null;
return cs;
})();
return (
{(() => {
// A turn that died (LLM/provider failure, interruption) ends
// with a turn_terminal error event. Surface it as an error
// card with an inline retry instead of leaving a bare trace.
if (isActiveAssistant) return null;
const terminalError = (msg.events ?? []).find(
(e) =>
e.type === "error" &&
Boolean(
(e.metadata as { turn_terminal?: boolean } | undefined)
?.turn_terminal,
),
);
if (!terminalError) return null;
return (