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-01 23:10:28 +00:00
parent be5e810ac9
commit ccdae76dd3
17 changed files with 313 additions and 819 deletions
+16
View File
@@ -7314,3 +7314,19 @@
- 相关记录:BUG-474
- 复发自:无
- 修复版本:待发布
## BUG-476 | 生时校正会话的 Agent 活动 UI 与普通会话是两套,live 标记被 14px 格子裁切
- 状态:resolved
- 首次发现:2026-09-01
- 最近更新:2026-09-01
- 影响面:`rectification-agentic-chat.tsx``agent-activity-status.tsx``chat-message-row.tsx``globals.css``package.json``thinking-orbs`
- 用户现象:校正会话里步骤标记是一个 20px 的 canvas 小球,被压在 14px 的格子里边缘裁切;结算后步骤列表永远全展开,下面还多挂一个「本轮完成 · N 个步骤」折叠块;失败时消息上方多一行红字;重新生成时先出现一个假的「正在组织回答…」;普通会话则是行内 spinner、结算收成「已完成 N 步」一行。两边看起来不像一个产品。
- 触发条件:任何一次校正回合与任何一次咨询回合并排对比。
- 根因:校正走 `activityTrace` + `AgentActivityStatus` 的 trace 分支,普通会话走 `ConsultationRunTimeline``chat-message-row.tsx` 里并存三条思考渲染路径(timeline、`ConsultationThinkingReport`+`ThinkingStepTree`、trace panel),其中 sections report 已无任何调用方可达;`.conversation.is-rectification .agent-thinking-marker { 14px }` 覆写与 `ThinkingOrb size={20}` 冲突。
- 修复:新增 `lib/rectification-timeline-adapter.ts`,把 trace/receipt/activity 映射成 `ConsultationTimelineRow`tool → calculate 行;失败 tool 标签加「未完成」;回执方法作为最后一条完成行的 sources chips,去重 ≤8;无 live 行时把「正在…」类活动文案作为 live 行,answer-composition 归 write)。校正消息由此走同一个 `ConsultationRunTimeline`。删除 `consultation-thinking-report.tsx``thinking-step-tree.tsx``completed-activity-receipt.tsx` 及其 CSS`AgentActivityStatus` 只保留无 timeline 的兜底,live 标记统一 `InlineSpinner` 12px,移除 `thinking-orbs` 依赖;删除 14px 覆写;失败态改走既有 error 通知;重新生成显示 queued 行由真实事件填充。校正 `thinking.delta` 仍在服务端公开边界丢弃,本轮未动。
- 验证:`tests/rectification-timeline-adapter.test.ts``chat-stream-layout``chat-bundle-splitting-contract``rectification-agentic-entry``rectification-activity-receipt` 中锁死路径的断言按「注明原值与错因」改为锁新路径。
- 防复发:任何会话面的 Agent 活动都必须投影成 `ConsultationTimelineRow` 交给 `ConsultationRunTimeline`live 标记只允许 `InlineSpinner`;不得再引入第二种等待词汇。
- 相关记录:BUG-474、BUG-475
- 复发自:无
- 修复版本:待发布
-107
View File
@@ -39,7 +39,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"thinking-orbs": "^0.1.1",
"tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
@@ -1745,9 +1744,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1764,9 +1760,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1783,9 +1776,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1802,9 +1792,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1821,9 +1808,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1840,9 +1824,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1859,9 +1840,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1878,9 +1856,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1897,9 +1872,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1922,9 +1894,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1947,9 +1916,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1972,9 +1938,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1997,9 +1960,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2022,9 +1982,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2047,9 +2004,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2072,9 +2026,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2508,9 +2459,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2527,9 +2475,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2546,9 +2491,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2565,9 +2507,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3886,9 +3825,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3903,9 +3839,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3920,9 +3853,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3937,9 +3867,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4205,9 +4132,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4224,9 +4148,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4243,9 +4164,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4262,9 +4180,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -9371,9 +9286,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9394,9 +9306,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9417,9 +9326,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -9440,9 +9346,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13217,16 +13120,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/thinking-orbs": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.1.1.tgz",
"integrity": "sha512-nLvLTGJtk74K13MmP7XiRdzFiQx7UsoZAIyBZNgXyl7Q3h2mdz0r3eKn9LCsuzb3DGOVjwDvMhtnAkIqJJRVQA==",
"license": "MIT",
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
-1
View File
@@ -48,7 +48,6 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"thinking-orbs": "^0.1.1",
"tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
-152
View File
@@ -426,13 +426,6 @@ button:disabled { cursor: default; opacity: .45; }
gap: var(--space-2);
min-height: 24px;
}
.agent-thinking-step.is-think:has(.message-thinking) {
display: block;
min-height: 0;
}
.agent-thinking-step.is-think .message-thinking {
margin-bottom: 0;
}
.agent-thinking-marker {
width: 20px;
height: 20px;
@@ -449,8 +442,6 @@ button:disabled { cursor: default; opacity: .45; }
}
.agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; }
.agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.agent-activity-status canvas,
.agent-thinking-marker canvas { flex: 0 0 auto; }
.agent-activity-status__elapsed,
.agent-thinking-elapsed {
flex: 0 0 auto;
@@ -1054,67 +1045,10 @@ button:disabled { cursor: default; opacity: .45; }
line-height: 1.55;
white-space: pre-wrap;
}
.consultation-step-tree__group {
display: grid;
gap: var(--space-2);
}
.consultation-step-tree__stage {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: 24px;
margin: 0;
color: var(--color-ink-strong);
font-family: inherit;
font-size: var(--type-caption);
font-weight: 600;
line-height: 1.35;
text-wrap: balance;
}
.consultation-step-tree__stage-index,
.consultation-step-tree__stage-mark {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: var(--radius-xs);
background: var(--color-canvas-muted);
box-shadow: inset 0 0 0 1px var(--color-border);
color: var(--color-ink-secondary);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.consultation-step-tree__stage-mark {
background: transparent;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
}
.consultation-step-tree__stage-title {
min-width: 0;
}
.agent-thinking-marker.is-pending {
background: var(--color-canvas-muted);
box-shadow: inset 0 0 0 1px var(--color-border);
}
.agent-thinking-step.is-more {
color: var(--color-ink-tertiary);
}
.consultation-thinking-report {
display: grid;
gap: var(--space-6);
}
.consultation-step-tree__group + .consultation-step-tree__group {
margin-top: var(--space-6);
}
.consultation-step-tree__reasoning {
margin: 0 0 var(--space-4);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
line-height: 1.55;
white-space: pre-wrap;
}
.consultation-report-analysis {
min-width: 0;
}
@@ -1124,11 +1058,6 @@ button:disabled { cursor: default; opacity: .45; }
.consultation-report-analysis .message-markdown {
color: var(--color-ink-strong);
}
.consultation-thinking-report .message-thinking {
margin-bottom: 0;
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
}
.consultation-run-timeline {
min-width: 0;
}
@@ -2682,14 +2611,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
max-width: 720px;
padding-bottom: var(--rectification-jump-clearance);
}
.conversation.is-rectification .message-assistant .agent-thinking-step {
grid-template-columns: 14px minmax(0, 1fr);
gap: 6px;
}
.conversation.is-rectification .message-assistant .agent-thinking-marker {
width: 14px;
height: 14px;
}
.conversation.is-rectification .message-actions {
margin-inline-start: var(--assistant-content-inset);
}
@@ -3545,78 +3466,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.rectification-message-wrap { display: grid; gap: var(--space-1); }
.rectification-activity-receipt {
width: min(720px, 100%);
margin: 0 0 var(--space-2) 36px;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.rectification-activity-receipt summary {
display: flex;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 0;
color: var(--color-ink-tertiary);
cursor: pointer;
font-weight: 500;
list-style: none;
}
.rectification-activity-receipt summary::-webkit-details-marker { display: none; }
.rectification-activity-receipt summary:focus-visible {
border-radius: var(--radius-sm);
outline: 2px solid var(--color-focus);
outline-offset: 3px;
}
.rectification-activity-receipt__summary-main,
.rectification-activity-receipt__toggle {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.rectification-activity-receipt__summary-main svg { color: var(--color-success); }
.rectification-activity-receipt__toggle { flex: 0 0 auto; color: var(--color-ink-tertiary); }
.rectification-activity-receipt__toggle svg { transition: transform 160ms ease; }
.rectification-activity-receipt[open] .rectification-activity-receipt__toggle svg { transform: rotate(180deg); }
.rectification-activity-receipt__failure-mark {
display: inline-grid;
width: 15px;
height: 15px;
place-items: center;
border: 1px solid currentColor;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.rectification-activity-receipt.is-failed,
.rectification-activity-failure { color: var(--color-danger); }
.rectification-activity-failure { margin: 0 0 var(--space-2) 36px; font-size: var(--type-caption); }
.rectification-activity-receipt__details {
display: grid;
gap: var(--space-3);
margin-top: var(--space-1);
padding: var(--space-3) 0 var(--space-2) 19px;
border-left: 1px solid var(--color-border);
}
.rectification-activity-receipt__details section { display: grid; gap: var(--space-1); }
.rectification-activity-receipt__details h3 {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
font-weight: 650;
}
.rectification-activity-receipt__details p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; }
.rectification-activity-receipt__method-groups { display: grid; gap: 2px; }
.rectification-activity-receipt__method-groups strong { color: var(--color-ink-secondary); font-weight: 600; }
@media (max-width: 640px) {
.rectification-activity-receipt,
.rectification-activity-failure { margin-left: 32px; }
.rectification-activity-receipt summary { align-items: flex-start; }
.rectification-activity-receipt__toggle { padding-left: var(--space-1); }
}
@media (prefers-reduced-motion: reduce) {
.agent-activity-status__text {
@@ -3624,6 +3473,5 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
background: none;
-webkit-text-fill-color: currentColor;
}
.rectification-activity-receipt__toggle svg { transition: none; }
.technique-audit > summary::before { transition: none; }
}
@@ -1,13 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { InlineSpinner } from "@/components/inline-spinner";
import { activityCompletedSteps, activityElapsedLabel } from "@/lib/chat-message-view";
import type { AgentActivityTraceItem } from "@/lib/agent-activity-trace";
export type AgentActivityState = "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
const labels = {
working: "正在处理任务…",
@@ -16,20 +15,7 @@ const labels = {
listening: "正在聆听…",
composing: "正在组织回答…",
shaping: "正在生成结果…",
} as const satisfies Record<OrbState, string>;
const importThinkingOrb = () => import("thinking-orbs");
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
loading: () => (
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
),
ssr: false,
});
prefetchOnIdle(importThinkingOrb);
export type AgentActivityState = OrbState;
} as const satisfies Record<AgentActivityState, string>;
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
const [now, setNow] = useState(() => Date.now());
@@ -77,13 +63,18 @@ function VargaTraceStep({ sentence }: Readonly<{ sentence: string }>) {
);
}
/**
* Fallback activity panel for an assistant row that carries no step timeline.
* Both chat surfaces normally render `ConsultationRunTimeline`; this remains
* for rows that only have a phase label, a completed trail or thinking text.
* The live marker is the shared `InlineSpinner`, the same one the timeline uses.
*/
export function AgentActivityStatus({
state,
label = labels[state],
startedAt,
completedTrail,
thinkingText,
activityTrace,
vargaSentence,
hasAnswer = false,
showLive = true,
@@ -93,33 +84,14 @@ export function AgentActivityStatus({
startedAt?: number;
completedTrail?: string;
thinkingText?: string;
activityTrace?: readonly AgentActivityTraceItem[];
vargaSentence?: string | null;
hasAnswer?: boolean;
showLive?: boolean;
}>) {
const completedSteps = activityCompletedSteps(completedTrail);
const live = showLive && !hasAnswer;
const trace = activityTrace ?? [];
const varga = vargaSentence?.trim() ?? "";
const showVarga = Boolean(varga) && !trace.some((item) => item.label === varga);
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}
/>
))}
{showVarga ? <VargaTraceStep sentence={varga} /> : null}
</ol>
</div>
);
}
const showVarga = Boolean(varga);
if (!live && completedSteps.length === 0 && !thinkingText?.trim() && !showVarga) return null;
return (
@@ -136,8 +108,8 @@ export function AgentActivityStatus({
))}
{live ? (
<li className="agent-thinking-step is-live">
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span className="agent-thinking-marker is-live-marker" aria-hidden="true">
<InlineSpinner size={12} />
</span>
<span className="agent-activity-status__live" role="status">
<span key={label} className="agent-activity-status__text">{label}</span>
@@ -152,45 +124,3 @@ 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>
);
}
+6 -22
View File
@@ -4,7 +4,6 @@ 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";
@@ -63,12 +62,11 @@ export function ChatMessageRow({
?? (message.state === "thinking" ? "正在处理…" : undefined);
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()) || hasTrace));
const showSpokenAnswer = !showReport && Boolean(message.text);
// Rows with a step timeline (every consultation and rectification reply) render
// `ConsultationRunTimeline`; the activity panel is the fallback for rows without one.
const showLiveActivity = showActivity && consultTimeline === undefined;
const showThinkingPanel = showLiveActivity || (consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
const showSpokenAnswer = Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
useEntryEffect(() => {
@@ -102,8 +100,7 @@ export function ChatMessageRow({
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={hasTrace ? undefined : message.thinkingText}
activityTrace={message.activityTrace}
thinkingText={message.thinkingText}
vargaSentence={vargaSentence}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
@@ -145,19 +142,6 @@ export function ChatMessageRow({
</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="message-stage-and-answer">
{thinkingPanel}
@@ -1,113 +0,0 @@
"use client";
import { Check, ChevronDown } from "lucide-react";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import { RECTIFICATION_TOOL_DONE_LABELS } from "@/lib/rectification-activity-labels";
import type {
PublicRectificationMethod,
PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "@/lib/rectification-varga-sentence";
const TOOL_LABELS = RECTIFICATION_TOOL_DONE_LABELS;
const METHOD_LABELS = PUBLIC_RECTIFICATION_METHOD_LABELS;
const METHOD_GROUPS: readonly Readonly<{
label: string;
methods: readonly PublicRectificationMethod[];
}>[] = [
{
label: "基础判断",
methods: [
"d1-rashi",
"functional-benefic-malefic",
"vimshottari-dasha",
"narayana-dasha",
"arudha-pada",
],
},
{
label: "主题分盘",
methods: [
"d2-hora",
"d4-chaturthamsha",
"d9-navamsa",
"d10-dashamsa",
"d11-labhamsha",
"d24-chaturvimshamsha",
"d30-trimshamsha",
],
},
{
label: "交叉验证",
methods: ["gochara", "ashtakavarga", "shadbala"],
},
];
function failureLabel(tool: PublicRectificationTool): string {
return tool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留"
: `${TOOL_LABELS[tool]}未完成,当前进度已保留`;
}
export function CompletedActivityReceipt({
receipt,
}: Readonly<{ receipt: CompletedActivityReceiptView }>) {
const stepLabels = receipt.steps.map((tool) => TOOL_LABELS[tool]);
const methodSet = new Set(receipt.methods);
const methodGroups = METHOD_GROUPS.map((group) => ({
label: group.label,
methods: group.methods.filter((method) => methodSet.has(method)).map((method) => METHOD_LABELS[method]),
})).filter((group) => group.methods.length > 0);
const hasDetails = stepLabels.length > 0 || methodGroups.length > 0;
const summary = receipt.failedTool
? failureLabel(receipt.failedTool)
: `本轮完成 · ${stepLabels.length} 个步骤 · ${receipt.methods.length} 项计算依据`;
if (!hasDetails) {
return receipt.failedTool
? <p className="rectification-activity-failure">{summary}</p>
: null;
}
return (
<details className={`rectification-activity-receipt${receipt.failedTool ? " is-failed" : ""}`}>
<summary>
<span className="rectification-activity-receipt__summary-main">
{receipt.failedTool ? (
<span className="rectification-activity-receipt__failure-mark" aria-hidden="true">!</span>
) : (
<Check aria-hidden="true" size={14} strokeWidth={2.25} />
)}
<span>{summary}</span>
</span>
<span className="rectification-activity-receipt__toggle">
<ChevronDown aria-hidden="true" size={14} />
</span>
</summary>
<div className="rectification-activity-receipt__details">
{stepLabels.length > 0 && (
<section aria-label="执行步骤">
<h3></h3>
<p>{stepLabels.join(" · ")}</p>
</section>
)}
{methodGroups.length > 0 && (
<section aria-label="计算依据">
<h3></h3>
<div className="rectification-activity-receipt__method-groups">
{methodGroups.map((group) => (
<p key={group.label}>
<strong>{group.label}</strong>
{group.methods.join("、")}
</p>
))}
</div>
</section>
)}
</div>
</details>
);
}
@@ -1,69 +0,0 @@
"use client";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ThinkingStepTree } from "@/components/thinking-step-tree";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
export function ConsultationThinkingReport({
sections,
answer,
thinkingText,
live = false,
liveLabel,
liveState,
startedAt,
auditRows,
vargaSentence,
}: Readonly<{
sections: readonly PublicThinkingSection[];
answer: string;
thinkingText?: string;
live?: boolean;
liveLabel?: string;
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
startedAt?: number;
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
}>) {
const hasAnswer = Boolean(answer.trim());
const progressed = applyThinkingSectionProgress(sections, answer, {
settled: !live && hasAnswer,
});
const activeIndex = progressed.findIndex((section) => (
section.steps.some((step) => step.status === "active")
));
return (
<div className="consultation-thinking-report">
<ThinkingStepTree
caption="思考"
reasoning={thinkingText}
groups={progressed.map((section, index) => ({
id: section.id,
intent: section.title,
steps: section.steps,
live: live && index === activeIndex,
}))}
revealAll
live={live && !hasAnswer}
liveLabel={live && !hasAnswer ? liveLabel : undefined}
liveState={liveState}
startedAt={live && !hasAnswer ? startedAt : undefined}
defaultOpen={live && !hasAnswer}
/>
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="分析">
<ChatMessageContent
text={answer}
auditRows={auditRows}
vargaSentence={vargaSentence}
/>
</section>
) : null}
</div>
);
}
@@ -41,6 +41,7 @@ import {
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { membershipHref } from "@/lib/membership";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
@@ -1158,18 +1159,29 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
const displayedMessage = regenerating
// Both surfaces render the same step timeline: the tool trace and receipt
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
// the queued row until real events fill it in, never a staged label.
const displayedMessage: RenderMessage = regenerating
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: nextActivityView(undefined, {
phase: "answer-composition",
label: "正在组织回答…",
}),
activity: undefined,
activityTrace: emptyActivityTrace(),
completedReceipt: undefined,
timeline: [],
}
: message;
: {
...message,
timeline: rectificationTimelineRows({
trace: message.activityTrace,
receipt: message.completedReceipt,
activity: message.activity,
settled: message.state === "settled",
}),
};
const settledChoice = message.choiceAttachment;
const visibleChoiceCard = settledChoice?.card;
const vargaSentence = !message.failed
@@ -1177,11 +1189,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: null;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.failed && Boolean(message.text) && (
<p className="rectification-activity-failure" role="status">
</p>
)}
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
@@ -1,213 +0,0 @@
"use client";
import { useEffect, useId, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityElapsedLabel } from "@/lib/chat-message-view";
import {
visibleThinkingSteps,
type PublicThinkingStep,
type ThinkingStepStatus,
} from "@/lib/consultation-thinking-plan";
const importThinkingOrb = () => import("thinking-orbs");
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
loading: () => (
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
),
ssr: false,
});
prefetchOnIdle(importThinkingOrb);
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const label = activityElapsedLabel(startedAt, now);
if (!label) return null;
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
}
function StepMarker({
status,
liveState,
}: Readonly<{
status: ThinkingStepStatus | "live";
liveState?: OrbState;
}>) {
if (status === "live" || status === "active") {
return (
<span className="agent-thinking-marker is-live-marker">
<ThinkingOrb aria-hidden="true" state={liveState ?? "working"} size={20} />
</span>
);
}
if (status === "done") {
return (
<span className="agent-thinking-marker" aria-hidden="true">
<Check />
</span>
);
}
return <span className="agent-thinking-marker is-pending" aria-hidden="true" />;
}
type ThinkingStepGroup = Readonly<{
id: string;
intent?: string;
steps: readonly PublicThinkingStep[];
live?: boolean;
}>;
function StepList({
steps,
revealAll,
live,
liveLabel,
liveState,
startedAt,
}: Readonly<{
steps: readonly PublicThinkingStep[];
revealAll: boolean;
live: boolean;
liveLabel?: string;
liveState?: OrbState;
startedAt?: number;
}>) {
const visible = revealAll ? { visible: steps, hiddenCount: 0 } : visibleThinkingSteps(steps);
const hiddenDone = visible.hiddenCount > 0
&& steps.slice(visible.visible.length).every((step) => step.status === "done");
return (
<ol className="agent-thinking-timeline">
{visible.visible.map((step) => (
<li
className={`agent-thinking-step is-${step.status}`}
key={step.id}
>
<StepMarker status={step.status} liveState={liveState} />
<span>{step.label}</span>
</li>
))}
{visible.hiddenCount > 0 ? (
<li className={`agent-thinking-step is-more${hiddenDone ? " is-done" : ""}`}>
<StepMarker status={hiddenDone ? "done" : "pending"} />
<span> {visible.hiddenCount} </span>
</li>
) : null}
{live && liveLabel ? (
<li className="agent-thinking-step is-live">
<StepMarker status="live" liveState={liveState} />
<span className="agent-activity-status__live" role="status">
<span key={liveLabel} className="agent-activity-status__text">{liveLabel}</span>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</span>
</li>
) : null}
</ol>
);
}
export function ThinkingStepTree({
caption,
intent,
reasoning,
steps = [],
groups,
hiddenCount,
revealAll = false,
live = false,
liveLabel,
liveState = "working",
startedAt,
defaultOpen = false,
}: Readonly<{
caption?: string;
intent?: string;
reasoning?: string;
steps?: readonly PublicThinkingStep[];
groups?: readonly ThinkingStepGroup[];
hiddenCount?: number;
revealAll?: boolean;
live?: boolean;
liveLabel?: string;
liveState?: OrbState;
startedAt?: number;
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const treeId = useId();
const groupItems = groups ?? [{
id: "default",
intent,
steps,
live,
}];
const showAll = revealAll || hiddenCount === 0;
const stagedCount = groupItems.filter((group) => Boolean(group.intent)).length;
const body = (
<>
{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>
);
})}
</>
);
if (!caption) return <div className="consultation-step-tree">{body}</div>;
const open = userOpen ?? defaultOpen;
return (
<details
className="message-thinking consultation-step-tree"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary>{caption}</summary>
<div className="message-thinking-body">
{reasoning?.trim() ? (
<div className="consultation-step-tree__reasoning">{reasoning}</div>
) : null}
{body}
</div>
</details>
);
}
@@ -0,0 +1,85 @@
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
import type { AgentActivityView } from "./chat-message-view.ts";
import type { ConsultationTimelineRow } from "./consultation-run-timeline.ts";
import type { CompletedActivityReceiptView } from "./rectification-activity-receipt.ts";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "./rectification-varga-sentence.ts";
export const RECTIFICATION_TIMELINE_SOURCE_LIMIT = 8;
export const RECTIFICATION_TIMELINE_LIVE_ID = "activity-live";
const FAILED_SUFFIX = "未完成";
export type RectificationTimelineInput = Readonly<{
trace: readonly AgentActivityTraceItem[] | undefined;
receipt: CompletedActivityReceiptView | undefined;
activity: AgentActivityView | undefined;
settled: boolean;
}>;
function methodChips(receipt: CompletedActivityReceiptView | undefined): string[] {
if (!receipt) return [];
const seen = new Set<string>();
const chips: string[] = [];
for (const method of receipt.methods) {
const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method];
if (!label || seen.has(label)) continue;
seen.add(label);
chips.push(label);
if (chips.length >= RECTIFICATION_TIMELINE_SOURCE_LIMIT) break;
}
return chips;
}
/** Progress labels are the only ones that name an action still under way. */
function isProgressLabel(label: string): boolean {
return /^正在/.test(label.trim());
}
/**
* Project the rectification agent's tool trace and receipt onto the same
* timeline rows the consultation surface renders, so both sessions share one
* step list, one live marker and one settled summary. Thinking text never
* reaches this surface: the public stream drops it on the server.
*/
export function rectificationTimelineRows(input: RectificationTimelineInput): ConsultationTimelineRow[] {
const failedTool = input.receipt?.failedTool;
const rows: ConsultationTimelineRow[] = (input.trace ?? []).map((item) => {
if (item.kind === "think") {
return {
id: item.id,
kind: "think",
status: item.status,
label: item.label,
...(item.text?.trim() ? { thinkingText: item.text } : {}),
};
}
const failed = item.status === "done" && failedTool !== undefined && item.tool === failedTool;
return {
id: item.id,
kind: "calculate",
status: item.status,
label: failed ? `${item.label}${FAILED_SUFFIX}` : item.label,
};
});
const chips = methodChips(input.receipt);
if (chips.length > 0) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
if (!row || row.kind !== "calculate" || row.status !== "done" || row.label.endsWith(FAILED_SUFFIX)) continue;
rows[index] = { ...row, sources: chips };
break;
}
}
const hasLiveRow = rows.some((row) => row.status === "live");
const label = input.activity?.label ?? "";
if (!input.settled && !hasLiveRow && isProgressLabel(label)) {
rows.push({
id: RECTIFICATION_TIMELINE_LIVE_ID,
kind: input.activity?.phase === "answer-composition" ? "write" : "calculate",
status: "live",
label,
});
}
return rows;
}
@@ -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/);
});