fix(rectification): offer times under the agent bubble
Keep the natal house table as a live snapshot outside chat, and show candidate times only after selection is allowed, attached to the latest settled reply. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-4
@@ -4660,9 +4660,41 @@
|
||||
- 用户现象:Agent 正文和赞踩操作栏缩在头像右侧,本命宫位标题和表格贴着对话左缘,和头像齐平。
|
||||
- 触发条件:校正对话出现本命宫位表。
|
||||
- 根因:宫位表是 `.message-list` 里消息行的兄弟节点,不走 `.message-assistant` 的头像 + `gap` 列,也没有补上同一条起始线。
|
||||
- 修复:`.message-list` 定义 `--assistant-content-inset: calc(32px + var(--space-3))`,宫位表用 `padding-inline-start`,候选卡和已采用说明用同一 inset 的 `margin-inline-start`。
|
||||
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定 inset 等于头像宽加 assistant gap。
|
||||
- 防复发:宫位表和后续结果块不得再与头像左缘齐平;起始线必须来自头像尺寸加 `.message-assistant` 的 gap,不能另写一个魔法像素。
|
||||
- 相关记录:BUG-307
|
||||
- 修复:当时用 `--assistant-content-inset` 把头像宽加 gap 补到表上,让它先和正文齐。后续在 BUG-311 改成快照卡片,不再用消息缩进冒充气泡。
|
||||
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 曾锁定 inset;现由 BUG-311 的快照卡片合同接替。
|
||||
- 防复发:宫位表不是聊天气泡。不要再把它缩进成 Agent 正文;结果块要用独立卡片表面。
|
||||
- 相关记录:BUG-307、BUG-311
|
||||
- 复发自:BUG-307(表作为消息兄弟落地,未接入 assistant 列)
|
||||
- 修复版本:`053e63ef`
|
||||
|
||||
## BUG-311 | 生时纠正宫位表被当成聊天正文,而不是随线索更新的当前盘面
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-19
|
||||
- 最近更新:2026-08-19
|
||||
- 影响面:生时纠正对话的宫位表
|
||||
- 用户现象:本命宫位出现在最后一条 Agent 回答下面,看起来像那条回复的一部分;补充经历后表格其实会整表替换,界面没有把这一点说清楚。
|
||||
- 触发条件:校正已经有 Candidate Snapshot,对话里同时出现 Agent 正文和宫位表。
|
||||
- 根因:表来自 Case 快照、挂在消息列表末尾,却按聊天气泡的起始线排版,标题也只说“本命宫位”。
|
||||
- 修复:宫位表单独放在消息列表后的 `rectification-snapshot`,表面与结果卡同类。标题改为“当前本命宫位”,并写明补充经历后会按新线索重算。时间选择卡不再进这个快照,见 BUG-312。
|
||||
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定快照容器在 `ChatMessageRow` 之外、不含候选卡、`aria-live`、重算文案,以及成功回合后 `loadCandidate`。
|
||||
- 防复发:宫位表必须在消息气泡外;文案必须说明随新线索重算。不得再用 `--assistant-content-inset` 把它扮成 Agent 正文。
|
||||
- 相关记录:BUG-307、BUG-310、BUG-312
|
||||
- 复发自:BUG-310(对齐 Agent 正文后更像一条聊天)
|
||||
- 修复版本:待提交
|
||||
|
||||
## BUG-312 | 生时纠正时间选择卡在线索未齐时直接出现,且不在 Agent 气泡下方
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-19
|
||||
- 最近更新:2026-08-19
|
||||
- 影响面:生时纠正对话的候选时间卡、`selectionAllowed`、Case 快照
|
||||
- 用户现象:当前可能的出生时间作为独立盘面模块直接出现;没有等线索齐到可以给出代表性时间,也不挂在刚说完的 Agent 气泡下面。
|
||||
- 触发条件:Case 已有快照或宫位表;引擎尚未 `selection_allowed`,或 Agent 回合仍在生成。
|
||||
- 根因:候选卡和宫位表被捆在同一块始终可见的 `rectification-snapshot` 里。宫位表应当随线索更新;时间选择是“可以给出代表性时间”之后的采用动作,应对齐当轮 Agent 回复。
|
||||
- 修复:候选卡只在 `selectionAllowed`、存在最近一条已落地且未失败的 Agent 正文、且当前不 busy/不重跑时,画在该条气泡和操作栏下方。生成中隐藏,避免挂在上一条气泡下。`--assistant-content-inset` 只用于这块卡片,不用于宫位表。
|
||||
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 锁定候选卡在消息循环内、操作栏之后、快照容器之外,以及 `selectionAllowed` / `latestOfferMessageKey` / `!busy` 门。
|
||||
- 防复发:时间选择卡不得进 `rectification-snapshot`;不得在 `selectionAllowed` 之前或回合生成中显示。宫位表仍是消息外的 live 快照。
|
||||
- 相关记录:BUG-120、BUG-179、BUG-304、BUG-311
|
||||
- 复发自:BUG-120(过早出示采用卡);BUG-311(与宫位表捆成始终可见模块)
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -1847,17 +1847,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
|
||||
.birth-time-clock-menu .select-item { justify-content: flex-start; }
|
||||
|
||||
.rectification-snapshot {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.rectification-candidates {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin: 8px 0 16px;
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-message-wrap .rectification-candidates {
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin: var(--space-3) 0 var(--space-4);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
.rectification-candidates-heading { display: grid; gap: 5px; }
|
||||
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
|
||||
.rectification-candidates-heading span,
|
||||
@@ -1905,18 +1913,15 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-weight: 650;
|
||||
}
|
||||
.rectification-candidate-action { align-self: end; color: var(--color-action); font-size: 13px; font-weight: 650; }
|
||||
.rectification-saved {
|
||||
width: calc(100% - var(--assistant-content-inset));
|
||||
margin: 8px 0 16px;
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.rectification-saved { margin: 8px 0 16px; color: var(--color-ink); font-size: var(--type-body-sm); }
|
||||
.rectification-house-table {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 8px 0 16px;
|
||||
padding-inline-start: var(--assistant-content-inset);
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-house-table__heading { display: grid; gap: 5px; }
|
||||
.rectification-house-table__heading strong {
|
||||
@@ -1932,6 +1937,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
.rectification-house-table .markdown-table { margin: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.rectification-house-table,
|
||||
.rectification-candidates { padding: 14px; }
|
||||
.rectification-candidate-list {
|
||||
grid-template-columns: none;
|
||||
|
||||
@@ -54,6 +54,53 @@ type PersistedTurn = Readonly<{
|
||||
|
||||
type CandidateResult = RectificationCandidateResult | null;
|
||||
|
||||
function RectificationCandidateCards({
|
||||
result,
|
||||
acceptingCandidateId,
|
||||
readonly,
|
||||
onAccept,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult;
|
||||
acceptingCandidateId: string | null;
|
||||
readonly: boolean;
|
||||
onAccept: (candidateId: string) => void;
|
||||
}>) {
|
||||
return (
|
||||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||||
<div className="rectification-candidates-heading">
|
||||
<strong>当前可能的出生时间</strong>
|
||||
<span>这次校正的结果是采用一个代表性时间作当前排盘。相邻分钟目前分不开,还不能确认唯一分钟。可以先采用,也可以继续补充事件或改选;新增证据后,候选和相对支持度会重新计算。</span>
|
||||
<span>相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。</span>
|
||||
</div>
|
||||
<div className="rectification-candidate-list">
|
||||
{result.candidates.map((candidate) => {
|
||||
const selected = result.selectedTime === candidate.time;
|
||||
const recommended = isRecommendedRectificationCandidate(result, candidate);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rectification-candidate${selected ? " is-selected" : ""}`}
|
||||
key={candidate.candidateId}
|
||||
disabled={selected || Boolean(acceptingCandidateId) || readonly}
|
||||
onClick={() => onAccept(candidate.candidateId)}
|
||||
>
|
||||
<span className="rectification-candidate-time">
|
||||
<strong>{candidate.time}</strong>
|
||||
{selected && <span className="rectification-candidate-badge">已采用</span>}
|
||||
{recommended && <span className="rectification-candidate-badge">当前推荐</span>}
|
||||
</span>
|
||||
<span className="rectification-candidate-support">相对支持度 {candidate.relativeSupport}</span>
|
||||
<span className="rectification-candidate-action">
|
||||
{selected ? "已采用" : acceptingCandidateId === candidate.candidateId ? "正在采用…" : result.selectedTime ? "改选为此时间" : "采用此时间"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type RectificationAgenticChatProps = Readonly<{
|
||||
caseId: string;
|
||||
sessionId: string;
|
||||
@@ -194,7 +241,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
top: container.scrollHeight,
|
||||
behavior: busy || reduceMotion ? "auto" : "smooth",
|
||||
});
|
||||
}, [busy, error, messages, savedTime]);
|
||||
}, [busy, candidateResult, error, messages, savedTime]);
|
||||
|
||||
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
|
||||
// from parsing agent text or hidden sentinels.
|
||||
@@ -498,6 +545,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& Boolean(message.turnId)
|
||||
&& Boolean(message.text)
|
||||
))?.renderKey;
|
||||
const latestOfferMessageKey = [...messages]
|
||||
.reverse()
|
||||
.find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& !message.failed
|
||||
&& Boolean(message.text)
|
||||
))?.renderKey;
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult?.selectionAllowed
|
||||
&& latestOfferMessageKey
|
||||
&& !busy
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const canSend = !busy && !readonly && !regeneratingMessageKey;
|
||||
|
||||
return (
|
||||
@@ -552,45 +613,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onRegenerate={() => void regenerateMessage(message)}
|
||||
/>
|
||||
)}
|
||||
{showSelectionCards && message.renderKey === latestOfferMessageKey && candidateResult && (
|
||||
<RectificationCandidateCards
|
||||
result={candidateResult}
|
||||
acceptingCandidateId={acceptingCandidateId}
|
||||
readonly={readonly}
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{candidateResult?.houseTable && (
|
||||
<RectificationHouseTableView table={candidateResult.houseTable} />
|
||||
)}
|
||||
{candidateResult?.selectionAllowed && (
|
||||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||||
<div className="rectification-candidates-heading">
|
||||
<strong>当前可能的出生时间</strong>
|
||||
<span>这次校正的结果是采用一个代表性时间作当前排盘。相邻分钟目前分不开,还不能确认唯一分钟。可以先采用,也可以继续补充事件或改选;新增证据后,候选和相对支持度会重新计算。</span>
|
||||
<span>相对支持度不是统计概率;采用不等于确认出生时间,也不会覆盖原始填报时间。</span>
|
||||
</div>
|
||||
<div className="rectification-candidate-list">
|
||||
{candidateResult.candidates.map((candidate) => {
|
||||
const selected = candidateResult.selectedTime === candidate.time;
|
||||
const recommended = isRecommendedRectificationCandidate(candidateResult, candidate);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rectification-candidate${selected ? " is-selected" : ""}`}
|
||||
key={candidate.candidateId}
|
||||
disabled={selected || Boolean(acceptingCandidateId) || readonly}
|
||||
onClick={() => void acceptCandidate(candidate.candidateId)}
|
||||
>
|
||||
<span className="rectification-candidate-time">
|
||||
<strong>{candidate.time}</strong>
|
||||
{selected && <span className="rectification-candidate-badge">已采用</span>}
|
||||
{recommended && <span className="rectification-candidate-badge">当前推荐</span>}
|
||||
</span>
|
||||
<span className="rectification-candidate-support">相对支持度 {candidate.relativeSupport}</span>
|
||||
<span className="rectification-candidate-action">
|
||||
{selected ? "已采用" : acceptingCandidateId === candidate.candidateId ? "正在采用…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<div className="rectification-snapshot">
|
||||
<RectificationHouseTableView table={candidateResult.houseTable} />
|
||||
</div>
|
||||
)}
|
||||
{savedTime && (
|
||||
<p className="rectification-saved" role="status">
|
||||
|
||||
@@ -6,12 +6,13 @@ export function RectificationHouseTableView({
|
||||
table,
|
||||
}: Readonly<{ table: RectificationHouseTable }>) {
|
||||
return (
|
||||
<section className="rectification-house-table" aria-label="本命宫位表">
|
||||
<section className="rectification-house-table" aria-label="当前本命宫位" aria-live="polite">
|
||||
<div className="rectification-house-table__heading">
|
||||
<strong>本命宫位</strong>
|
||||
<strong>当前本命宫位</strong>
|
||||
<span>
|
||||
按 {table.time} 排出,上升{table.lagna}。这是当前代表性时间下的盘面,不是已确认出生盘。
|
||||
按 {table.time} 排出,上升{table.lagna}。补充经历后会按新线索重算。
|
||||
</span>
|
||||
<span>这是当前代表性时间下的盘面,不是已确认出生盘。</span>
|
||||
</div>
|
||||
<div className="markdown-table">
|
||||
<table>
|
||||
|
||||
@@ -227,7 +227,7 @@ test("Agentic rectification scrolls the conversation container as streamed messa
|
||||
assert.match(chat, /<section ref=\{conversation\} className="conversation"/);
|
||||
assert.match(chat, /const container = conversation\.current/);
|
||||
assert.match(chat, /top: container\.scrollHeight/);
|
||||
assert.match(chat, /\}, \[busy, error, messages, savedTime\]\);/);
|
||||
assert.match(chat, /\}, \[busy, candidateResult, error, messages, savedTime\]\);/);
|
||||
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
|
||||
});
|
||||
|
||||
@@ -256,7 +256,7 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
|
||||
|
||||
const messageRender = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{candidateResult?.selectionAllowed"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
const houseIndex = messageRender.indexOf("<RectificationHouseTableView");
|
||||
@@ -282,7 +282,7 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
|
||||
|
||||
const messageRender = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{candidateResult?.selectionAllowed"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
const actionsIndex = messageRender.indexOf("<ChatMessageActions");
|
||||
@@ -315,14 +315,59 @@ test("candidate state renders from the snapshot API and never from sentinels", (
|
||||
assert.match(chat, /已采用/);
|
||||
assert.match(chat, /正在采用…/);
|
||||
assert.match(chat, /RectificationHouseTableView/);
|
||||
assert.match(styles, /\.rectification-house-table \{/);
|
||||
assert.match(styles, /--assistant-content-inset:\s*calc\(32px \+ var\(--space-3\)\)/);
|
||||
assert.match(styles, /\.rectification-house-table \{[^}]*padding-inline-start:\s*var\(--assistant-content-inset\)/);
|
||||
assert.match(styles, /\.agent-avatar \{[^}]*width:\s*32px/);
|
||||
assert.match(styles, /\.message-assistant \{[^}]*gap:\s*var\(--space-3\)/);
|
||||
assert.match(chat, /className="rectification-snapshot"/);
|
||||
assert.match(styles, /\.rectification-snapshot \{/);
|
||||
assert.match(styles, /\.rectification-house-table \{[^}]*border:\s*1px solid var\(--color-border\)/);
|
||||
assert.match(styles, /\.rectification-house-table \{[^}]*background:\s*var\(--color-canvas-soft\)/);
|
||||
assert.match(styles, /\.message-list \{[\s\S]*--assistant-content-inset: calc\(32px \+ var\(--space-3\)\)/);
|
||||
assert.match(styles, /\.rectification-message-wrap \.rectification-candidates \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
|
||||
assert.doesNotMatch(styles, /\.rectification-snapshot \{[^}]*--assistant-content-inset/);
|
||||
assert.doesNotMatch(styles, /\.rectification-house-table \{[^}]*--assistant-content-inset/);
|
||||
});
|
||||
|
||||
|
||||
test("the natal house table is a live snapshot card outside the agent bubble", () => {
|
||||
const houseTable = readFileSync(
|
||||
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const messageLoop = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{candidateResult?.houseTable &&"),
|
||||
);
|
||||
const snapshot = chat.slice(
|
||||
chat.indexOf("{candidateResult?.houseTable &&"),
|
||||
chat.indexOf("{savedTime &&"),
|
||||
);
|
||||
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
|
||||
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
|
||||
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
|
||||
assert.ok(rowIndex >= 0);
|
||||
assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex);
|
||||
assert.doesNotMatch(snapshot, /<RectificationCandidateCards/);
|
||||
assert.doesNotMatch(snapshot, /当前可能的出生时间/);
|
||||
assert.match(chat, /await loadCandidate\(\)\.then\(\(result\) => \{/);
|
||||
assert.match(houseTable, /aria-live="polite"/);
|
||||
assert.match(houseTable, /当前本命宫位/);
|
||||
assert.match(houseTable, /补充经历后会按新线索重算/);
|
||||
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
|
||||
});
|
||||
|
||||
test("time-selection cards appear under the latest settled agent bubble once selection is allowed", () => {
|
||||
const messageLoop = chat.slice(
|
||||
chat.indexOf("{messages.map((message) => {"),
|
||||
chat.indexOf("{candidateResult?.houseTable &&"),
|
||||
);
|
||||
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
|
||||
const cardsIndex = messageLoop.indexOf("<RectificationCandidateCards");
|
||||
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
|
||||
assert.match(chat, /candidateResult\?\.selectionAllowed/);
|
||||
assert.match(chat, /latestOfferMessageKey/);
|
||||
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*!busy[\s\S]*regeneratingMessageKey === null/);
|
||||
assert.match(messageLoop, /showSelectionCards && message\.renderKey === latestOfferMessageKey/);
|
||||
assert.doesNotMatch(messageLoop, /className="rectification-snapshot"/);
|
||||
});
|
||||
|
||||
test("rectification keeps the composer but never renders generated suggestion chips", () => {
|
||||
assert.match(chat, /<form className="composer"/);
|
||||
assert.doesNotMatch(chat, /composer-suggestions/);
|
||||
|
||||
Reference in New Issue
Block a user