Merge pull request #57913 from NousResearch/bb/desktop-tool-scroll

feat(desktop): auto-scrolling window for long tool-call runs
This commit is contained in:
brooklyn! 2026-07-03 14:58:47 -05:00 committed by GitHub
commit cd124ad1fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 14 deletions

View file

@ -2,7 +2,19 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react'
import {
Children,
createContext,
type FC,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
@ -428,6 +440,7 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
data-tool-open={open ? '' : undefined}
data-tool-row=""
ref={enterRef}
>
@ -592,6 +605,59 @@ function ToolEntry({ part }: ToolEntryProps) {
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
// growth (a call lands or a row expands) unless the user scrolled up, and fades
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
// preview. `enabled` is false for short runs, leaving the plain flat stack.
function useToolWindow(enabled: boolean) {
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const stickRef = useRef(true)
const [faded, setFaded] = useState(false)
const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), [])
const onScroll = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8
syncFade()
}, [syncFade])
useEffect(() => {
const el = scrollRef.current
const content = contentRef.current
if (!enabled || !el || !content) {
return
}
const pin = () => {
if (stickRef.current) {
el.scrollTop = el.scrollHeight
}
syncFade()
}
pin()
const observer = new ResizeObserver(pin)
observer.observe(content)
return () => observer.disconnect()
}, [enabled, syncFade])
return { contentRef, faded, onScroll, scrollRef }
}
/**
* Flat, Cursor-style tool list. assistant-ui hands us a *range* of
* consecutive tool-call parts, but how that range is sliced is unstable: a
@ -600,12 +666,13 @@ function ToolEntry({ part }: ToolEntryProps) {
* (one big range). Rendering a "Tool actions · N steps" group off that range
* therefore reshuffled the whole turn the instant it settled.
*
* So we never group: each tool is a standalone row, and the wrapper just lays
* its children out on the tight `--tool-row-gap` rhythm. One range or ten,
* fragmented or consecutive, the result is pixel-identical a tight, stable
* stack. The wrapper stays a single `<div>` of stable identity so children
* never remount as the range grows mid-stream. `ToolEmbedContext` is false so
* every row owns its own chrome (timer / preview / copy / inline approval).
* So we still never *label* the group: each tool is a standalone row on the
* tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD`
* rows it collapses into a fixed-height, auto-scrolling window so a long run
* doesn't shove the reply off screen; shorter runs are byte-identical to before.
* The DOM shape is the same either way only classes flip so a run that
* crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is
* false so every row owns its own chrome (timer / preview / copy / approval).
*/
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children,
@ -615,15 +682,24 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
const messageRunning = useAuiState(selectMessageRunning)
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
const bounded = Children.count(children) >= TOOL_GROUP_SCROLL_THRESHOLD
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
return (
<ToolEmbedContext.Provider value={false}>
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
data-tool-group=""
ref={enterRef}
>
{children}
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" data-tool-group="" ref={enterRef}>
<div
className={cn(
bounded && 'tool-group-scroll max-h-(--tool-group-scroll-max-h) overflow-y-auto',
bounded && faded && 'tool-group-scroll--faded'
)}
onScroll={bounded ? onScroll : undefined}
ref={scrollRef}
>
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" ref={contentRef}>
{children}
</div>
</div>
</div>
</ToolEmbedContext.Provider>
)

View file

@ -349,6 +349,10 @@
/* Tight gap between tool rows inside a single action group, so a back-to-back
run still reads as one cohesive sequence. */
--tool-row-gap: 0.375rem;
/* Height of the bounded, auto-scrolling window a long adjacent tool-call run
collapses into (see `.tool-group-scroll`). Sized to show a few rows before
the scroll + top fade kick in. */
--tool-group-scroll-max-h: 6.75rem;
/* Paragraph spacing vertical gap between prose paragraphs, both inside a
markdown block and between consecutive prose parts. Single knob; tweak
freely. */
@ -1432,6 +1436,34 @@ text-* variant utilities. */ .btn-arc {
mask-image: linear-gradient(to bottom, transparent 0%, black 28%, black 100%);
}
/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window.
ToolGroupSlot pins the newest call to the bottom (unless the user scrolls
up), so a back-to-back run stays compact instead of pushing the reply off
screen. A thin scrollbar keeps the affordance discoverable. */
.tool-group-scroll {
scrollbar-width: thin;
scrollbar-color: var(--ui-stroke-tertiary) transparent;
}
/* Break out of the fixed window the moment any row inside is expanded: the
user is now reading a diff/output, so let it grow to full height instead of
peering at it through a ~2-row viewport. Collapsing the row drops it back
into the compact, auto-scrolling window. Beats a larger fixed cap since an
open row already bounds its own payload with an inner scroll. */
.tool-group-scroll:has([data-tool-row][data-tool-open]) {
max-height: none;
-webkit-mask-image: none;
mask-image: none;
}
/* Top gradient only applied once the user is scrolled down off the top, so
the oldest visible call fades up under the fade while the first row stays
fully legible when scrolled all the way up. */
.tool-group-scroll--faded {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
}
@keyframes code-card-stream-enter {
from {
opacity: 0.74;