diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
index 6996912738..0e94afc57a 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
@@ -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();
},
};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
index fb70d20d41..5896627f1b 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -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 ? (
- Thinking
+ {title}
) : (
- Thinking
+ {title}
)
}
>
@@ -171,7 +173,7 @@ const ReasoningDisclosure = memo<{
urlTransform={urlTransform}
streaming={isStreaming}
>
- {displayText}
+ {body}
)}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.test.ts
new file mode 100644
index 0000000000..509087ae7c
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.test.ts
@@ -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",
+ );
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.ts b/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.ts
new file mode 100644
index 0000000000..aa11b4195d
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatConversation/thinkingTitle.ts
@@ -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),
+ };
+};