fix(web): keep later thinking below rectification tool steps

BUG-360: stream thinking and tool activity as an ordered trace so later CoT opens under 正在整理 instead of filling the first 思考 block.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 17:02:06 +08:00
co-authored by Cursor
parent f18eb195d9
commit 6b225a288c
12 changed files with 346 additions and 18 deletions
@@ -7,6 +7,7 @@ import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
const labels = {
working: "正在处理任务…",
@@ -71,6 +72,7 @@ export function AgentActivityStatus({
startedAt,
completedTrail,
thinkingText,
activityTrace,
hasAnswer = false,
showLive = true,
}: Readonly<{
@@ -79,11 +81,29 @@ export function AgentActivityStatus({
startedAt?: number;
completedTrail?: string;
thinkingText?: string;
activityTrace?: readonly AgentActivityTraceItem[];
hasAnswer?: boolean;
showLive?: boolean;
}>) {
const completedSteps = activityCompletedSteps(completedTrail);
const live = showLive && !hasAnswer;
const trace = activityTrace ?? [];
if (trace.length > 0) {
return (
<div className="agent-thinking-panel agent-activity-status">
<ol className="agent-thinking-timeline">
{trace.map((item) => (
<TraceStep
key={item.id}
item={item}
state={state}
hasAnswer={hasAnswer}
/>
))}
</ol>
</div>
);
}
if (!live && completedSteps.length === 0 && !thinkingText?.trim()) return null;
return (
@@ -115,3 +135,45 @@ export function AgentActivityStatus({
</div>
);
}
function TraceStep({
item,
state,
hasAnswer,
}: Readonly<{
item: AgentActivityTraceItem;
state: AgentActivityState;
hasAnswer: boolean;
}>) {
if (item.kind === "think") {
return (
<li className={`agent-thinking-step is-think is-${item.status}`}>
{item.text?.trim() ? (
<MessageThinkingTrace text={item.text} hasAnswer={hasAnswer || item.status === "done"} />
) : (
<>
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
</span>
<span>{item.label}</span>
</>
)}
</li>
);
}
return (
<li className={`agent-thinking-step is-${item.status}`}>
<span className={`agent-thinking-marker${item.status === "live" ? " is-live-marker" : ""}`} aria-hidden="true">
{item.status === "live" ? <ThinkingOrb aria-hidden="true" state={state} size={20} /> : <Check />}
</span>
{item.status === "live" ? (
<span className="agent-activity-status__live" role="status">
<span key={item.label} className="agent-activity-status__text">{item.label}</span>
{item.startedAt ? <ActivityElapsed key={item.startedAt} startedAt={item.startedAt} /> : null}
</span>
) : (
<span>{item.label}</span>
)}
</li>
);
}
+4 -2
View File
@@ -64,9 +64,10 @@ export function ChatMessageRow({
const hasAnswer = Boolean(message.text.trim());
const consultTimeline = message.timeline;
const thinkingSections = message.thinkingSections ?? [];
const hasTrace = (message.activityTrace?.length ?? 0) > 0;
const showReport = consultTimeline === undefined && thinkingSections.length > 0;
const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && (Boolean(message.thinkingText?.trim()) || hasTrace));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
@@ -101,7 +102,8 @@ export function ChatMessageRow({
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
thinkingText={hasTrace ? undefined : message.thinkingText}
activityTrace={message.activityTrace}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
/>
@@ -6,6 +6,16 @@ import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import {
appendActivityTraceThinking,
completeActivityTrace,
completeActivityTraceStep,
emptyActivityTrace,
freezeLiveThink,
startActivityTraceStep,
type AgentActivityTraceItem,
} from "@/lib/agent-activity-trace";
import {
RECTIFICATION_TOOL_DONE_LABELS,
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
rectificationToolActivityPhase,
@@ -151,6 +161,7 @@ type RenderMessage = ChatMessageView & {
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
};
function turnOfferedSelection(message: RenderMessage): boolean {
@@ -383,7 +394,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: {
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activityTrace: emptyActivityTrace(), activity: {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
@@ -393,6 +404,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
let raw = "";
let thinkingRaw = "";
let activityTrace: readonly AgentActivityTraceItem[] = emptyActivityTrace();
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
@@ -464,6 +476,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
activityTrace = freezeLiveThink(activityTrace);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
@@ -471,6 +484,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace,
state: parsed.text ? "streaming" : "thinking",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
@@ -480,6 +494,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
thinkingRaw += event.text;
activityTrace = appendActivityTraceThinking(activityTrace, event.text);
const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
@@ -487,12 +502,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace,
state: parsed.text ? "streaming" : "thinking",
}
: message));
} else if (event.type === "attempt.reset") {
raw = "";
thinkingRaw = "";
activityTrace = emptyActivityTrace();
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
@@ -501,6 +518,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: "",
thinkingText: undefined,
activityTrace,
state: "thinking",
completedReceipt: undefined,
failed: false,
@@ -523,9 +541,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
if (event.status === "started") {
activityTrace = startActivityTraceStep(
activityTrace,
tool,
RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activityTrace,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
@@ -536,6 +560,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityTrace = completeActivityTraceStep(
activityTrace,
tool,
RECTIFICATION_TOOL_DONE_LABELS[tool],
);
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
status: event.status,
@@ -544,6 +573,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activityTrace,
completedReceipt,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_DONE_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
}
}
}
@@ -558,6 +599,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: false,
@@ -570,6 +612,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: true,
@@ -607,6 +650,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...message,
text: parsed.text,
thinkingText: settled.thinking.trim() || undefined,
activityTrace: completeActivityTrace(activityTrace),
state: "settled",
completedReceipt,
failed: false,