feat: follow a streaming run's pane without moving the page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 09:10:52 +02:00
co-authored by Claude Opus 5
parent 5cb176cc65
commit 8455df3159
2 changed files with 76 additions and 1 deletions
@@ -2,6 +2,7 @@
import { untrack } from 'svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { isFollowingEnd, runPane, scrollRunToEnd } from './agentScroll'
import {
advanceAgentStream,
emptyAgentStreamProgress,
@@ -33,13 +34,40 @@
let stream = $derived(progress.stream)
let anchor: HTMLElement | undefined = $state()
// Carry the reader along as the text is written, unless they have scrolled up
// to read something — then the pane is theirs until they come back to the end.
let following = true
// The listener goes on the pane, not on this element: a scroll event fires on
// whatever actually scrolled and does not bubble, so a handler here would never
// run and the reader would be dragged back on every poll.
$effect(() => {
const pane = runPane(anchor)
if (!pane) {
return
}
const onScroll = () => (following = isFollowingEnd(anchor))
pane.addEventListener('scroll', onScroll, { passive: true })
return () => pane.removeEventListener('scroll', onScroll)
})
$effect(() => {
stream.current
stream.reasoning
stream.entries.length
if (following) {
scrollRunToEnd(anchor)
}
})
</script>
<!-- The same order a finished run uses — what it did, then what it is saying — so
nothing moves when the result lands. The text at the bottom is deliberately
unlabelled: a turn that goes on to call a tool was narration, and only the end
of the run settles which this one is. -->
<div class="flex flex-col w-full py-3">
<div bind:this={anchor} class="flex flex-col w-full py-3">
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
{#if entry.kind === 'tool'}
<ChatCollapsibleCard
@@ -0,0 +1,47 @@
/**
* The pane an agent run is rendered in: the nearest ancestor that is a real
* scroll container.
*
* Selection is on `overflow-y` alone, deliberately *not* on whether the element
* currently overflows. A pane that happens to fit its content is still the pane,
* and skipping it walks straight past into page chrome — `#content` and `<body>`
* report `scrollHeight > clientHeight` merely because the document scrolls, so an
* overflow test picks them and scrolling one drops a whole run page to its
* footer. Their `overflow-y` is `visible`, which is what actually distinguishes
* them, and the walk stops at the page's content root either way.
*/
export function runPane(node: HTMLElement | undefined | null): HTMLElement | undefined {
let current = node?.parentElement
while (current && current !== document.body && current.id !== 'content') {
const overflowY = getComputedStyle(current).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return undefined
}
/** Scroll the run's pane to its end, where its output is. */
export function scrollRunToEnd(node: HTMLElement | undefined | null) {
const pane = runPane(node)
if (pane) {
pane.scrollTop = pane.scrollHeight
}
}
/** Within this many pixels of the end, a reader is still following along. */
const FOLLOWING_PX = 32
/**
* Whether the reader is still at the end and so wants new content to carry them
* with it. Someone who has scrolled up to read an earlier row is reading it, and
* must not be dragged back on the next poll.
*/
export function isFollowingEnd(node: HTMLElement | undefined | null): boolean {
const pane = runPane(node)
if (!pane) {
return false
}
return pane.scrollHeight - pane.scrollTop - pane.clientHeight <= FOLLOWING_PX
}