diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 9d7e8248c4e..d33515443e8 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -6,7 +6,14 @@ import type { SessionMessageInfo, } from "@opencode/client/promise" import { createStore } from "solid-js/store" -import { createTimelineProjection, reuseTimelineRows, Timeline, TimelineRow, type PartGroup } from "./projection" +import { + createTimelineProjection, + reasoningHeading, + reuseTimelineRows, + Timeline, + TimelineRow, + type PartGroup, +} from "./projection" const context = (key: string, partIDs: string[], identity: { userMessageID?: string; messageID?: string } = {}) => new TimelineRow.AssistantPart({ @@ -325,3 +332,35 @@ describe("createTimelineProjection", () => { ]) }) }) + +describe("reasoningHeading", () => { + test("returns the most recent thinking summary heading as sections stream in", () => { + const sections = [ + "**Designing Interactive Experience**\nI'm focusing on crafting a fun, tactile, and interactive visual toy.", + "**Analyzing Reading Recommendations**\nI'm currently dissecting whether Luke Parker should read the *Throne of Glass* series.", + "**Constructing Reader Tools**\nI'm now building out interactive tools.", + ] + + expect(reasoningHeading(sections[0])).toBe("Designing Interactive Experience") + expect(reasoningHeading(sections.slice(0, 2).join("\n\n"))).toBe("Analyzing Reading Recommendations") + expect(reasoningHeading(sections.join("\n\n"))).toBe("Constructing Reader Tools") + }) + + test("keeps the previous heading while the next bold heading is still streaming", () => { + const text = [ + "**Designing Interactive Experience**\nI'm focusing on crafting a fun, tactile, and interactive visual toy.", + "**Analyzing Reading", + ].join("\n\n") + + expect(reasoningHeading(text)).toBe("Designing Interactive Experience") + }) + + test("returns the latest heading across markdown heading styles and ignores inline bold spans", () => { + expect(reasoningHeading("## First step\n\nDetails.\n\n## Second step\n\nMore details.")).toBe("Second step") + expect(reasoningHeading("

First step

\n

Body

\n

Second step

")).toBe("Second step") + expect(reasoningHeading("First step\n===\n\nSecond step\n---")).toBe("Second step") + expect( + reasoningHeading("**Designing Interactive Experience**\n\n**Note** that this line ends with **bold**"), + ).toBe("Designing Interactive Experience") + }) +}) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 2cfdecc9915..e2e2dfad8fd 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -869,39 +869,32 @@ function toolGroupType( export function reasoningHeading(text: string): string | undefined { const markdown = text.replace(/\r\n?/g, "\n") - const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) + const html = lastHeading(markdown, /]*>([\s\S]*?)<\/h[1-6]>/gi, (value) => value.replace(/<[^>]+>/g, " ")) + const atx = lastHeading(markdown, /^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/gm) + const setext = lastHeading(markdown, /^([^\n]+)\n(?:=+|-+)\s*$/gm) + const strong = lastHeading(markdown, /^\s*(?:\*\*((?:(?!\*\*).)+)\*\*|__((?:(?!__).)+)__)\s*$/gm) - if (html?.[1]) { - const value = cleanHeading(html[1].replace(/<[^>]+>/g, " ")) + const latest = [html, atx, strong].reduce( + (best, current) => (!best || (current && current.index >= best.index) ? current : best), + undefined, + ) - if (value) return value - } + return (latest ?? setext)?.value +} - const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) +type HeadingMatch = { index: number; value: string } - if (atx?.[1]) { - const value = cleanHeading(atx[1]) +function lastHeading( + markdown: string, + pattern: RegExp, + transform?: (value: string) => string, +): HeadingMatch | undefined { + return Array.from(markdown.matchAll(pattern)).reduce((best, match) => { + const raw = match[1] ?? match[2] ?? "" + const value = cleanHeading(transform ? transform(raw) : raw) - if (value) return value - } - - const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) - - if (setext?.[1]) { - const value = cleanHeading(setext[1]) - - if (value) return value - } - - const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) - - if (strong?.[1]) { - const value = cleanHeading(strong[1]) - - if (value) return value - } - - return undefined + return value ? { index: match.index, value } : best + }, undefined) } function cleanHeading(value: string) {