refactor: share the chat's stick-to-bottom mechanics with the agent run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 09:11:31 +02:00
co-authored by Claude Opus 5
parent 8455df3159
commit 41cbaa41bd
6 changed files with 84 additions and 53 deletions
@@ -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))
})
</script>
<div class="flex flex-col w-full py-3">
<div bind:this={anchor} class="flex flex-col w-full py-3">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
<LabeledDivider class="my-3">
@@ -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))
}
})
@@ -1278,7 +1278,7 @@
{:else if !forceJson && resultKind === 'aiagent'}
{@const agentResult = parseAgentResult(result)}
{#if agentResult}
<AgentResultDisplay result={agentResult} {workspaceId}>
<AgentResultDisplay result={agentResult} {workspaceId} runKey={jobId}>
{#snippet structuredOutput(output)}
<DisplayResult
noControls
@@ -21,27 +21,3 @@ export function runPane(node: HTMLElement | undefined | null): HTMLElement | und
}
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
}
@@ -1,4 +1,5 @@
<script lang="ts">
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()
@@ -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
}
}
}