refactor: simplify rectification conversation chrome

This commit is contained in:
Jesse_Chen
2026-07-23 17:02:11 +08:00
parent c9821cc4d4
commit e55e06521a
5 changed files with 81 additions and 100 deletions
+19 -3
View File
@@ -707,9 +707,25 @@
- 用户现象:生时校正右侧直接显示浏览器或操作系统默认滚动条,与站内温和、低对比的编辑式界面不一致。
- 触发条件:生时校正历史内容超过消息区高度,在桌面浏览器出现纵向滚动条。
- 根因:`.rectification-message-list` 只声明了 `overflow-y: auto`,没有提供跨浏览器的产品内滚动条颜色、宽度和交互状态。
- 修复:为生时校正消息区增加细宽、透明轨道、低对比圆角拇指,并在 hover 时适度加深;Firefox 使用标准 scrollbar 属性,Chromium / Safari 使用 WebKit 伪元素,颜色继续取现有设计 token。
- 验证:CSS 契约锁定标准与 WebKit 两套样式,聚焦组件测试、目标 ESLint、production build 与生产浏览器 smoke。
- 防复发:独立滚动容器必须复用产品 token,并同时覆盖标准 scrollbar 属性和 WebKit 伪元素;不得引入渐变、重阴影或高饱和装饰。
- 修复:为生时校正消息区增加细宽、透明轨道、低对比圆角拇指;默认隐藏拇指,仅在指针移动、列表滚动或键盘焦点进入消息区时短暂显示,停止操作后自动隐藏。Firefox 使用标准 scrollbar 属性,Chromium / Safari 使用 WebKit 伪元素,颜色继续取现有设计 token。
- 验证:CSS 契约锁定默认透明、交互显现及标准与 WebKit 两套样式,聚焦组件测试、目标 ESLint、production build 与生产浏览器 smoke。
- 防复发:独立滚动容器必须复用产品 token,并同时覆盖标准 scrollbar 属性和 WebKit 伪元素;默认态不得持续抢占视觉注意力,也不得引入渐变、重阴影或高饱和装饰。
- 相关记录:BUG-041
- 复发自:无
- 修复版本:本次简约滚动条修复提交
## BUG-043 | 生时校正把后台证据状态重复渲染成可展开管理面板
- 状态:resolved
- 首次发现:2026-07-23
- 最近更新:2026-07-23
- 影响面:生时校正消息流、候选进度、历史经历与更正入口
- 用户现象:Agent 已经在自然对话中确认和追问经历,消息区底部仍额外显示“当前候选 · 待验证”、候选范围、历史经历列表和“更正”按钮;用户需要理解并操作第二套记录界面,破坏一问一答的连续性。
- 触发条件:任何已有候选或至少一条 evidence recap 的生时校正案例。
- 根因:语言交互改版后仍保留旧产品流程的 `<details>` 进度与证据管理面板,把本应仅供后台评分和恢复使用的结构化状态再次暴露给用户。
- 修复:从生时校正消息流移除整块候选进度与历史经历管理面板,不再显示候选状态、范围、经历列表或逐条更正按钮;后台 evidence、评分与持久化保持不变,最终可确认状态仍通过明确确认动作呈现。
- 验证:组件与真实 Chromium 回归锁定页面不包含候选进度、历史经历面板或更正按钮,同时保留自然对话、流式回答、撤回窗口、自动贴底和最终确认能力。
- 防复发:结构化 evidence 是 Agent 的后台推理与持久化输入,不得在语言优先界面重复渲染成需要用户管理的卡片或表单;需要纠正时继续通过自然语言表达。
- 相关记录:BUG-020、BUG-034、BUG-042
- 复发自:BUG-020
- 修复版本:待提交
+12 -8
View File
@@ -788,16 +788,26 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
overflow-y: auto;
overscroll-behavior: contain;
padding-bottom: var(--space-6);
scrollbar-color: color-mix(in srgb, var(--color-ink-tertiary) 46%, transparent) transparent;
scrollbar-color: transparent transparent;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.rectification-message-list.is-scrollbar-visible,
.rectification-message-list:focus-within {
scrollbar-color: color-mix(in srgb, var(--color-ink-tertiary) 46%, transparent) transparent;
}
.rectification-message-list::-webkit-scrollbar { width: 10px; }
.rectification-message-list::-webkit-scrollbar-track { background: transparent; }
.rectification-message-list::-webkit-scrollbar-thumb {
min-height: 40px;
border: 3px solid transparent;
border-radius: 999px;
background: transparent;
background-clip: padding-box;
transition: background-color 160ms ease;
}
.rectification-message-list.is-scrollbar-visible::-webkit-scrollbar-thumb,
.rectification-message-list:focus-within::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-ink-tertiary) 42%, transparent);
background-clip: padding-box;
}
@@ -805,19 +815,13 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
background: color-mix(in srgb, var(--color-ink-secondary) 62%, transparent);
background-clip: padding-box;
}
.rectification-message-details { margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-message-details summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; }
.rectification-message-details p { margin: 0 0 var(--space-2); font-size: var(--type-caption); line-height: 1.6; }
.rectification-message-details ul { display: grid; gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; list-style: none; }
.rectification-message-details li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
.rectification-message-details button, .rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
.rectification-composer-wrap { position: static; flex: 0 0 auto; }
.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); }
.rectification-correction-target p { margin: 0; }
.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); }
@media (max-width: 430px) {
.rectification-message-details li { align-items: stretch; grid-template-columns: minmax(0, 1fr); }
.rectification-correction-target { align-items: stretch; display: grid; }
}
@@ -11,7 +11,6 @@ import {
} from "../hooks/use-conversational-rectification.ts";
import type {
ConversationalRectificationResponse,
ConversationalRectificationTurn,
} from "../lib/conversational-rectification/contracts.ts";
type SurfaceProps = Readonly<{
@@ -28,13 +27,6 @@ function safely(request: Promise<unknown>) {
void request.catch(() => undefined);
}
function candidateStatus(turn: ConversationalRectificationTurn): string {
if (turn.status === "completed" && turn.candidate.status === "confirmed") return "已确认";
if (turn.status === "completed") return "范围已保存,分钟未确认";
if (turn.candidate.status === "ready_for_confirmation") return "待确认,尚未验证";
return "待验证";
}
export function ConversationalRectificationSurface({
controller,
openingAssistantText = "",
@@ -44,6 +36,7 @@ export function ConversationalRectificationSurface({
}: SurfaceProps) {
const composer = useRef<HTMLTextAreaElement>(null);
const messageList = useRef<HTMLDivElement>(null);
const scrollbarHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submission, setSubmission] = useState<Readonly<{
text: string;
@@ -77,6 +70,7 @@ export function ConversationalRectificationSurface({
]);
useEffect(() => () => {
if (undoTimer.current) clearTimeout(undoTimer.current);
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
}, []);
const pendingQuestion = turn?.status === "completed"
? turn.pendingConsultationQuestion
@@ -132,10 +126,31 @@ export function ConversationalRectificationSurface({
setSubmission(null);
requestAnimationFrame(() => composer.current?.focus());
};
const revealScrollbar = () => {
const list = messageList.current;
if (!list) return;
list.classList.add("is-scrollbar-visible");
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
scrollbarHideTimer.current = setTimeout(() => {
list.classList.remove("is-scrollbar-visible");
scrollbarHideTimer.current = null;
}, 900);
};
const hideScrollbar = () => {
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
scrollbarHideTimer.current = null;
messageList.current?.classList.remove("is-scrollbar-visible");
};
return (
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
<div ref={messageList} className="message-list rectification-message-list">
<div
ref={messageList}
className="message-list rectification-message-list"
onPointerLeave={hideScrollbar}
onPointerMove={revealScrollbar}
onScroll={revealScrollbar}
>
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{(controller.messages ?? [{
role: "assistant" as const,
@@ -157,41 +172,6 @@ export function ConversationalRectificationSurface({
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
/>
)}
<details className="rectification-message-details rectification-progress-details">
<summary>
{turn.candidate.representativeTime
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
: `校正进度 · ${candidateStatus(turn)}`}
</summary>
<p>
{turn.candidate.rangeStart && turn.candidate.rangeEnd
? `候选范围 ${turn.candidate.rangeStart}${turn.candidate.rangeEnd};已记录 ${turn.evidenceRecap.length} 条经历。`
: `已记录 ${turn.evidenceRecap.length} 条经历,尚未缩小候选范围。`}
</p>
<p></p>
{turn.evidenceRecap.length > 0 && (
<ul aria-label="已记录的真实经历">
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<span>{entry.dateLabel} · {entry.summary}{entry.isCorrection ? "(已修订)" : ""}</span>
{canAnswer && (
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={busy}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
composer.current?.focus();
}}
>
</button>
)}
</li>
))}
</ul>
)}
</details>
{controller.pending && canAnswer && (
controller.streamingAssistantText
? <ChatMessageRow message={{
@@ -93,8 +93,8 @@ test("rectification is a language-first exchange with one free-text answer path"
assert.match(markup, /当前判断/);
assert.match(markup, /D9 与 D10/);
assert.match(markup, /2021 年 7 月 · 开始第一份长期工作/);
assert.ok(markup.indexOf("当前判断") < markup.indexOf("当前候选 05:18"));
assert.doesNotMatch(markup, /已记录的经历|已记录的真实经历|更正这条经历/);
assert.doesNotMatch(markup, /当前候选 05:18/);
assert.doesNotMatch(markup, /目前已经形成一个待确认候选/);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /像聊天一样回答即可/);
@@ -104,7 +104,7 @@ test("rectification is a language-first exchange with one free-text answer path"
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
test("a resumed legacy turn preserves its Agent narrative and keeps evidence in progress details", () => {
test("a resumed legacy turn preserves its Agent narrative without a separate evidence panel", () => {
const legacyTurn = {
...turn,
status: "active",
@@ -119,7 +119,7 @@ test("a resumed legacy turn preserves its Agent narrative and keeps evidence in
assert.match(markup, /05:30 是范围内的待验证候选/);
assert.match(markup, /D1 保持稳定/);
assert.match(markup, /2021 年 7 月 · 开始第一份长期工作/);
assert.doesNotMatch(markup, /2021 年 7 月 · 开始第一份长期工作|已记录的经历/);
assert.doesNotMatch(markup, /范围暂未变化不代表提交失败/);
});
@@ -194,28 +194,23 @@ test("the first Agent guidance streams into the empty rectification surface", ()
assert.doesNotMatch(markup, /正在建立校正记录/);
});
test("evidence is correctable, secondary controls stay hidden, and confirmation is explicit", () => {
test("history management stays hidden while final confirmation remains explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
));
assert.match(markup, /已记录的真实经历/);
assert.match(markup, /2021 年 7 月/);
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.doesNotMatch(markup, /已记录的真实经历|2021 年 7 月|更正这条经历/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.match(markup, /当前候选 05:18/);
assert.match(markup, /待确认,尚未验证/);
assert.doesNotMatch(markup, /当前候选 05:18/);
assert.match(markup, /确认采用 05:18(尚未验证)/);
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
assert.match(markup, /已记录 1 条经历/);
assert.match(markup, /候选只用于继续验证/);
assert.match(markup, /这一步不会自动采用候选/);
assert.doesNotMatch(markup, /已记录 1 条经历|候选只用于继续验证|这一步不会自动采用候选/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
test("correction mode identifies its durable target, can be cancelled, and marks revised recaps", () => {
test("an existing correction target can still be cancelled without rendering history management", () => {
const revisedTurn = {
...turn,
evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }],
@@ -241,7 +236,7 @@ test("correction mode identifies its durable target, can be cancelled, and marks
assert.match(markup, /正在更正/);
assert.match(markup, /开始第一份长期工作/);
assert.match(markup, /取消更正/);
assert.match(markup, /(已修订)/);
assert.doesNotMatch(markup, /(已修订)|已记录的经历|更正这条经历/);
});
test("pending markup and responsive CSS expose accessibility contracts", () => {
@@ -287,10 +282,12 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
assert.match(css, /\.rectification-message-list[^}]*scrollbar-color:\s*transparent transparent/);
assert.match(css, /\.rectification-message-list[^}]*scrollbar-width:\s*thin/);
assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb[^}]*background-clip:\s*padding-box/);
assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb[^}]*background:\s*transparent[^}]*background-clip:\s*padding-box/);
assert.match(css, /\.rectification-message-list\.is-scrollbar-visible::\-webkit-scrollbar-thumb[^}]*color-mix/);
assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb:hover[^}]*color-mix/);
assert.match(css, /\.rectification-message-details button[^}]*min-height:\s*44px/);
assert.match(css, /\.rectification-correction-target button[^}]*min-height:\s*44px/);
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
@@ -298,8 +295,11 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.rectification-message-details/);
assert.doesNotMatch(css, /\.rectification-message-details/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /onPointerMove=\{revealScrollbar\}/);
assert.match(component, /onScroll=\{revealScrollbar\}/);
assert.match(component, /classList\.remove\("is-scrollbar-visible"\)/);
assert.match(component, /controller\.answer\(undefined, text\)/);
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(component, /onPendingChange/);
@@ -802,8 +802,9 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
await waitFor(
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前候选 05:18')
&& document.body.textContent.includes('2021-07 · 开始第一份长期工作')`) ?? Promise.resolve(false),
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前判断')
&& !document.body.textContent.includes('当前候选 05:18')
&& !document.body.textContent.includes('已记录的经历')`) ?? Promise.resolve(false),
"streamlined async initial turn",
);
@@ -833,23 +834,6 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
await cdp.evaluate("document.querySelector('.rectification-message-details').open = true");
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const textarea = document.getElementById('conversational-rectification-answer');
return document.body.textContent.includes('正在更正')
&& document.body.textContent.includes('开始第一份长期工作')
&& textarea?.value === '';
})()`) ?? Promise.resolve(false),
"durable correction target selection without polluting the new answer",
);
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('取消更正')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("!document.body.textContent.includes('正在更正') && document.getElementById('conversational-rectification-answer').value === ''") ?? Promise.resolve(false),
"correction cancellation",
);
const mistakenAnswer = "2020年9月离职写错了";
await cdp.evaluate(`(() => {
const textarea = document.getElementById('conversational-rectification-answer');
@@ -874,8 +858,9 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const text = document.body.textContent;
return text.includes('当前候选')
&& !text.includes('候选时间')
return text.includes('当前判断')
&& !text.includes('当前候选 05:18')
&& !text.includes('已记录的经历')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
@@ -68,7 +68,7 @@ test("visible rectification copy does not replace a tailored Agent answer with a
assert.match(narrative, /事业转折/);
});
test("the rectification chat renders the controller's alternating message history", () => {
test("the rectification chat renders alternating messages without a separate evidence panel", () => {
const source = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
@@ -76,9 +76,5 @@ test("the rectification chat renders the controller's alternating message histor
assert.match(source, /controller\.messages/);
assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/);
const messageHistoryIndex = source.indexOf("controller.messages");
const progressDetailsIndex = source.indexOf("rectification-progress-details");
const evidenceRecapIndex = source.indexOf("turn.evidenceRecap.map");
assert.ok(messageHistoryIndex >= 0 && messageHistoryIndex < progressDetailsIndex);
assert.ok(evidenceRecapIndex > progressDetailsIndex);
assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map|更正这条经历/);
});