fix(web): keep consultation thinking above one spoken answer

Per-domain thinking trees were interleaved with sliced analysis, so a finished reply still looked like unfinished checklists. One collapsed thinking panel and one full body restores the reading order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 01:49:05 +08:00
parent 0285cad1fd
commit 995b752e70
12 changed files with 274 additions and 95 deletions
+27 -11
View File
@@ -859,22 +859,34 @@ button:disabled { cursor: default; opacity: .45; }
}
.consultation-thinking-report {
display: grid;
gap: var(--space-5);
gap: var(--space-6);
}
.consultation-report-block {
display: grid;
gap: var(--space-2);
.consultation-step-tree__group + .consultation-step-tree__group {
margin-top: var(--space-4);
}
.consultation-report-analysis__label {
margin: 0 0 var(--space-2);
color: var(--color-ink);
font-size: 13px;
font-weight: 600;
line-height: 1.5;
.consultation-report-analysis {
min-width: 0;
}
.consultation-report-analysis .message-answer {
margin-top: 0;
}
.consultation-report-analysis .message-markdown {
color: var(--color-ink-strong);
}
.message-markdown ul.markdown-list,
.message-markdown ol.markdown-list {
display: grid;
gap: var(--space-3);
margin: var(--space-3) 0 var(--space-5);
padding-inline-start: 1.35em;
}
.message-markdown .markdown-list > li {
padding-block: 0;
padding-inline-start: 6px;
}
.message-markdown .markdown-list > li + li {
margin-top: 0;
}
.rectification-message-entry { min-width: 0; }
.message-actions {
display: flex;
@@ -1947,7 +1959,11 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.conversation:not(.is-empty):not(.is-rectification) .message-markdown ul,
.conversation:not(.is-empty):not(.is-rectification) .message-markdown ol {
margin: 4px 0 var(--space-5);
margin: var(--space-3) 0 var(--space-5);
}
.conversation:not(.is-empty):not(.is-rectification) .message-markdown .markdown-list {
gap: var(--space-4);
}
.conversation:not(.is-empty):not(.is-rectification) .markdown-table {
@@ -3,6 +3,8 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { promoteDefinitionLists } from "@/lib/chat-definition-lists";
const markdownComponents: Components = {
a: ({ children, href, ...props }) => (
<a {...props} href={href} rel="noreferrer" target="_blank">
@@ -25,7 +27,7 @@ export function renderChatMarkdown(text: string) {
remarkPlugins={[remarkGfm]}
skipHtml
>
{text}
{promoteDefinitionLists(text)}
</ReactMarkdown>
);
}
@@ -54,6 +54,7 @@ export function ChatMessageContent({
const split = splitSpokenAnswerAndTechniqueAudit(text);
const rows = resolveTechniqueAuditRows(split, auditRows);
const spoken = split.spoken;
const showFoldedAudit = rows.length > 0 && split.rows.length === 0;
return (
<div className="message-answer">
@@ -67,7 +68,7 @@ export function ChatMessageContent({
</div>
) : null}
{vargaSentence ? <p className="message-varga-sentence">{vargaSentence}</p> : null}
{rows.length > 0 ? <TechniqueAuditDisclosure rows={rows} /> : null}
{showFoldedAudit ? <TechniqueAuditDisclosure rows={rows} /> : null}
</div>
);
}
@@ -5,25 +5,9 @@ import { ThinkingStepTree } from "@/components/thinking-step-tree";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
applyThinkingSectionProgress,
REPORT_HEADING,
splitAnswerByHeadings,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
function analysisForSection(
section: PublicThinkingSection,
preamble: string,
slices: Record<string, string>,
isFirst: boolean,
): string {
const own = slices[section.heading] ?? "";
const wrap = section.id === "close" ? (slices[REPORT_HEADING.wrap] ?? "") : "";
const lead = isFirst || section.id === "foundation" || section.id === "answer" || section.id === "window"
? preamble
: "";
return [lead, own, wrap].filter((part) => part.trim()).join("\n\n");
}
export function ConsultationThinkingReport({
sections,
answer,
@@ -43,44 +27,40 @@ export function ConsultationThinkingReport({
auditRows?: readonly TechniqueAuditRow[];
vargaSentence?: string | null;
}>) {
const progressed = applyThinkingSectionProgress(sections, answer);
const headings = [
...progressed.map((section) => section.heading),
REPORT_HEADING.wrap,
];
const { preamble, slices } = splitAnswerByHeadings(answer, headings);
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">
{progressed.map((section, index) => {
const active = section.steps.some((step) => step.status === "active");
const analysis = analysisForSection(section, preamble, slices, index === 0);
const last = index === progressed.length - 1;
return (
<section className="consultation-report-block" key={section.id}>
<ThinkingStepTree
caption="思考"
intent={section.title}
steps={section.steps}
live={live && active}
liveLabel={live && active ? liveLabel : undefined}
liveState={liveState}
startedAt={live && active ? startedAt : undefined}
defaultOpen={active || (live && index === 0 && !answer.trim())}
/>
{analysis.trim() ? (
<div className="consultation-report-analysis">
<h3 className="consultation-report-analysis__label"></h3>
<ChatMessageContent
text={analysis}
auditRows={last ? auditRows : undefined}
vargaSentence={last ? vargaSentence : null}
/>
</div>
) : null}
</section>
);
})}
<ThinkingStepTree
caption="思考"
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>
);
}
+81 -30
View File
@@ -59,11 +59,68 @@ function StepMarker({
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,
steps,
steps = [],
groups,
hiddenCount,
revealAll = false,
live = false,
liveLabel,
liveState = "working",
@@ -72,8 +129,10 @@ export function ThinkingStepTree({
}: Readonly<{
caption?: string;
intent?: string;
steps: readonly PublicThinkingStep[];
steps?: readonly PublicThinkingStep[];
groups?: readonly ThinkingStepGroup[];
hiddenCount?: number;
revealAll?: boolean;
live?: boolean;
liveLabel?: string;
liveState?: OrbState;
@@ -81,36 +140,28 @@ export function ThinkingStepTree({
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const visible = hiddenCount === undefined ? visibleThinkingSteps(steps) : { visible: steps, hiddenCount };
const groupItems = groups ?? [{
id: "default",
intent,
steps,
live,
}];
const showAll = revealAll || hiddenCount === 0;
const body = (
<>
{intent ? <p className="consultation-step-tree__intent">{intent}</p> : null}
<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">
<span className="agent-thinking-marker is-pending" aria-hidden="true" />
<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>
{groupItems.map((group) => (
<div className="consultation-step-tree__group" key={group.id}>
{group.intent ? <p className="consultation-step-tree__intent">{group.intent}</p> : null}
<StepList
steps={group.steps}
revealAll={showAll}
live={Boolean(group.live)}
liveLabel={group.live ? liveLabel : undefined}
liveState={liveState}
startedAt={group.live ? startedAt : undefined}
/>
</div>
))}
</>
);
+54
View File
@@ -0,0 +1,54 @@
const FENCE = /(```[\s\S]*?```)/;
const DEFINITION_LINE = /^(.{2,80}?)[:](.+)$/u;
function isSkippableLine(line: string): boolean {
return /^(#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\|)/.test(line) || line.startsWith("```");
}
function isDefinitionLine(block: string): boolean {
const trimmed = block.trim();
if (!trimmed || trimmed.includes("\n") || isSkippableLine(trimmed)) return false;
const match = DEFINITION_LINE.exec(trimmed);
if (!match) return false;
const body = match[2]?.trim() ?? "";
return body.length >= 4;
}
function toListItem(block: string): string {
const trimmed = block.trim();
const match = DEFINITION_LINE.exec(trimmed);
if (!match) return trimmed;
return `- **${match[1].trim()}**${match[2].trim()}`;
}
function promoteProse(text: string): string {
const blocks = text.split(/\n{2,}/);
const out: string[] = [];
let index = 0;
while (index < blocks.length) {
if (isDefinitionLine(blocks[index] ?? "")) {
let end = index;
while (end < blocks.length && isDefinitionLine(blocks[end] ?? "")) end += 1;
if (end - index >= 2) {
out.push(blocks.slice(index, end).map((block) => toListItem(block)).join("\n"));
index = end;
continue;
}
}
const lines = (blocks[index] ?? "").split("\n").map((line) => line.trim()).filter(Boolean);
if (lines.length >= 2 && lines.every(isDefinitionLine)) {
out.push(lines.map((line) => toListItem(line)).join("\n"));
} else {
out.push(blocks[index] ?? "");
}
index += 1;
}
return out.join("\n\n");
}
export function promoteDefinitionLists(text: string): string {
if (!text.includes("") && !text.includes(":")) return text;
return text.split(FENCE).map((chunk, index) => (
index % 2 === 1 ? chunk : promoteProse(chunk)
)).join("");
}
@@ -170,8 +170,15 @@ export function windowConsultationThinkingPlan(): PublicThinkingSection[] {
export function applyThinkingSectionProgress(
sections: readonly PublicThinkingSection[],
answerText: string,
options?: { settled?: boolean },
): PublicThinkingSection[] {
if (sections.length === 0) return [];
if (options?.settled && answerText.trim()) {
return sections.map((section) => ({
...section,
steps: section.steps.map((item) => ({ ...item, status: "done" as const })),
}));
}
const present = new Set(
[...answerText.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? ""),
);
@@ -234,6 +241,7 @@ export function consultationSpokenHeadingRule(kind: "natal" | "general" | "windo
if (kind === "natal") {
return [
`Write the spoken answer with these exact Markdown H2 headings in order: ## ${REPORT_HEADING.foundation}, then ## {the Chinese label of each executed domain in tool order, such as 事业 / 财富 / 关系}, then ## ${REPORT_HEADING.audit}, then ## ${REPORT_HEADING.wrap}.`,
"Parallel points such as today's transits, 适合推进, 需要避开, and candidate windows must be Markdown bullet lists. Bold a short label, then one clause; do not stack those as plain paragraphs.",
activity,
secrets,
].join(" ");