diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte
index 3384d216ba..05ce8b1eb8 100644
--- a/frontend/src/lib/components/AgentStreamDisplay.svelte
+++ b/frontend/src/lib/components/AgentStreamDisplay.svelte
@@ -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)
+ }
+ })
+
-
+
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
{#if entry.kind === 'tool'}
`
+ * 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
+}