feat(site): show reasoning heading in thinking block (#25594)

> Mux is opening this PR on behalf of Mike.

Updates agent chat thinking disclosures to include the first Markdown
heading or leading header-like reasoning paragraph, rendering titles
like `Thinking about configuring model settings` while preserving
`Thinking` when no heading is present.

Existing chat logs store many thinking section titles as bold standalone
paragraphs, such as `**Checking tool execution**`. This handles that
format too, and removes the displayed heading from the expanded thinking
body so it does not appear twice. Adds focused title/body extraction
coverage and updates the conversation timeline story for the heading
title behavior.
This commit is contained in:
Michael Suchacz
2026-05-22 01:59:09 +02:00
committed by GitHub
parent fa9eb1ad56
commit 1809cfc37f
4 changed files with 400 additions and 7 deletions
@@ -2121,7 +2121,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
content: [
{
type: "reasoning",
text: "Let me think about this step by step.",
text: "**Configuring model settings**\n\nLet me think about this step by step.",
},
{
type: "text",
@@ -2133,12 +2133,23 @@ export const ThinkingBlockAlwaysExpanded: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Thinking")).toBeInTheDocument();
expect(
canvas.getByText("Thinking about configuring model settings"),
).toBeInTheDocument();
await waitFor(() => {
expect(
canvas.getByText(/Let me think about this step by step/),
).toBeVisible();
});
const thinkingRow = canvas
.getByText(/Let me think about this step by step/)
.closest("[data-transcript-row]");
expect(thinkingRow).toBeInstanceOf(HTMLElement);
expect(
within(thinkingRow as HTMLElement).queryByText(
"Configuring model settings",
),
).not.toBeInTheDocument();
},
};
@@ -44,6 +44,7 @@ import { FileProbeProvider } from "./FileProbeContext";
import { deriveMessageDisplayState } from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
import { getThinkingDisclosureDisplay } from "./thinkingTitle";
import type {
MergedTool,
ParsedMessageContent,
@@ -125,12 +126,13 @@ const ReasoningDisclosure = memo<{
streamKey: id,
});
const displayText = isStreaming ? visibleText : text;
const hasText = displayText.trim().length > 0;
const { title, body } = getThinkingDisclosureDisplay(displayText);
const hasText = body.trim().length > 0;
// Auto-scroll the preview container to the bottom as new
// thinking content streams in. useLayoutEffect avoids a
// visible frame where content has grown but not scrolled.
const displayTextLength = displayText.length;
const displayTextLength = body.length;
useLayoutEffect(() => {
if (
displayTextLength &&
@@ -151,10 +153,10 @@ const ReasoningDisclosure = memo<{
header={
isStreaming ? (
<Shimmer as="span" className="text-[13px]">
Thinking
{title}
</Shimmer>
) : (
<span className="text-[13px]">Thinking</span>
<span className="text-[13px]">{title}</span>
)
}
>
@@ -171,7 +173,7 @@ const ReasoningDisclosure = memo<{
urlTransform={urlTransform}
streaming={isStreaming}
>
{displayText}
{body}
</Response>
</div>
)}
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { getThinkingDisclosureDisplay } from "./thinkingTitle";
describe("getThinkingDisclosureDisplay", () => {
it("returns the default title and original body when there is no heading", () => {
expect(getThinkingDisclosureDisplay("Let me think this through.")).toEqual({
title: "Thinking",
body: "Let me think this through.",
});
});
it("uses the first ATX heading and removes it from the body", () => {
expect(
getThinkingDisclosureDisplay(
[
"I need to inspect the configuration.",
"",
"### Configuring model settings",
"The model has several options.",
].join("\n"),
),
).toEqual({
title: "Thinking about configuring model settings",
body: [
"I need to inspect the configuration.",
"",
"The model has several options.",
].join("\n"),
});
});
it("preserves existing body content before the first heading", () => {
expect(
getThinkingDisclosureDisplay(
[
" I need to inspect the configuration.",
"",
"### Configuring model settings",
"The model has several options.",
].join("\n"),
),
).toEqual({
title: "Thinking about configuring model settings",
body: [
" I need to inspect the configuration.",
"",
"The model has several options.",
].join("\n"),
});
});
it("uses a leading header-like paragraph and removes it from the body", () => {
expect(
getThinkingDisclosureDisplay(
[
"**Configuring model settings**",
"",
"I need to inspect the model configuration.",
].join("\n"),
),
).toEqual({
title: "Thinking about configuring model settings",
body: "I need to inspect the model configuration.",
});
});
it("uses a body-only emphasized heading", () => {
expect(getThinkingDisclosureDisplay("**Checking tool execution**")).toEqual(
{
title: "Thinking about checking tool execution",
body: "",
},
);
});
it("keeps ordinary opening sentences in the body", () => {
expect(
getThinkingDisclosureDisplay(
[
"I need to inspect the model configuration",
"",
"The model has several options.",
].join("\n"),
),
).toEqual({
title: "Thinking",
body: [
"I need to inspect the model configuration",
"",
"The model has several options.",
].join("\n"),
});
});
it("uses setext headings and removes them from the body", () => {
expect(
getThinkingDisclosureDisplay(
[
"Configuring model settings",
"---",
"The model has several options.",
].join("\n"),
),
).toEqual({
title: "Thinking about configuring model settings",
body: "The model has several options.",
});
});
it("ignores headings inside fenced code blocks", () => {
expect(
getThinkingDisclosureDisplay(
["```md", "# Not the title", "```", "## Reviewing logs", "Done"].join(
"\n",
),
),
).toEqual({
title: "Thinking about reviewing logs",
body: ["```md", "# Not the title", "```", "Done"].join("\n"),
});
});
it("cleans common inline markdown from headings", () => {
expect(
getThinkingDisclosureDisplay(
"### **Configuring** `model` settings [docs](https://example.com) ###",
),
).toEqual({
title: "Thinking about configuring model settings docs",
body: "",
});
});
it("preserves acronym and mixed-case heading starts", () => {
expect(getThinkingDisclosureDisplay("### API configuration").title).toBe(
"Thinking about API configuration",
);
expect(getThinkingDisclosureDisplay("### GitHub Actions").title).toBe(
"Thinking about GitHub Actions",
);
});
});
@@ -0,0 +1,238 @@
const DEFAULT_THINKING_TITLE = "Thinking";
type LineRange = {
line: string;
start: number;
nextStart: number;
};
type HeadingMatch = {
text: string;
start: number;
end: number;
};
type ThinkingDisclosureDisplay = {
title: string;
body: string;
};
const cleanHeadingText = (text: string): string =>
text
.replace(/\\([\\`*_[\]{}()#+.!-])/g, "$1")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1")
.replace(/<\/?[^>]+>/g, "")
.replace(/\s+/g, " ")
.trim();
const getLines = (text: string): LineRange[] => {
const lines: LineRange[] = [];
const linePattern = /[^\r\n]*(?:\r\n|\r|\n|$)/g;
for (const match of text.matchAll(linePattern)) {
const rawLine = match[0];
const start = match.index ?? 0;
if (rawLine === "" && start === text.length) {
break;
}
const line = rawLine.replace(/\r\n$|\r$|\n$/, "");
lines.push({
line,
start,
nextStart: start + rawLine.length,
});
}
return lines;
};
const getAtxHeadingText = (line: string): string | undefined => {
const match = line.match(/^ {0,3}#{1,6}(?:[ \t]+|$)(.*)$/);
if (!match) {
return undefined;
}
const heading = cleanHeadingText(match[1].replace(/[ \t]+#{1,}[ \t]*$/, ""));
return heading || undefined;
};
const getFenceMarker = (
line: string,
): { character: "`" | "~"; length: number } | undefined => {
const match = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (!match) {
return undefined;
}
const marker = match[1];
return {
character: marker[0] as "`" | "~",
length: marker.length,
};
};
const isClosingFence = (
line: string,
marker: { character: "`" | "~"; length: number },
): boolean => {
const match = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
return (
!!match &&
match[1][0] === marker.character &&
match[1].length >= marker.length
);
};
const hasBodyAfterLine = (
lines: readonly LineRange[],
index: number,
): boolean => lines.slice(index + 1).some(({ line }) => line.trim().length > 0);
const getEmphasizedLineHeadingText = (line: string): string | undefined => {
const match = line.match(/^ {0,3}(?:\*\*([^*]+)\*\*|__([^_]+)__)[ \t]*$/);
if (!match) {
return undefined;
}
const heading = cleanHeadingText(match[1] ?? match[2] ?? "");
return heading || undefined;
};
const isHeadingLikeParagraph = (
lines: readonly LineRange[],
index: number,
text: string,
): string | undefined => {
const lineRange = lines[index];
const prefix = text.slice(0, lineRange.start);
if (prefix.trim()) {
return undefined;
}
const emphasizedHeading = getEmphasizedLineHeadingText(lineRange.line);
const nextLine = lines[index + 1];
const hasBody = hasBodyAfterLine(lines, index);
if ((!nextLine || nextLine.line.trim() || !hasBody) && !emphasizedHeading) {
return undefined;
}
const heading = emphasizedHeading ?? cleanHeadingText(lineRange.line);
if (!heading) {
return undefined;
}
const wordCount = heading.split(/\s+/).length;
if (heading.length > 96 || wordCount > 12 || /[.!?]$/.test(heading)) {
return undefined;
}
if (
/^[a-z]/.test(heading) ||
/^(I|I'm|Im|We|We're|Were|Let's|Lets)\b/.test(heading)
) {
return undefined;
}
return heading;
};
const getFirstHeading = (text: string): HeadingMatch | undefined => {
let activeFence: { character: "`" | "~"; length: number } | undefined;
let setextCandidate: LineRange | undefined;
const lines = getLines(text);
for (const [index, lineRange] of lines.entries()) {
const { line } = lineRange;
if (activeFence) {
if (isClosingFence(line, activeFence)) {
activeFence = undefined;
}
continue;
}
const openingFence = getFenceMarker(line);
if (openingFence) {
activeFence = openingFence;
setextCandidate = undefined;
continue;
}
const atxHeading = getAtxHeadingText(line);
if (atxHeading) {
return {
text: atxHeading,
start: lineRange.start,
end: lineRange.nextStart,
};
}
const paragraphHeading = isHeadingLikeParagraph(lines, index, text);
if (paragraphHeading) {
return {
text: paragraphHeading,
start: lineRange.start,
end: lineRange.nextStart,
};
}
if (/^ {0,3}(=+|-+)[ \t]*$/.test(line) && setextCandidate) {
const heading = cleanHeadingText(setextCandidate.line);
if (!heading) {
return undefined;
}
return {
text: heading,
start: setextCandidate.start,
end: lineRange.nextStart,
};
}
const trimmedLine = line.trim();
setextCandidate = trimmedLine ? lineRange : undefined;
}
return undefined;
};
const lowercaseSentenceStart = (text: string): string => {
const firstWord = text.match(/^[A-Za-z]+\b/)?.[0];
if (!firstWord || !/^[A-Z][a-z]+$/.test(firstWord)) {
return text;
}
return `${firstWord[0].toLowerCase()}${text.slice(1)}`;
};
const removeHeading = (text: string, heading: HeadingMatch): string => {
const beforeHeading = text.slice(0, heading.start);
const body = `${beforeHeading}${text.slice(heading.end)}`;
if (beforeHeading.trim()) {
return body;
}
return body.replace(/^\s+/, "");
};
export const getThinkingDisclosureDisplay = (
text: string,
): ThinkingDisclosureDisplay => {
const heading = getFirstHeading(text);
if (!heading) {
return {
title: DEFAULT_THINKING_TITLE,
body: text,
};
}
return {
title: `${DEFAULT_THINKING_TITLE} about ${lowercaseSentenceStart(heading.text)}`,
body: removeHeading(text, heading),
};
};