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
\nBody
\nSecond 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) {