refactor(chat): render rectification activity through the shared step timeline

The rectification surface had its own activity pipeline: a trace panel
with a 20px canvas orb clipped inside a 14px marker, a list that never
collapsed, a second receipt disclosure under every reply, an inline
failure banner and a staged label while regenerating. Its trace and
receipt are now projected onto ConsultationTimelineRow so both surfaces
render one ConsultationRunTimeline with one live marker and one settled
summary; receipt methods become source chips on the last completed row.
The unreachable sections-report/step-tree path, the trace panel, the
receipt component and the thinking-orbs dependency are removed. The
server-side drop of rectification thinking deltas is untouched.

BUG-476

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-02 04:02:15 +00:00
co-authored by Claude Fable 5.1
parent be5e810ac9
commit ccdae76dd3
17 changed files with 313 additions and 819 deletions
@@ -71,21 +71,16 @@ test("gsap loads on demand while keeping the reduced-motion gate", () => {
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
});
test("the thinking orb loads on demand behind a same-size placeholder", () => {
// Given: only the state type is imported at build time.
assert.doesNotMatch(activitySource, /^import \{ ThinkingOrb/m);
assert.match(activitySource, /^import type \{ OrbState \} from "thinking-orbs";$/m);
assert.match(activitySource, /dynamic\(async \(\) => \(await importThinkingOrb\(\)\)\.ThinkingOrb/);
assert.match(activitySource, /ssr: false/);
// Then: the placeholder reserves the exact 20px orb box so the row cannot shift.
assert.match(activitySource, /loading: \(\) => \(/);
assert.match(activitySource, /height: 20, width: 20/);
assert.match(activitySource, /flex: "0 0 auto"/);
test("the live marker is the shared inline spinner, with no on-demand orb chunk", () => {
// Former test: "the thinking orb loads on demand behind a same-size placeholder". The canvas
// orb was a second live-marker vocabulary beside the timeline's InlineSpinner, undocumented in
// DESIGN.md §9, and clipped inside the rectification surface's 14px marker (BUG-476). Removed.
assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|next\/dynamic/);
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
// And: the live region and the decorative marking survive.
assert.match(activitySource, /role="status"/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-thinking-marker is-live-marker" aria-hidden="true"/);
});
test("deferred chat chunks are requested during idle time after first paint", () => {
@@ -93,6 +88,5 @@ test("deferred chat chunks are requested during idle time after first paint", ()
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
assert.match(activitySource, /prefetchOnIdle\(importThinkingOrb\)/);
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
});
+14 -23
View File
@@ -8,8 +8,6 @@ const transcriptSource = readFileSync(new URL("../src/components/chat-transcript
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const reportSource = readFileSync(new URL("../src/components/consultation-thinking-report.tsx", import.meta.url), "utf8");
const treeSource = readFileSync(new URL("../src/components/thinking-step-tree.tsx", import.meta.url), "utf8");
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
@@ -80,7 +78,9 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
// Former lock: `<ThinkingOrb aria-hidden="true" state={state} size={20}` — the canvas orb was a
// second live-marker vocabulary next to the timeline's InlineSpinner (BUG-476). One marker now.
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-thinking-timeline"/);
@@ -104,20 +104,14 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /activeStreamingSections/);
assert.match(pageSource, /streamedThinking/);
assert.match(messageRowSource, /showLiveActivity/);
assert.match(messageRowSource, /ConsultationThinkingReport/);
assert.match(messageRowSource, /thinkingText=\{hasTrace \? undefined : message\.thinkingText\}/);
assert.match(messageRowSource, /activityTrace=\{message\.activityTrace\}/);
assert.match(messageRowSource, /showLiveActivity \|\| \(!showReport && consultTimeline === undefined && \(Boolean\(message\.thinkingText/);
assert.match(activitySource, /activityTrace \?\? \[\]/);
assert.match(activitySource, /function TraceStep/);
assert.match(globalStyles, /\.agent-thinking-step\.is-think:has\(\.message-thinking\)/);
assert.match(reportSource, /caption="思考"/);
assert.match(reportSource, /reasoning=\{thinkingText\}/);
assert.match(reportSource, /aria-label="分析"/);
assert.match(treeSource, /consultation-step-tree__reasoning/);
assert.match(reportSource, /revealAll/);
assert.match(reportSource, /groups=\{progressed\.map/);
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
// Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`,
// `function TraceStep`, `.agent-thinking-step.is-think:has(...)`, and the report/tree sources:
// those pinned the second and third thinking-render paths (sections report, trace panel) that
// no caller could reach once every reply carries a timeline (BUG-476). One path remains.
assert.doesNotMatch(messageRowSource, /ConsultationThinkingReport|showReport|hasTrace|activityTrace/);
assert.match(messageRowSource, /thinkingText=\{message\.thinkingText\}/);
assert.match(messageRowSource, /showLiveActivity \|\| \(consultTimeline === undefined && Boolean\(message\.thinkingText/);
assert.doesNotMatch(activitySource, /activityTrace|thinking-orbs/);
assert.match(globalStyles, /\.markdown-list/);
assert.match(activitySource, />思考</);
assert.match(pageSource, /event\.type === "run\.failed"/);
@@ -144,12 +138,9 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(reducerSource, /kind: "calculate"/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
assert.match(treeSource, /consultation-step-tree__stage/);
assert.match(treeSource, /阶段 \{index \+ 1\}/);
assert.doesNotMatch(treeSource, /consultation-step-tree__intent/);
assert.match(globalStyles, /\.consultation-step-tree__stage-index/);
assert.match(globalStyles, /\.consultation-step-tree__group \+ \.consultation-step-tree__group[^}]*margin-top:\s*var\(--space-6\)/);
// Former locks on `.consultation-thinking-report .message-thinking`, `consultation-step-tree__*`
// and the tree source: the step-tree report path is deleted (BUG-476), and with it its CSS.
assert.doesNotMatch(globalStyles, /consultation-step-tree/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
@@ -54,7 +54,6 @@ const tailwindCollisions = new Set([
const knownUnstyled = new Set([
"birth-time-evidence-receipt",
"chart-nav",
"consultation-step-tree",
"is-changed",
"is-done",
"is-live",
@@ -99,8 +99,16 @@ test("answer deltas preserve a still-running server activity", () => {
chatSource.indexOf('event.type === "answer.delta"'),
chatSource.indexOf('event.type === "run.failed"'),
);
assert.match(deltaBranch, /state: raw\.trim\(\) \? "streaming" : "thinking"/);
assert.match(deltaBranch, /text: raw,/);
// Former locks: `state: raw.trim() ? "streaming" : "thinking"` and `text: raw,` inside the
// delta branch. Those pinned one setMessages per network chunk (BUG-473); the same state
// derivation now lives in the frame buffer's flush, which the delta branch feeds.
const frameFlush = chatSource.slice(
chatSource.indexOf("const frames = createStreamFrameBuffer"),
chatSource.indexOf("const abortController = new AbortController()"),
);
assert.match(frameFlush, /state: text\.trim\(\) \? "streaming" : "thinking"/);
assert.match(frameFlush, /^\s*text,$/m);
assert.match(deltaBranch, /frames\.setAnswer\(raw\)/);
assert.match(deltaBranch, /event\.replace === true \? event\.text : raw \+ event\.text/);
assert.doesNotMatch(deltaBranch, /settled\.spoken/);
assert.match(deltaBranch, /正在组织回答/);
@@ -110,7 +118,11 @@ test("answer deltas preserve a still-running server activity", () => {
test("failed server turns retain their receipt state without parsing activity from answer text", () => {
assert.match(chatSource, /failed:\s*true/);
assert.match(chatSource, /completedReceipt/);
assert.match(chatSource, /message\.state === "settled" && message\.failed/);
// Former lock: `message.state === "settled" && message.failed`, the inline red banner above a
// failed reply. That banner was the second, rectification-only failure surface (BUG-476);
// failure now reaches the shared error notice while the receipt state still gates actions.
assert.match(chatSource, /&& !message\.failed/);
assert.doesNotMatch(chatSource, /rectification-activity-failure/);
assert.doesNotMatch(chatSource, /parsed\.(?:activity|receipt|tools|methods)/);
});
@@ -44,10 +44,6 @@ const progressLabels = readFileSync(
new URL("../src/lib/rectification-activity-labels.ts", import.meta.url),
"utf8",
);
const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
);
const choiceCardComponent = readFileSync(
new URL("../src/components/rectification-choice-card.tsx", import.meta.url),
"utf8",
@@ -344,9 +340,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
const replyIndex = messageRender.indexOf("<ChatMessageRow");
assert.ok(replyIndex >= 0);
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
assert.match(completedActivityReceipt, /<details className=/);
assert.doesNotMatch(completedActivityReceipt, /<details[^>]*\sopen/);
assert.match(completedActivityReceipt, /本轮完成 · \$\{stepLabels\.length\} 个步骤 · \$\{receipt\.methods\.length\} 项计算依据/);
// Former locks on `completed-activity-receipt.tsx` (`<details>`, never open, the "本轮完成 · N 个
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
assert.match(chat, /completedReceipt/);
for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮做了什么"]) {
assert.doesNotMatch(chat, new RegExp(genericCopy));
}
@@ -360,7 +357,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.doesNotMatch(chat, /本轮处理未完成,已保留服务端记录的执行进度/);
assert.match(chat, /startActivityTraceStep/);
assert.match(chat, /completeActivityTraceStep/);
assert.match(activityStatus, /activityTrace/);
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
assert.match(chat, /rectificationTimelineRows/);
assert.doesNotMatch(activityStatus, /activityTrace/);
assert.match(activityStatus, /className="message-thinking"/);
assert.match(activityStatus, />思考</);
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
completeActivityTraceStep,
emptyActivityTrace,
startActivityTraceStep,
} from "../src/lib/agent-activity-trace.ts";
import {
RECTIFICATION_TIMELINE_LIVE_ID,
RECTIFICATION_TIMELINE_SOURCE_LIMIT,
rectificationTimelineRows,
} from "../src/lib/rectification-timeline-adapter.ts";
import { PUBLIC_RECTIFICATION_METHODS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const chatSource = read("src/components/rectification-agentic-chat.tsx");
const rowSource = read("src/components/chat-message-row.tsx");
const activitySource = read("src/components/agent-activity-status.tsx");
const packageJson = read("package.json");
const globalStyles = read("src/app/globals.css");
test("a started tool is a live calculate row and a completed tool a done row with the done label", () => {
const started = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
assert.deepEqual(rectificationTimelineRows({ trace: started, receipt: undefined, activity: undefined, settled: false }), [
{ id: "activity-rectification-read-case-1", kind: "calculate", status: "live", label: "正在读取校正记录…" },
]);
const done = completeActivityTraceStep(started, "rectification-read-case", "读取校正记录");
assert.deepEqual(rectificationTimelineRows({
trace: done,
receipt: { steps: ["rectification-read-case"], methods: [] },
activity: undefined,
settled: true,
}), [
{ id: "activity-rectification-read-case-1", kind: "calculate", status: "done", label: "读取校正记录" },
]);
});
test("a failed tool keeps its row with a 未完成 suffix and never carries the method chips", () => {
let trace = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
trace = completeActivityTraceStep(trace, "rectification-read-case", "读取校正记录");
trace = startActivityTraceStep(trace, "rectification-compare-candidates", "正在比较候选时间…", 2);
trace = completeActivityTraceStep(trace, "rectification-compare-candidates", "比较候选时间");
const rows = rectificationTimelineRows({
trace,
receipt: { steps: ["rectification-read-case"], methods: ["d1-rashi"], failedTool: "rectification-compare-candidates" },
activity: undefined,
settled: true,
});
assert.equal(rows.length, 2);
assert.equal(rows[1]?.label, "比较候选时间未完成");
assert.equal(rows[1]?.sources, undefined);
assert.deepEqual(rows[0]?.sources, ["D1 本命盘"]);
});
test("method chips are de-duplicated, labelled, capped, and attached to the last completed row", () => {
let trace = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
trace = completeActivityTraceStep(trace, "rectification-read-case", "读取校正记录");
trace = startActivityTraceStep(trace, "rectification-compare-candidates", "正在比较候选时间…", 2);
trace = completeActivityTraceStep(trace, "rectification-compare-candidates", "比较候选时间");
const rows = rectificationTimelineRows({
trace,
receipt: {
steps: ["rectification-read-case", "rectification-compare-candidates"],
methods: [...PUBLIC_RECTIFICATION_METHODS, ...PUBLIC_RECTIFICATION_METHODS],
},
activity: undefined,
settled: true,
});
assert.equal(rows[0]?.sources, undefined);
const chips = rows[1]?.sources ?? [];
assert.equal(chips.length, RECTIFICATION_TIMELINE_SOURCE_LIMIT);
assert.equal(new Set(chips).size, chips.length);
for (const chip of chips) assert.doesNotMatch(chip, /^d\d+-|-/);
});
test("a phase label that names work under way becomes the live row when no tool is live", () => {
const queued = rectificationTimelineRows({
trace: emptyActivityTrace(),
receipt: undefined,
activity: { phase: "evidence-validation", label: "正在处理…" },
settled: false,
});
assert.deepEqual(queued, [{ id: RECTIFICATION_TIMELINE_LIVE_ID, kind: "calculate", status: "live", label: "正在处理…" }]);
const composing = rectificationTimelineRows({
trace: emptyActivityTrace(),
receipt: undefined,
activity: { phase: "answer-composition", label: "正在组织回答…" },
settled: false,
});
assert.equal(composing[0]?.kind, "write");
// A done label left on the activity after a tool completed is not a live step.
const doneLabel = rectificationTimelineRows({
trace: emptyActivityTrace(),
receipt: undefined,
activity: { phase: "chart-calculation", label: "比较候选时间" },
settled: false,
});
assert.deepEqual(doneLabel, []);
// A live tool row already covers the activity, so nothing is appended.
const live = startActivityTraceStep(emptyActivityTrace(), "rectification-read-case", "正在读取校正记录…", 1);
assert.equal(rectificationTimelineRows({
trace: live,
receipt: undefined,
activity: { phase: "loading-method", label: "正在读取校正记录…" },
settled: false,
}).length, 1);
});
test("after attempt.reset the timeline is empty, and a settled reply without a receipt renders no rows", () => {
assert.deepEqual(rectificationTimelineRows({ trace: emptyActivityTrace(), receipt: undefined, activity: undefined, settled: false }), []);
assert.deepEqual(rectificationTimelineRows({ trace: undefined, receipt: { steps: [], methods: [] }, activity: undefined, settled: true }), []);
});
test("both chat surfaces render one timeline, one live marker, and no second receipt block", () => {
assert.match(chatSource, /rectificationTimelineRows\(\{/);
assert.match(chatSource, /timeline: rectificationTimelineRows/);
assert.doesNotMatch(chatSource, /rectification-activity-failure|<CompletedActivityReceipt/);
assert.doesNotMatch(chatSource, /label: "正在组织回答…",\s*\}\),\s*\}\s*:\s*message;/);
assert.doesNotMatch(rowSource, /ConsultationThinkingReport|showReport|activityTrace/);
assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|\bTraceStep\b|activityTrace/);
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
assert.doesNotMatch(packageJson, /thinking-orbs/);
assert.doesNotMatch(globalStyles, /\.conversation\.is-rectification \.message-assistant \.agent-thinking-(step|marker)/);
assert.doesNotMatch(globalStyles, /rectification-activity-receipt|rectification-activity-failure|consultation-step-tree/);
});