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 + } + } +}