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:
co-authored by
Claude Fable 5.1
parent
be5e810ac9
commit
ccdae76dd3
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user