From 8455df315961fe4e8cb4dabe15951e484668e581 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 11 Sep 2026 18:22:45 +0200 Subject: [PATCH] feat: follow a streaming run's pane without moving the page Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/AgentStreamDisplay.svelte | 30 +++++++++++- frontend/src/lib/components/agentScroll.ts | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/agentScroll.ts 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 +}