mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: follow a streaming run's pane without moving the page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5cb176cc65
commit
8455df3159
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<!-- The same order a finished run uses — what it did, then what it is saying — so
|
||||
nothing moves when the result lands. The text at the bottom is deliberately
|
||||
unlabelled: a turn that goes on to call a tool was narration, and only the end
|
||||
of the run settles which this one is. -->
|
||||
<div class="flex flex-col w-full py-3">
|
||||
<div bind:this={anchor} class="flex flex-col w-full py-3">
|
||||
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
|
||||
{#if entry.kind === 'tool'}
|
||||
<ChatCollapsibleCard
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The pane an agent run is rendered in: the nearest ancestor that is a real
|
||||
* scroll container.
|
||||
*
|
||||
* Selection is on `overflow-y` alone, deliberately *not* on whether the element
|
||||
* currently overflows. A pane that happens to fit its content is still the pane,
|
||||
* and skipping it walks straight past into page chrome — `#content` and `<body>`
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user