fix(rectification): put the collect stem in the assistant bubble
The live question slot still rendered the collect prompt below the action icons when the streamed body was only a greeting. Join the GET prompt into that bubble and stop drawing a sibling collect slot. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7504,3 +7504,19 @@
|
||||
- 相关记录:BUG-449、BUG-461、BUG-469、BUG-471、BUG-485
|
||||
- 复发自:BUG-485(槽位可见但不是聊天历史)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-488 | 口述采集题干仍画在动作图标下方,没有进气泡正文
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-02
|
||||
- 最近更新:2026-09-02
|
||||
- 影响面:`rectification-agentic-chat` 最新助手气泡、`.rectification-question-slot__prompt`
|
||||
- 用户现象:开场气泡只有打招呼,采集题干仍出现在点赞/复制/重生成图标下面。staging 已是 BUG-487 的 SHA。
|
||||
- 触发条件:新 Case opening,`current_question.kind=collect_spoken`,GET 已有 prompt。
|
||||
- 根因:BUG-487 把题干接到 `assistant_message` 并允许槽位兜底。直播正文仍可能只有打招呼(replace 未进客户端 raw、或 GET 后才有 current_question),于是 `showCollectSpokenPrompt` 把同一句画在图标下。用户要的是气泡正文,不是槽。
|
||||
- 修复:不再渲染采集题槽。最新助手气泡按题干精确身份 `composeCollectSpokenAssistantText`;定稿与 snapshot 把组合文本写进该条消息状态,复制走组合文本。选择卡仍走问题槽。模型仍不自己提问,避免气泡里两句相近问法。不 bump Skill,不放宽确认门。
|
||||
- 验证:`rectification-spoken-collect` 锁气泡拼接、禁止 `rectification-question-slot__prompt`;既有 collect-prompt / v9-agent / regenerate / voice 锁保持。
|
||||
- 防复发:口述采集题干不得作为动作图标下的兄弟节点。不得用正文字符串判断「有没有问过」。不得同时让模型提问又拼接同一句服务端题干。
|
||||
- 相关记录:BUG-471、BUG-485、BUG-487
|
||||
- 复发自:BUG-487(服务端拼接后槽位兜底仍可见)
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -323,12 +323,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const previousBoardResult = useRef<CandidateResult>(null);
|
||||
const runAbort = useRef<AbortController | null>(null);
|
||||
const choiceActionIds = useRef(new Map<string, string>());
|
||||
const currentQuestionRef = useRef(currentQuestion);
|
||||
currentQuestionRef.current = currentQuestion;
|
||||
const [compactBoard, setCompactBoard] = useState(false);
|
||||
const [boardOpen, setBoardOpen] = useState(false);
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
const boardId = useId();
|
||||
const boardTitleId = useId();
|
||||
const collectSpokenPromptId = useId();
|
||||
const composerRemainingId = useId();
|
||||
// The same anchor-and-follow the consultation surface uses: streamed tokens and
|
||||
// new cards land the viewport on the bottom only while the reader is there.
|
||||
@@ -427,6 +428,25 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return () => controller.abort();
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const prompt = currentQuestion?.kind === "collect_spoken" ? currentQuestion.prompt : null;
|
||||
if (!prompt || busy || regeneratingMessageKey !== null) return;
|
||||
setMessages((current) => {
|
||||
const last = [...current].reverse().find((message) => (
|
||||
message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& !message.failed
|
||||
&& Boolean(message.text)
|
||||
));
|
||||
if (!last) return current;
|
||||
const combined = composeCollectSpokenAssistantText(last.text, prompt);
|
||||
if (combined === last.text) return current;
|
||||
return current.map((item) => (
|
||||
item.renderKey === last.renderKey ? { ...item, text: combined } : item
|
||||
));
|
||||
});
|
||||
}, [busy, currentQuestion, regeneratingMessageKey]);
|
||||
|
||||
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
if ((action === "message" && !trimmed) || busy || readonly) return;
|
||||
@@ -647,12 +667,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
|
||||
const parsed = completed && !streamFailed ? parseAgentReply(raw) : { text: "", title: undefined };
|
||||
const succeeded = completed && !streamFailed && Boolean(parsed.text);
|
||||
const collectPrompt = currentQuestionRef.current?.kind === "collect_spoken"
|
||||
? currentQuestionRef.current.prompt
|
||||
: null;
|
||||
const settledText = succeeded && collectPrompt
|
||||
? composeCollectSpokenAssistantText(parsed.text, collectPrompt)
|
||||
: parsed.text;
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (succeeded) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
text: settledText,
|
||||
activityTrace: completeActivityTrace(activityTrace),
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
@@ -684,7 +710,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (succeeded) {
|
||||
onMessagesChange?.([
|
||||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||||
{ role: "assistant", text: parsed.text },
|
||||
{ role: "assistant", text: settledText },
|
||||
]);
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
@@ -1048,20 +1074,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken"
|
||||
? currentQuestion.prompt
|
||||
: null;
|
||||
const latestCollectBody = latestSettledAssistant?.text ?? "";
|
||||
const collectStemAlreadyInLatestBody = Boolean(
|
||||
collectSpokenPrompt
|
||||
&& latestSettledAssistant
|
||||
&& composeCollectSpokenAssistantText(latestCollectBody, collectSpokenPrompt) === latestCollectBody.trim(),
|
||||
);
|
||||
const showCollectSpokenPrompt = Boolean(
|
||||
collectSpokenPrompt
|
||||
&& !collectStemAlreadyInLatestBody
|
||||
&& !showLiveChoiceCard
|
||||
&& !readonly
|
||||
&& !busy
|
||||
&& regeneratingMessageKey === null,
|
||||
);
|
||||
const resumableCase = caseStatus !== null && isResumableStatus(caseStatus);
|
||||
const showMissingQuestion = Boolean(
|
||||
caseSnapshotLoaded
|
||||
@@ -1081,7 +1093,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
);
|
||||
const showQuestionSlot = Boolean(
|
||||
showLiveChoiceCard
|
||||
|| showCollectSpokenPrompt
|
||||
|| showMissingQuestion
|
||||
|| showUnavailableQuestion,
|
||||
);
|
||||
@@ -1164,12 +1175,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
: null;
|
||||
const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey;
|
||||
const bubbleText = !regenerating
|
||||
&& isLatestMessage
|
||||
&& message.role === "assistant"
|
||||
&& collectSpokenPrompt
|
||||
? composeCollectSpokenAssistantText(displayedMessage.text, collectSpokenPrompt)
|
||||
: displayedMessage.text;
|
||||
const bubbleMessage = bubbleText === displayedMessage.text
|
||||
? displayedMessage
|
||||
: { ...displayedMessage, text: bubbleText };
|
||||
return (
|
||||
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
|
||||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||||
{(!message.failed || Boolean(bubbleMessage.text) || regenerating) && (
|
||||
<ChatMessageRow
|
||||
message={displayedMessage}
|
||||
showActivity={displayedMessage.state !== "settled"}
|
||||
message={bubbleMessage}
|
||||
showActivity={bubbleMessage.state !== "settled"}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
)}
|
||||
@@ -1182,7 +1202,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
...current,
|
||||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
|
||||
}))}
|
||||
onCopy={() => void copyMessage(message)}
|
||||
onCopy={() => void copyMessage({ ...message, text: bubbleText })}
|
||||
onRegenerate={() => void regenerateMessage(message)}
|
||||
/>
|
||||
)}
|
||||
@@ -1220,11 +1240,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{showCollectSpokenPrompt && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{showMissingQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
@@ -1253,11 +1268,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onStop={submitStop}
|
||||
/>
|
||||
)}
|
||||
{showCollectSpokenPrompt && (
|
||||
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
|
||||
{collectSpokenPrompt}
|
||||
</p>
|
||||
)}
|
||||
{showMissingQuestion && (
|
||||
<p className="rectification-question-slot__status" role="status">
|
||||
当前没有可回答的问题,正在等待服务端更新。
|
||||
@@ -1298,9 +1308,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
)}
|
||||
<ChatComposer
|
||||
inputRef={composer}
|
||||
describedBy={showCollectSpokenPrompt
|
||||
? collectSpokenPromptId
|
||||
: undefined}
|
||||
value={draft}
|
||||
remainingId={composerRemainingId}
|
||||
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
@@ -1308,7 +1315,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||||
: showLiveChoiceCard
|
||||
? "点上面的选项即可;想补一句细节再写"
|
||||
: showCollectSpokenPrompt || collectStemAlreadyInLatestBody
|
||||
: collectSpokenPrompt
|
||||
? "请回答上面的问题…"
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
maxLength={RECTIFICATION_COMPOSER_MAX_LENGTH}
|
||||
|
||||
@@ -105,23 +105,21 @@ test("cases current_question drives the unified question slot", () => {
|
||||
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
|
||||
});
|
||||
|
||||
test("collect_spoken stem stays in the same assistant body; the slot is fallback only", () => {
|
||||
test("collect_spoken stem is joined into the latest assistant bubble, never a slot sibling", () => {
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
||||
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
|
||||
assert.match(chat, /const collectSpokenPrompt =/);
|
||||
assert.match(chat, /composeCollectSpokenAssistantText\(latestCollectBody, collectSpokenPrompt\)/);
|
||||
assert.match(chat, /const collectStemAlreadyInLatestBody = Boolean\(/);
|
||||
assert.match(chat, /const showCollectSpokenPrompt = Boolean\(/);
|
||||
assert.match(chat, /!collectStemAlreadyInLatestBody/);
|
||||
assert.match(chat, /!busy/);
|
||||
assert.match(chat, /composeCollectSpokenAssistantText\(displayedMessage\.text, collectSpokenPrompt\)/);
|
||||
assert.match(chat, /composeCollectSpokenAssistantText\(last\.text, prompt\)/);
|
||||
assert.match(chat, /composeCollectSpokenAssistantText\(parsed\.text, collectPrompt\)/);
|
||||
assert.doesNotMatch(chat, /showCollectSpokenPrompt/);
|
||||
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
|
||||
assert.doesNotMatch(chat, /collectSpokenPromptId/);
|
||||
assert.match(chat, /showQuestionSlot/);
|
||||
assert.match(chat, /isLatestMessage && showQuestionSlot/);
|
||||
assert.match(chat, /messages\.length === 0 && showQuestionSlot/);
|
||||
assert.match(chat, /showCollectSpokenPrompt \|\| collectStemAlreadyInLatestBody/);
|
||||
assert.match(chat, /rectification-question-slot__prompt/);
|
||||
assert.match(chat, /describedBy=\{showCollectSpokenPrompt/);
|
||||
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
|
||||
assert.match(
|
||||
readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"),
|
||||
/\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/,
|
||||
@@ -137,7 +135,6 @@ test("collect_spoken stem stays in the same assistant body; the slot is fallback
|
||||
assert.doesNotMatch(agentRun, /answerText\.(?:includes|match|search)\(/);
|
||||
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
|
||||
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
|
||||
assert.match(styles, /\.rectification-question-slot__prompt \{/);
|
||||
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user