feat(web): show consult runs as a Lucide timeline with sliced compose

Keep provider thinking on a separate channel so process talk is not billed as the spoken reply (BUG-359).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 14:38:50 +08:00
co-authored by Cursor
parent 22010e81b7
commit 04463e9af3
29 changed files with 1410 additions and 201 deletions
+45 -27
View File
@@ -3,6 +3,7 @@
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
@@ -61,9 +62,11 @@ export function ChatMessageRow({
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const consultTimeline = message.timeline;
const thinkingSections = message.thinkingSections ?? [];
const showReport = thinkingSections.length > 0;
const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
const showReport = consultTimeline === undefined && thinkingSections.length > 0;
const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
@@ -100,11 +103,11 @@ export function ChatMessageRow({
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
showLive={showActivity}
showLive={showLiveActivity}
/>
)
: null;
const spokenAnswer = showSpokenAnswer
const spokenAnswer = showSpokenAnswer || (consultTimeline !== undefined && hasAnswer)
? (
<ChatMessageContent
text={message.text}
@@ -124,33 +127,48 @@ export function ChatMessageRow({
<div className="message-content">
<div className="message-bubble">
{message.role === "assistant" ? (
<>
{showReport && (
<ConsultationThinkingReport
sections={thinkingSections}
answer={message.text}
live={showActivity && !hasAnswer}
liveLabel={activityLabel}
liveState={activityState}
startedAt={message.activity?.startedAt}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
consultTimeline !== undefined ? (
<div className="consultation-thinking-report">
<ConsultationRunTimeline
rows={consultTimeline}
live={showActivity && message.state !== "settled"}
/>
)}
{stackedThinkingAndAnswer ? (
<div className="consultation-thinking-report">
{thinkingPanel}
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
</>
)}
</>
) : null}
</div>
) : (
<>
{showReport && (
<ConsultationThinkingReport
sections={thinkingSections}
thinkingText={message.thinkingText}
answer={message.text}
live={showActivity && !hasAnswer}
liveLabel={activityLabel}
liveState={activityState}
startedAt={message.activity?.startedAt}
auditRows={message.agentExecutionReceipt?.techniqueAuditTable}
vargaSentence={vargaSentence}
/>
)}
{stackedThinkingAndAnswer ? (
<div className="consultation-thinking-report">
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
</>
)}
</>
)
) : <p>{message.text}</p>}
</div>
</div>
@@ -0,0 +1,118 @@
"use client";
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
import type {
ConsultationTimelineKind,
ConsultationTimelineRow,
} from "@/lib/consultation-run-timeline";
const KIND_ICONS: Record<ConsultationTimelineKind, LucideIcon> = {
method: BookOpen,
calculate: Layers,
think: ListTodo,
write: PenLine,
};
export function ConsultationRunTimeline({
rows,
live = false,
}: Readonly<{
rows: readonly ConsultationTimelineRow[];
live?: boolean;
}>) {
if (rows.length === 0) return null;
return (
<details
key={live ? "live" : "settled"}
className="consultation-run-timeline"
open={live ? true : undefined}
>
<summary className="consultation-run-timeline__summary">
<span className="consultation-run-timeline__summary-label">
{live ? "正在分析" : `已完成 ${rows.length}`}
</span>
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
</summary>
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
{rows.map((row) => (
<TimelineRow key={row.id} row={row} />
))}
</ol>
</details>
);
}
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
const KindIcon = KIND_ICONS[row.kind];
const expandable = Boolean(
(row.queries && row.queries.length > 0)
|| (row.sources && row.sources.length > 0)
|| row.thinkingText?.trim(),
);
const marker = (
<span
className={`agent-thinking-marker${row.status === "live" ? " is-live-marker" : ""}`}
aria-hidden="true"
>
{row.status === "live"
? <LoaderCircle className="consultation-run-timeline__spinner" />
: <Check />}
</span>
);
const label = (
<span className="consultation-run-timeline__label">
<KindIcon className="consultation-run-timeline__kind-icon" aria-hidden="true" />
<span>{row.label}</span>
</span>
);
if (!expandable) {
return (
<li
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
data-kind={row.kind}
data-status={row.status}
>
{marker}
{label}
</li>
);
}
return (
<li
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
data-kind={row.kind}
data-status={row.status}
>
<details className="consultation-run-timeline__details">
<summary>
{marker}
{label}
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
</summary>
<div className="consultation-run-timeline__body">
{row.queries && row.queries.length > 0 ? (
<ul className="consultation-run-timeline__queries">
{row.queries.map((query) => (
<li key={query}>{query}</li>
))}
</ul>
) : null}
{row.sources && row.sources.length > 0 ? (
<div className="consultation-run-timeline__sources">
{row.sources.map((source) => (
<span className="consultation-run-timeline__source" key={source}>{source}</span>
))}
</div>
) : null}
{row.thinkingText?.trim() ? (
<p className="consultation-run-timeline__thinking">{row.thinkingText}</p>
) : null}
</div>
</details>
</li>
);
}
@@ -11,6 +11,7 @@ import {
export function ConsultationThinkingReport({
sections,
answer,
thinkingText,
live = false,
liveLabel,
liveState,
@@ -20,6 +21,7 @@ export function ConsultationThinkingReport({
}: Readonly<{
sections: readonly PublicThinkingSection[];
answer: string;
thinkingText?: string;
live?: boolean;
liveLabel?: string;
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
@@ -39,6 +41,7 @@ export function ConsultationThinkingReport({
<div className="consultation-thinking-report">
<ThinkingStepTree
caption="思考"
reasoning={thinkingText}
groups={progressed.map((section, index) => ({
id: section.id,
intent: section.title,
@@ -33,7 +33,10 @@ import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
import {
finalizeRectificationSpokenAndThinking,
settleRectificationSpokenAndThinking,
} from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
@@ -207,7 +210,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const thinkingText = split.thinking.trim() || undefined;
return [{
role: "assistant",
text: split.spoken || raw,
text: split.spoken,
...(thinkingText ? { thinkingText } : {}),
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
@@ -461,12 +464,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
const parsed = parseAgentReply(raw);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: parsed.text,
state: "streaming",
thinkingText: settled.thinking.trim() || undefined,
state: parsed.text ? "streaming" : "thinking",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
@@ -475,11 +480,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
thinkingRaw += event.text;
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
thinkingText: thinkingRaw,
state: raw ? "streaming" : "thinking",
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
state: parsed.text ? "streaming" : "thinking",
}
: message));
} else if (event.type === "attempt.reset") {
@@ -540,7 +548,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}
const parsed = parseAgentReply(raw);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
@@ -548,6 +557,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return [{
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: false,
@@ -555,10 +565,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text || settled.thinking.trim()) {
return [{
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: true,
@@ -587,13 +598,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
const parsed = parseAgentReply(raw);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
if (parsed.text) {
if (parsed.text || settled.thinking.trim()) {
return [{
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: false,
+45 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useId, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
@@ -117,6 +117,7 @@ function StepList({
export function ThinkingStepTree({
caption,
intent,
reasoning,
steps = [],
groups,
hiddenCount,
@@ -129,6 +130,7 @@ export function ThinkingStepTree({
}: Readonly<{
caption?: string;
intent?: string;
reasoning?: string;
steps?: readonly PublicThinkingStep[];
groups?: readonly ThinkingStepGroup[];
hiddenCount?: number;
@@ -140,6 +142,7 @@ export function ThinkingStepTree({
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const treeId = useId();
const groupItems = groups ?? [{
id: "default",
intent,
@@ -147,21 +150,43 @@ export function ThinkingStepTree({
live,
}];
const showAll = revealAll || hiddenCount === 0;
const stagedCount = groupItems.filter((group) => Boolean(group.intent)).length;
const body = (
<>
{groupItems.map((group) => (
<div className="consultation-step-tree__group" key={group.id}>
{group.intent ? <p className="consultation-step-tree__intent">{group.intent}</p> : null}
<StepList
steps={group.steps}
revealAll={showAll}
live={Boolean(group.live)}
liveLabel={group.live ? liveLabel : undefined}
liveState={liveState}
startedAt={group.live ? startedAt : undefined}
/>
</div>
))}
{groupItems.map((group, index) => {
const stageId = `${treeId}-${group.id}-stage`;
return (
<section
className="consultation-step-tree__group"
key={group.id}
aria-labelledby={group.intent ? stageId : undefined}
>
{group.intent ? (
<h3 className="consultation-step-tree__stage" id={stageId}>
{stagedCount > 1 ? (
<span className="consultation-step-tree__stage-index" aria-hidden="true">
{index + 1}
</span>
) : (
<span className="consultation-step-tree__stage-mark" aria-hidden="true" />
)}
<span className="consultation-step-tree__stage-title">
{stagedCount > 1 ? <span className="sr-only"> {index + 1}</span> : null}
{group.intent}
</span>
</h3>
) : null}
<StepList
steps={group.steps}
revealAll={showAll}
live={Boolean(group.live)}
liveLabel={group.live ? liveLabel : undefined}
liveState={liveState}
startedAt={group.live ? startedAt : undefined}
/>
</section>
);
})}
</>
);
@@ -177,7 +202,12 @@ export function ThinkingStepTree({
}}
>
<summary>{caption}</summary>
<div className="message-thinking-body">{body}</div>
<div className="message-thinking-body">
{reasoning?.trim() ? (
<div className="consultation-step-tree__reasoning">{reasoning}</div>
) : null}
{body}
</div>
</details>
);
}