diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 27404c83eb..6b94bfff6c 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -119,6 +119,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useRef, useState, } from "react"; @@ -127,6 +128,18 @@ import { // composer), so the composer can show its "Drop files here" affordance. const PageDragContext = createContext(false); +// Gap (px) between the last message and the floating composer. The bottom +// spacer tracks composer height plus this gap so the chat can always be +// scrolled fully above the composer. +const COMPOSER_SCROLL_GAP_PX = 24; +// The scroll-to-bottom footer sits 10px below the spacer top. +const FOOTER_GAP_BELOW_SPACER_PX = 10; +// Composer shrinks this soon after a run start (send clears the chips) +// apply immediately: the run-start pin owns the bottom, so the clamp is +// the intended glide. Covers instant responses where isRunning is +// already false by the time the dock resize is observed. +const RUN_SHRINK_WINDOW_MS = 1000; + export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean; @@ -144,12 +157,156 @@ export const Thread: FC<{ ); const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const threadId = targetThreadId ?? activeThreadId ?? null; + const aui = useAui(); + + // Measured height of the floating composer dock (null until measured). + // Drives the bottom spacer and the scroll-to-bottom footer offset. + const [composerHeight, setComposerHeight] = useState(null); + const footerBottomPx = + composerHeight == null + ? null + : composerHeight + COMPOSER_SCROLL_GAP_PX - FOOTER_GAP_BELOW_SPACER_PX; + + // The viewport element is owned by the autoscroll hook; mirror it + // locally for the spacer clamp math below. State, not a ref: the keyed + // provider below remounts the viewport on thread switches, and the + // scroll listener effect must re-attach to the new element. + const [viewportEl, setViewportEl] = useState(null); + const composedViewportRef = useCallback( + (node: HTMLElement | null) => { + setViewportEl(node); + viewportRef(node); + }, + [viewportRef], + ); + + // Bottom spacer sizing. Invariant: the chat never moves on its own when + // the composer resizes. + // - Grow (attachment added, multiline input): grow the spacer at once. + // Growth below the scroll position is invisible and only adds room. + // - Shrink (attachment removed): shrinking scrollHeight near the bottom + // would clamp scrollTop and yank the chat down. Defer the shrink until + // it is invisible (user scrolled up) or a bottom-pinning moment. + // Applied imperatively so a remounted spacer can be sized from refs even + // when composerHeight did not change (e.g. thread switch). + const spacerElRef = useRef(null); + const desiredSpacerPxRef = useRef(null); + const appliedSpacerPxRef = useRef(null); + + const applySpacerPx = useCallback((px: number) => { + appliedSpacerPxRef.current = px; + const node = spacerElRef.current; + if (node) { + node.style.height = `${px}px`; + } + }, []); + + // Release any deferred shrink; used at moments that pin to the bottom + // anyway, where the clamp is the intended motion. + const releaseSpacerExcess = useCallback(() => { + const desired = desiredSpacerPxRef.current; + const applied = appliedSpacerPxRef.current; + if (desired != null && applied != null && applied > desired) { + applySpacerPx(desired); + } + }, [applySpacerPx]); + + const spacerRef = useCallback( + (node: HTMLDivElement | null) => { + spacerElRef.current = node; + // Fresh mounts (thread switch, first message) start at the desired + // size; deferral state from a previous mount is moot. + const desired = desiredSpacerPxRef.current; + if (node && desired != null) { + applySpacerPx(desired); + } + }, + [applySpacerPx], + ); + + const prevComposerHeightRef = useRef(null); + // Set on thread.runStart; see RUN_SHRINK_WINDOW_MS. + const runStartAtRef = useRef(0); + useLayoutEffect(() => { + const prev = prevComposerHeightRef.current; + prevComposerHeightRef.current = composerHeight; + if (composerHeight == null || hideComposer) { + desiredSpacerPxRef.current = null; + appliedSpacerPxRef.current = null; + spacerElRef.current?.style.removeProperty("height"); + return; + } + const desired = composerHeight + COMPOSER_SCROLL_GAP_PX; + desiredSpacerPxRef.current = desired; + const applied = appliedSpacerPxRef.current; + if (applied == null || desired >= applied) { + applySpacerPx(desired); + } else { + const distance = viewportEl + ? viewportEl.scrollHeight - viewportEl.scrollTop - viewportEl.clientHeight + : Number.POSITIVE_INFINITY; + const runOwnsBottom = + aui.thread().getState().isRunning || + performance.now() - runStartAtRef.current < RUN_SHRINK_WINDOW_MS; + // At the bottom the shrink only drops blank spacer, so apply it now + // instead of stranding dead space until the next pin. + if ( + runOwnsBottom || + distance >= applied - desired || + autoScrollContext.getIsAtBottom() + ) { + applySpacerPx(desired); + } + // else: deferred; released on scroll or a bottom-pinning event. + } + if (prev != null && composerHeight > prev) { + // The chat is now above the new bottom. Detach as if the user had + // scrolled up so no later signal re-pins and shoves the chat up. + // Scrolling back down re-attaches; explicit pins still work. + // Mid-run growth comes from tool-status rows, not the user, and + // detaching then would break streaming autoscroll, so skip it. + if (!aui.thread().getState().isRunning) { + autoScrollContext.detachFromBottom(); + } + } + }, [composerHeight, hideComposer, autoScrollContext, aui, applySpacerPx, viewportEl]); + + // Drop deferred spacer excess as soon as the user has scrolled far + // enough above the bottom that the shrink cannot clamp scrollTop. + // Keyed on viewportEl so the listener follows viewport remounts. + useEffect(() => { + const el = viewportEl; + if (!el) { + return; + } + const onScroll = () => { + const desired = desiredSpacerPxRef.current; + const applied = appliedSpacerPxRef.current; + if (desired == null || applied == null || applied <= desired) { + return; + } + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distance >= applied - desired) { + applySpacerPx(desired); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [viewportEl, applySpacerPx]); + + // These pin to the bottom, so releasing the excess here is invisible. + // runStart also opens the shrink window for the send-clears-chips case. + useAuiEvent("thread.runStart", () => { + runStartAtRef.current = performance.now(); + releaseSpacerExcess(); + }); + useAuiEvent("thread.initialize", releaseSpacerExcess); + useAuiEvent("threadListItem.switchedTo", releaseSpacerExcess); // Page-wide drag-and-drop: dropping a file anywhere on the chat page (not // just on the composer) attaches it and shows the composer drop affordance. // The composer's own dropzone still handles drops on the box itself; its // handler calls preventDefault, so the page handler skips them (no double-add). - const aui = useAui(); const [pageDragging, setPageDragging] = useState(false); const dragDepth = useRef(0); const hasFiles = (e: ReactDragEvent) => @@ -208,7 +365,7 @@ export const Thread: FC<{ > hideWelcome || !thread.isEmpty}>
@@ -250,21 +415,34 @@ export const Thread: FC<{ className={cn( "aui-thread-viewport-footer pointer-events-none sticky z-20 flex w-full justify-center bg-transparent", // 150px (was 140px) to add a small gap above the composer - hideComposer ? "bottom-3" : "bottom-[150px]", + hideComposer + ? "bottom-3" + : footerBottomPx == null + ? "bottom-[150px]" + : undefined, )} + style={ + !hideComposer && footerBottomPx != null + ? { bottom: footerBottomPx } + : undefined + } > - + {!hideComposer && ( hideWelcome || !thread.isEmpty}> )} @@ -275,9 +453,10 @@ export const Thread: FC<{ ); }; -const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ - hideComposer, -}) => { +const GeneratedImageViewportOverlay: FC<{ + hideComposer?: boolean; + bottomOffsetPx?: number | null; +}> = ({ hideComposer, bottomOffsetPx }) => { const { overlay, closeOverlay } = useGeneratedImageOverlay(); useEffect(() => { @@ -302,8 +481,17 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
@@ -370,11 +558,29 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({ const ThreadComposerDock: FC<{ disabled?: boolean; threadId?: string | null; -}> = ({ disabled, threadId }) => { + onHeightChange?: (height: number | null) => void; +}> = ({ disabled, threadId, onHeightChange }) => { const { overlay } = useGeneratedImageOverlay(); + // Report the dock's rendered height so the viewport can reserve matching + // scroll space when attachments or multiline input grow the composer. + const dockRef = useRef(null); + useEffect(() => { + const el = dockRef.current; + if (!el || !onHeightChange) return; + const measure = () => onHeightChange(el.offsetHeight); + measure(); + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(el); + return () => { + resizeObserver.disconnect(); + onHeightChange(null); + }; + }, [onHeightChange]); + return (
boolean; subscribe: (listener: () => void) => () => void; + /** + * Mark the user as detached from the bottom, as if they had scrolled + * up. Called when the composer grows and the bottom spacer grows with + * it: the chat is then above the new bottom, and observer-driven pins + * must not shove it up. Scrolling back to the bottom re-attaches; + * explicit pins (run start, scroll-to-bottom button) still work. + */ + detachFromBottom: () => void; }; const noopContext: AutoScrollContextValue = { @@ -87,6 +95,9 @@ const noopContext: AutoScrollContextValue = { subscribe: () => () => { /* no-op */ }, + detachFromBottom: () => { + /* no viewport mounted */ + }, }; const AutoScrollContext = createContext(noopContext); @@ -129,6 +140,9 @@ export function useIntentAwareAutoScroll(): { const scrollImplRef = useRef(() => { /* no viewport mounted */ }); + const detachImplRef = useRef<() => void>(() => { + /* no viewport mounted */ + }); const getIsAtBottom = useCallback(() => isAtBottomRef.current, []); @@ -153,8 +167,12 @@ export function useIntentAwareAutoScroll(): { scrollImplRef.current(behavior); }, []); + const detachFromBottom = useCallback(() => { + detachImplRef.current(); + }, []); + const attach = useCallback( - (el: HTMLElement) => { + (el: HTMLElement, isRebind: boolean) => { let rafId: number | null = null; let lastScrollTop = el.scrollTop; let lastClientWidth = el.clientWidth; @@ -286,6 +304,13 @@ export function useIntentAwareAutoScroll(): { requestTick(); }; + // Programmatic detach (see detachFromBottom). Same effect as the + // user scrolling up; the tick refresh updates isAtBottom. + detachImplRef.current = () => { + detach(); + requestTick(); + }; + const onWheel = (e: WheelEvent) => { if ( e.deltaY < 0 && @@ -476,21 +501,24 @@ export function useIntentAwareAutoScroll(): { const mutationObserver = new MutationObserver(onLayoutChange); const onViewportResize = onLayoutChange; - // Fresh attach always starts pinned. `userDetachedRef` survives - // ref rebinds (it's hook-scoped), so if the viewport element is - // ever unmounted and remounted without an AUI lifecycle event - // (e.g. a parent layout refactor that remounts the viewport), - // a prior detach would silently disable auto-follow for the - // rest of the session. - userDetachedRef.current = false; + // Fresh attach (a new viewport element) always starts pinned. + // Rebinds to the SAME element must not pin or reset detach state: + // the Viewport composes refs with an identity that changes on + // re-render, so React re-runs the ref (null, then same element) + // on unrelated renders such as composer resizes. Pinning here + // would yank the chat to the bottom on every such render. The + // observers below are re-installed either way. + if (!isRebind) { + userDetachedRef.current = false; - // Pin to bottom when the ref first attaches. Covers the case - // where `thread.initialize` fires before the ref is bound. - extendFollow(); - if (el.scrollHeight > el.clientHeight) { - el.scrollTo({ top: el.scrollHeight, behavior: "instant" }); + // Pin to bottom when the ref first attaches. Covers the case + // where `thread.initialize` fires before the ref is bound. + extendFollow(); + if (el.scrollHeight > el.clientHeight) { + el.scrollTo({ top: el.scrollHeight, behavior: "instant" }); + } + setIsAtBottom(true); } - setIsAtBottom(true); requestTick(); // Observe the border box, not the content box. The stabilizer @@ -544,6 +572,9 @@ export function useIntentAwareAutoScroll(): { scrollImplRef.current = () => { /* no viewport mounted */ }; + detachImplRef.current = () => { + /* no viewport mounted */ + }; }; }, [setIsAtBottom], @@ -562,6 +593,7 @@ export function useIntentAwareAutoScroll(): { useAuiEvent("thread.initialize", () => pinToBottom("instant")); useAuiEvent("threadListItem.switchedTo", () => pinToBottom("instant")); + const lastElRef = useRef(null); const ref = useCallback>( (el) => { if (cleanupRef.current) { @@ -569,15 +601,20 @@ export function useIntentAwareAutoScroll(): { cleanupRef.current = null; } if (el) { - cleanupRef.current = attach(el); + // Same-element rebind vs a genuinely new element, see attach(). + const isRebind = lastElRef.current === el; + lastElRef.current = el; + cleanupRef.current = attach(el, isRebind); } + // On null, keep lastElRef so a rebind to the same element is + // recognized; a real remount binds a different element anyway. }, [attach], ); const context = useMemo( - () => ({ scrollToBottom, getIsAtBottom, subscribe }), - [scrollToBottom, getIsAtBottom, subscribe], + () => ({ scrollToBottom, getIsAtBottom, subscribe, detachFromBottom }), + [scrollToBottom, getIsAtBottom, subscribe, detachFromBottom], ); return { ref, context };