diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte
index f207b4ca9d..1a81944077 100644
--- a/frontend/src/lib/components/AgentResultDisplay.svelte
+++ b/frontend/src/lib/components/AgentResultDisplay.svelte
@@ -5,11 +5,15 @@
import AgentTrace from './AgentTrace.svelte'
import LabeledDivider from './LabeledDivider.svelte'
import { buildAgentTrace } from './agentTrace'
+ import { runPane } from './agentScroll'
+ import { createBottomSticker } from './stickToBottom'
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
interface Props {
result: AgentResult
workspaceId?: string
+ /** Identifies the run, so a reused viewer lands on the new one's output. */
+ runKey?: string
/**
* How to render an output that is not text. An `output_schema` makes `output`
* an object, and the right rendering for it is whatever the result viewer
@@ -20,7 +24,7 @@
structuredOutput: Snippet<[unknown]>
}
- let { result, workspaceId, structuredOutput }: Props = $props()
+ let { result, workspaceId, runKey, structuredOutput }: Props = $props()
let summary = $derived(summarizeAgentResult(result))
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
@@ -32,9 +36,19 @@
const entries = buildAgentTrace(result.messages)
return entries.at(-1)?.kind === 'assistant' ? entries.slice(0, -1) : entries
})
+
+ let anchor: HTMLElement | undefined = $state()
+ const sticker = createBottomSticker()
+ $effect(() => {
+ // Also on arrival from a stream: the run finishing adds the output separator,
+ // so the end has moved from wherever the stream had the reader parked.
+ runKey
+ trace.length
+ sticker.scrollToEnd(runPane(anchor))
+ })
-
+
{#if trace.length > 0}
diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte
index 05ce8b1eb8..c7c95eb902 100644
--- a/frontend/src/lib/components/AgentStreamDisplay.svelte
+++ b/frontend/src/lib/components/AgentStreamDisplay.svelte
@@ -2,7 +2,8 @@
import { untrack } from 'svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
- import { isFollowingEnd, runPane, scrollRunToEnd } from './agentScroll'
+ import { runPane } from './agentScroll'
+ import { createBottomSticker } from './stickToBottom'
import {
advanceAgentStream,
emptyAgentStreamProgress,
@@ -38,6 +39,7 @@
// 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
+ const sticker = createBottomSticker()
// 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
@@ -47,7 +49,11 @@
if (!pane) {
return
}
- const onScroll = () => (following = isFollowingEnd(anchor))
+ const onScroll = () => {
+ if (!sticker.isOwnScroll()) {
+ following = sticker.isAtEnd(pane)
+ }
+ }
pane.addEventListener('scroll', onScroll, { passive: true })
return () => pane.removeEventListener('scroll', onScroll)
})
@@ -57,7 +63,7 @@
stream.reasoning
stream.entries.length
if (following) {
- scrollRunToEnd(anchor)
+ sticker.scrollToEnd(runPane(anchor))
}
})
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte
index 3142b5b2a8..740244e8c1 100644
--- a/frontend/src/lib/components/DisplayResult.svelte
+++ b/frontend/src/lib/components/DisplayResult.svelte
@@ -1278,7 +1278,7 @@
{:else if !forceJson && resultKind === 'aiagent'}
{@const agentResult = parseAgentResult(result)}
{#if agentResult}
-
+
{#snippet structuredOutput(output)}
+ import { createBottomSticker } from '$lib/components/stickToBottom'
import AIChatMessage from './AIChatMessage.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import ChatContextPicker from './ChatContextPicker.svelte'
@@ -265,21 +266,11 @@
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
- // Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
- // event; if a token-append between the scrollTo and the dispatch makes
- // scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
- // disengage auto-scroll mid-stream. A short cooldown after our own
- // scroll swallows that spurious event without affecting genuine user
- // scrolls (wheel/touch/keyboard are reaction-time orders of magnitude
- // slower than the cooldown).
- const PROGRAMMATIC_SCROLL_COOLDOWN_MS = 120
- let programmaticScrollAt: number | undefined
- // Instant scroll — smooth would animate every token append, racing with
- // the next scrollDown and confusing the onscroll bottom-detection below.
+ // Shared with the agent run viewer, which needs the same programmatic-scroll
+ // guard for the same reason.
+ const sticker = createBottomSticker()
function scrollDown() {
- if (!scrollElement) return
- programmaticScrollAt = Date.now()
- scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
+ sticker.scrollToEnd(scrollElement)
}
let height = $state(0)
@@ -298,10 +289,6 @@
}
})
- // Pixel distance from the bottom under which we treat the user as
- // "stuck to the bottom" and re-enable automatic scroll. 8px allows for
- // sub-pixel rounding from scrollTo + the occasional overscroll bounce.
- const STICK_TO_BOTTOM_PX = 8
// Show the "scroll to latest" arrow only once the user has scrolled
// meaningfully away from the tail — a couple of message-heights up. Avoids
// flicker when the auto-scroll lags by a few px during streaming.
@@ -316,13 +303,10 @@
// whose only event would otherwise be swallowed, leaving the arrow
// stuck visible after we already reached the bottom.
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
- if (
- programmaticScrollAt !== undefined &&
- Date.now() - programmaticScrollAt < PROGRAMMATIC_SCROLL_COOLDOWN_MS
- ) {
+ if (sticker.isOwnScroll()) {
return
}
- if (distance <= STICK_TO_BOTTOM_PX) {
+ if (sticker.isAtEnd(scrollElement)) {
chatHost.enableAutomaticScroll()
} else {
chatHost.disableAutomaticScroll()
diff --git a/frontend/src/lib/components/stickToBottom.ts b/frontend/src/lib/components/stickToBottom.ts
new file mode 100644
index 0000000000..2430aab37b
--- /dev/null
+++ b/frontend/src/lib/components/stickToBottom.ts
@@ -0,0 +1,51 @@
+/**
+ * The mechanics of keeping a growing pane pinned to its end, shared by the AI
+ * chat transcript and the agent run viewer.
+ *
+ * Both need the same non-obvious guard, which is why this is not two copies: a
+ * programmatic scroll dispatches its own `scroll` event asynchronously, and if
+ * content lands between the call and the event the gap can exceed the threshold
+ * for one tick — read naively, that looks like the reader scrolling away and
+ * disengages the follow mid-stream.
+ */
+
+/**
+ * Distance from the end within which a reader counts as still following. Allows
+ * for sub-pixel rounding from `scrollTo` and the occasional overscroll bounce.
+ */
+export const STICK_TO_BOTTOM_PX = 8
+
+/** A scroll event this close after our own scroll is ours, not the reader's. */
+const OWN_SCROLL_WINDOW_MS = 120
+
+export type BottomSticker = {
+ /** Jump to the end. Instant: smooth would animate every append and race the next. */
+ scrollToEnd: (pane: HTMLElement | undefined | null) => void
+ /** Whether the pane is at its end, i.e. the reader wants to be carried along. */
+ isAtEnd: (pane: HTMLElement | undefined | null) => boolean
+ /** Whether the scroll event being handled was one we caused. */
+ isOwnScroll: () => boolean
+}
+
+export function createBottomSticker(): BottomSticker {
+ let scrolledAt: number | undefined
+
+ return {
+ scrollToEnd(pane) {
+ if (!pane) {
+ return
+ }
+ scrolledAt = Date.now()
+ pane.scrollTo({ top: pane.scrollHeight, behavior: 'auto' })
+ },
+ isAtEnd(pane) {
+ if (!pane) {
+ return false
+ }
+ return pane.scrollHeight - pane.scrollTop - pane.clientHeight <= STICK_TO_BOTTOM_PX
+ },
+ isOwnScroll() {
+ return scrolledAt !== undefined && Date.now() - scrolledAt < OWN_SCROLL_WINDOW_MS
+ }
+ }
+}