mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+13
-2
@@ -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|I’m|We|We're|We’re|Let's|Let’s)\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),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user