mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
a584958955
commit
ed0b71d4ff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user