fix(session-ui): show latest thinking summary heading while streaming (#53815)

This commit is contained in:
Luke Parker
2026-10-08 08:24:05 +10:00
committed by GitHub
Unverified
parent be19c7612f
commit 46c69bc35c
2 changed files with 61 additions and 29 deletions
@@ -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("<h2>First step</h2>\n<p>Body</p>\n<h3>Second step</h3>")).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")
})
})
+21 -28
View File
@@ -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(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
const html = lastHeading(markdown, /<h[1-6][^>]*>([\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<HeadingMatch | undefined>(
(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<HeadingMatch | undefined>((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) {