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:
commit
cd124ad1fa
2 changed files with 122 additions and 14 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue