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), + }; +};