fix(ui): reasoning blocks respect scroll position and expanded state (#8642)

Guard streaming auto-scroll behind a near-bottom check so the user can
scroll up without being snapped back on the next token. Track explicit
user opens in a module-level Set so auto-collapse is skipped when the
user has the block open.

Closes #8586

Co-authored-by: Thomas Brugman <thomas.brugman.teb3@gmail.com>
This commit is contained in:
Marius
2026-04-09 11:01:54 +02:00
committed by GitHub
parent fc25106c25
commit 7594a02580
2 changed files with 41 additions and 9 deletions
@@ -1254,6 +1254,9 @@ const streamed = new Set<string>()
// Tracks parts that have already been auto-collapsed once, so component
// recreation (from store updates while other parts stream) won't collapse again.
const autocollapsed = new Set<string>()
// Tracks parts that the user has explicitly opened, so auto-collapse won't
// override the user's intent when reasoning finishes or a tool call starts.
const userOpened = new Set<string>()
// Overrides upstream flat markdown render with streaming reasoning block + auto-collapse.
// Also filters encrypted reasoning data from OpenRouter that appears as [REDACTED].
@@ -1283,14 +1286,24 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
// Streaming → open. Just finished (was streaming, now done) → open briefly
// then collapse. Historical → collapsed from the start.
const [open, setOpen] = createSignal(!done() || was)
// Restore user's explicit open preference across component recreations.
const [open, setOpen] = createSignal(!done() || was || userOpened.has(id))
// Propagate user intent to the module-level set so it survives component
// recreations (e.g. when a tool call arrives while reading reasoning).
const track = (value: boolean) => {
if (value) userOpened.add(id)
else userOpened.delete(id)
setOpen(value)
}
// Auto-collapse once when reasoning finishes (streaming → done transition).
// Collapses immediately so the grid transition runs in sync with the
// streaming-height removal. Module-level Set prevents re-triggering on
// component recreation or when the user manually reopens.
// component recreation. Skipped entirely if the user has explicitly opened
// the block, so reading is not interrupted by a subsequent tool call.
createEffect(() => {
if (done() && open() && !autocollapsed.has(id)) {
if (done() && open() && !autocollapsed.has(id) && !userOpened.has(id)) {
autocollapsed.add(id)
setOpen(false)
}
@@ -1298,13 +1311,32 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
onCleanup(() => {
if (done()) streamed.delete(id)
// userOpened is intentionally NOT deleted here. The component recreates
// frequently while other parts stream (same as autocollapsed), so removing
// the entry on unmount would discard the user's explicit preference and
// re-collapse the block on the next remount.
})
// Auto-scroll the content container while streaming
// Auto-scroll the content container while streaming.
// Use a plain mutable flag rather than checking dist inside the reactive
// effect: by the time the effect runs the DOM has already grown, so reading
// scrollHeight post-update incorrectly reports the user as scrolled away
// whenever a streaming chunk is > 10px tall.
let ref: HTMLDivElement | undefined
let scrolled = false
const onScroll = (e: Event) => {
const el = e.currentTarget as HTMLDivElement
if (el.scrollHeight - el.clientHeight - el.scrollTop < 10) scrolled = false
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) scrolled = true
}
createEffect(() => {
display()
if (!done() && ref) {
if (!done() && ref && !scrolled) {
ref.scrollTop = ref.scrollHeight
}
})
@@ -1312,7 +1344,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
return (
<Show when={display()}>
<div data-component="reasoning-part" data-streaming={!done() ? "" : undefined}>
<Collapsible open={open()} onOpenChange={setOpen} class="tool-collapsible">
<Collapsible open={open()} onOpenChange={track} class="tool-collapsible">
<Collapsible.Trigger>
<div data-slot="reasoning-header">
<Icon name="brain" size="small" />
@@ -1321,7 +1353,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="reasoning-content" ref={ref}>
<div data-slot="reasoning-content" ref={ref} onScroll={onScroll} onWheel={onWheel}>
<Markdown text={display()} cacheKey={id} />
</div>
</Collapsible.Content>
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b0f41c54aca88874a3c74151b77ef20f0f17fa9bb2f149c13adfaf4f48de286
size 14702
oid sha256:cc32d99eeff1cb3061caa4a75e3353e30e6373073bbd7ebf17048c917367e11a
size 28005