From f36cdd9a49d6da87411979a8c9a0dca8bd060bd2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Jul 2026 14:57:12 -0500 Subject: [PATCH] feat(desktop): collapse long tool-call runs into an auto-scrolling window A back-to-back run of 3+ adjacent tool calls now collapses into a fixed-height window that pins the newest call to the bottom and fades older ones up under a top gradient, so a long run no longer shoves the reply off screen. Shorter runs are byte-identical to before, and the DOM shape is the same in both modes (only classes flip) so crossing the threshold mid-stream never remounts a row. Expanding any row breaks the window out to full height via a `:has([data-tool-open])` rule. --- .../components/assistant-ui/tool/fallback.tsx | 104 +++++++++++++++--- apps/desktop/src/styles.css | 32 ++++++ 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index a8267ce6b..acee3a18f 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -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(null) + const contentRef = useRef(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 `
` 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> = ({ children, @@ -615,15 +682,24 @@ export const ToolGroupSlot: FC= TOOL_GROUP_SCROLL_THRESHOLD + const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded) + return ( -
- {children} +
+
+
+ {children} +
+
) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 636d71c18..3c090e674 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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;