diff --git a/apps/web/components/task/chat/anchored-last-prompt-bar.test.tsx b/apps/web/components/task/chat/anchored-last-prompt-bar.test.tsx index b0782c4806..e7c50e1b8c 100644 --- a/apps/web/components/task/chat/anchored-last-prompt-bar.test.tsx +++ b/apps/web/components/task/chat/anchored-last-prompt-bar.test.tsx @@ -6,11 +6,13 @@ import { AnchoredLastPromptBar } from "./anchored-last-prompt-bar"; const BAR_TESTID = "anchored-last-prompt-bar"; const EXPAND_TESTID = "anchored-last-prompt-expand"; const TEXT_TESTID = "anchored-last-prompt-text"; +const CONTENT_TESTID = "anchored-last-prompt-content"; const SHORT_TEXT = "fix the bug"; const LONG_TEXT = "Please refactor the authentication module to support OAuth as well as the existing session cookie flow, and add tests."; const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); function renderBar( overrides: Partial[0]> = {}, @@ -41,13 +43,21 @@ function setPromptMeasurements(clientHeight: number, scrollHeight: number) { }); } +function setContentHeight(offsetHeight: number) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => offsetHeight, + }); +} + function restorePromptMeasurements() { restoreMeasurement("clientHeight", originalClientHeight); restoreMeasurement("scrollHeight", originalScrollHeight); + restoreMeasurement("offsetHeight", originalOffsetHeight); } function restoreMeasurement( - property: "clientHeight" | "scrollHeight", + property: "clientHeight" | "scrollHeight" | "offsetHeight", descriptor: PropertyDescriptor | undefined, ) { if (descriptor) { @@ -203,3 +213,26 @@ describe("AnchoredLastPromptBar controls", () => { expect(screen.queryByRole("button", { name: /scroll to last prompt/i })).toBeNull(); }); }); + +describe("AnchoredLastPromptBar height reporting", () => { + it("reports the pinned content's rendered height on mount, independent of open/closed state", () => { + setContentHeight(76); + const onHeightChange = vi.fn(); + + renderBar({ isVisible: false, onHeightChange }); + + expect(onHeightChange).toHaveBeenCalledWith(76); + screen.getByTestId(CONTENT_TESTID); + }); + + it("reports zero height on unmount so a removed bar stops reserving scroll space", () => { + setContentHeight(76); + const onHeightChange = vi.fn(); + const { unmount } = renderBar({ onHeightChange }); + onHeightChange.mockClear(); + + unmount(); + + expect(onHeightChange).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/web/components/task/chat/anchored-last-prompt-bar.tsx b/apps/web/components/task/chat/anchored-last-prompt-bar.tsx index 471bd8f8a3..e4bc1dd6aa 100644 --- a/apps/web/components/task/chat/anchored-last-prompt-bar.tsx +++ b/apps/web/components/task/chat/anchored-last-prompt-bar.tsx @@ -17,8 +17,42 @@ type AnchoredLastPromptBarProps = { onScrollUp: () => void; /** Whether the scroll-to-last-prompt control is enabled. */ showScrollToLastPrompt?: boolean; + /** Reports the pinned content's current rendered height (px) whenever it + * changes, and 0 on unmount. Measures the content row nested *inside* + * the grid item that the open/closed transform collapses via + * `grid-template-rows` — that grandchild is normal block content, not + * itself a grid item, so its own box keeps its natural (open) height + * even while an ancestor's `overflow-hidden` clips it down to nothing + * on screen. This lets a caller reserve scroll room for the bar ahead + * of it actually opening (see resolveLastPromptControls). */ + onHeightChange?: (height: number) => void; }; +/** Reports `contentRef`'s rendered height to `onHeightChange` on mount and + * on every subsequent resize, and 0 once unmounted. */ +function useReportContentHeight( + contentRef: React.RefObject, + onHeightChange: ((height: number) => void) | undefined, +) { + useLayoutEffect(() => { + const el = contentRef.current; + if (!el || !onHeightChange) return; + + const report = () => onHeightChange(el.offsetHeight); + report(); + + let observer: ResizeObserver | undefined; + if ("ResizeObserver" in window) { + observer = new ResizeObserver(report); + observer.observe(el); + } + return () => { + observer?.disconnect(); + onHeightChange(0); + }; + }, [contentRef, onHeightChange]); +} + /** Proportional cap for the expanded view: 40% of the transcript scroll * container's actual height, so it stays sensible on both a tall * full-screen panel and a short embedded/split view. `.chat-message-list` @@ -90,12 +124,15 @@ export function AnchoredLastPromptBar({ isVisible, onScrollUp, showScrollToLastPrompt = true, + onHeightChange, }: AnchoredLastPromptBarProps) { const [expanded, setExpanded] = useState(false); const textRef = useRef(null); + const contentRef = useRef(null); const visible = stripSystemTags(promptText); const expandedMaxHeight = useExpandedMaxHeight(textRef); const canExpand = useCanExpand(textRef, expanded, visible); + useReportContentHeight(contentRef, onHeightChange); useLayoutEffect(() => { setExpanded(false); @@ -122,7 +159,11 @@ export function AnchoredLastPromptBar({ )} >
-
+
{showScrollToLastPrompt && ( ({ + useDockviewStore: Object.assign( + (selector: (state: { pendingChatScrollTop: number | null }) => unknown) => + selector({ pendingChatScrollTop: null }), + { getState: () => ({ pendingChatScrollTop: null }) }, + ), +})); + +import { useScrollToDividerOrBottom } from "./message-list-native"; + +const DIVIDER_KEY = "m2"; + +function Harness({ + itemCount, + anchoredBarOffsetPx, + dividerKey = DIVIDER_KEY, +}: { + itemCount: number; + anchoredBarOffsetPx: number; + dividerKey?: string | null; +}) { + const scrollRef = useRef(null); + useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx); + return ( +
+
+
+
+ ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useScrollToDividerOrBottom — anchored-bar offset", () => { + it("re-scrolls the divider into view once the anchored bar's measured height arrives, so it lands below the pinned bar instead of under it", () => { + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + const { rerender } = render(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // The anchored bar's real height resolves a tick after mount (async + // ResizeObserver report) — the divider must be re-placed now that the + // scroll-margin backing it is no longer 0, or it stays hidden under the + // bar for good (didScrollToDivider already latched true). + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(2); + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "start" }); + }); + + it("never re-scrolls once the reader has started scrolling, even if the anchored bar's height changes afterward", () => { + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + const { rerender, container } = render(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + container.querySelector("div")?.dispatchEvent(new Event("wheel", { bubbles: true })); + + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it("never scrolls the divider when there is no unread boundary, regardless of anchored-bar height changes", () => { + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + const { rerender } = render( + , + ); + expect(scrollIntoView).not.toHaveBeenCalled(); + + rerender(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it("stops re-scrolling once the settling window has elapsed, even without any user interaction", () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + const { rerender } = render(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // Past the 4s settling window (e.g. a scrollbar drag with no + // wheel/touch/key event to catch — the correction must freeze anyway). + vi.advanceTimersByTime(4001); + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 0c9f99c87f..5c54fea7ae 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -17,6 +17,7 @@ import { MessageListStatus, MessageItem, UnreadDivider, + anchoredBarScrollOffsetPx, getItemKey, getConversationLoadingState, getEffectiveActiveTurnId, @@ -106,7 +107,7 @@ function MessageRow({ return (
{dividerBeforeItemKey === key && } @@ -188,10 +189,11 @@ type NativeMessageListBodyProps = { * see TaskChatPanel) never resolve a divider, so they keep the * original, unconditional scroll-to-bottom-on-mount behavior untouched. */ -function useScrollToDividerOrBottom( +export function useScrollToDividerOrBottom( scrollRef: React.RefObject, itemCount: number, dividerBeforeItemKey: string | null | undefined, + anchoredBarOffsetPx: number, ) { const isUserScrollingRef = useRef(false); useEffect(() => { @@ -252,7 +254,7 @@ function useScrollToDividerOrBottom( } el.scrollTop = el.scrollHeight; didInitialScroll.current = true; - }, [itemCount, dividerBeforeItemKey]); + }, [itemCount, dividerBeforeItemKey, anchoredBarOffsetPx]); } /** Sentinel, status/footer, and transcript rows — everything below the @@ -361,6 +363,7 @@ export const NativeMessageList = memo( onFirstMessageHiddenChange, stickyPromptBar, dividerBeforeItemKey, + anchoredBarHeight, }: MessageListProps, ref, ) { @@ -389,7 +392,11 @@ export const NativeMessageList = memo( isLoadingMore, loadMore, }); - useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey); + const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight); + useEffect(() => { + scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`); + }, [anchoredBarOffsetPx]); + useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey, anchoredBarOffsetPx); useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [ handleScrollToMessage, ]); diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index 4320fc55a9..8be76fbba6 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -75,6 +75,7 @@ import { MessageItem, MessageListStatus, UnreadDivider, + anchoredBarScrollOffsetPx, getConversationLoadingState, getEffectiveActiveTurnId, getItemKey, @@ -89,6 +90,24 @@ import { shouldLoadMoreForTranscriptTarget, } from "./message-list-shared"; +describe("anchoredBarScrollOffsetPx", () => { + it("passes through a measured height unchanged", () => { + expect(anchoredBarScrollOffsetPx(63)).toBe(63); + }); + + it("treats an unmeasured (undefined) height as no offset", () => { + expect(anchoredBarScrollOffsetPx(undefined)).toBe(0); + }); + + it("clamps a negative height to zero", () => { + expect(anchoredBarScrollOffsetPx(-12)).toBe(0); + }); + + it("rounds a fractional height to whole pixels", () => { + expect(anchoredBarScrollOffsetPx(63.7)).toBe(64); + }); +}); + const item: RenderItem = { type: "message", message: { id: "m1" } as Message }; const noop = () => {}; const perm = new Map(); diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index 697f26491f..f5c85b2a93 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -53,6 +53,11 @@ export type MessageListProps = { * top — the desktop-only, opt-in anchored last-prompt bar. `null`/`undefined` * when the setting is off or on mobile. */ stickyPromptBar?: ReactNode; + /** Current rendered height (px) of the anchored last-prompt bar's pinned + * overlay, or 0/undefined when it isn't showing. Lets a target scrolled + * to the top of the transcript (e.g. the unread "New" divider) reserve + * room for the overlay instead of being covered by it. */ + anchoredBarHeight?: number; }; /** Imperative handle exposed by `MessageList` and both rendering strategies, @@ -182,6 +187,16 @@ export function isElementFullyVisible(container: HTMLElement, target: HTMLElemen return t.top >= c.top - tolerance && t.bottom <= c.bottom + tolerance; } +/** Pixel offset to reserve at the top of the transcript for the anchored + * last-prompt bar's pinned overlay, so a scroll-into-view target (namely + * the unread "New" divider) lands below it instead of underneath it. + * Clamps an unmeasured/negative height to zero and rounds to whole pixels + * — callers feed this straight into a CSS `scroll-margin-top` or a + * Virtuoso `scrollToIndex` offset. */ +export function anchoredBarScrollOffsetPx(anchoredBarHeight: number | undefined): number { + return Math.max(0, Math.round(anchoredBarHeight ?? 0)); +} + /** Only the latest ordinary agent row in the active turn can be the streaming reply. */ export function getStreamingAgentMessageId(messages: Message[]): string | null { const latestUserIndex = findLastUserMessageIndex(messages); diff --git a/apps/web/components/task/chat/message-list-virtuoso.test.tsx b/apps/web/components/task/chat/message-list-virtuoso.test.tsx new file mode 100644 index 0000000000..ab76f47e9c --- /dev/null +++ b/apps/web/components/task/chat/message-list-virtuoso.test.tsx @@ -0,0 +1,162 @@ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RenderItem } from "@/hooks/use-processed-messages"; +import type { Message } from "@/lib/types/http"; +import type { VirtuosoHandle } from "react-virtuoso"; + +import { useScrollToDividerOnceResolved } from "./message-list-virtuoso"; + +const items: RenderItem[] = [ + { type: "message", message: { id: "m1" } as Message }, + { type: "message", message: { id: "m2" } as Message }, +]; +const itemsWithLiveMessage: RenderItem[] = [ + ...items, + { type: "message", message: { id: "m3" } as Message }, +]; +const DIVIDER_KEY = "m2"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function makeHarness( + virtuosoRef: { current: VirtuosoHandle }, + scrollParent: HTMLDivElement, + dividerKey: string | null = DIVIDER_KEY, + runLocked: (performScroll: () => void) => void = (performScroll) => performScroll(), +) { + return function DirectHarness({ + offsetPx, + renderItems = items, + }: { + offsetPx: number; + renderItems?: RenderItem[]; + }) { + useScrollToDividerOnceResolved(virtuosoRef, renderItems, 0, dividerKey, { + offsetPx, + scrollParent, + runLocked, + }); + return null; + }; +} + +it("runs the reassert scroll through runLocked so followOutput can't fight a live-update reassertion", () => { + let insideLock = false; + let scrolledInsideLock: boolean | null = null; + const scrollToIndex = vi.fn(() => { + scrolledInsideLock = insideLock; + }); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const runLocked = vi.fn((performScroll: () => void) => { + insideLock = true; + performScroll(); + insideLock = false; + }); + const DirectHarness = makeHarness( + virtuosoRef, + document.createElement("div"), + DIVIDER_KEY, + runLocked, + ); + + render(); + + expect(runLocked).toHaveBeenCalledTimes(1); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + expect(scrolledInsideLock).toBe(true); +}); + +describe("useScrollToDividerOnceResolved — anchored-bar offset", () => { + it("negatively offsets the divider scroll by the anchored bar's height so the item lands below the pinned bar", () => { + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const DirectHarness = makeHarness(virtuosoRef, document.createElement("div")); + + render(); + + expect(scrollToIndex).toHaveBeenCalledWith({ index: 1, align: "start", offset: -76 }); + }); + + it("re-scrolls once the anchored bar's measured height arrives after the initial placement", () => { + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const DirectHarness = makeHarness(virtuosoRef, document.createElement("div")); + + const { rerender } = render(); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: 0 }); + + rerender(); + + expect(scrollToIndex).toHaveBeenCalledTimes(2); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 }); + }); + + it("never re-scrolls once the reader has started scrolling, even if the anchored bar's height changes afterward", () => { + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const scrollParent = document.createElement("div"); + document.body.appendChild(scrollParent); + const DirectHarness = makeHarness(virtuosoRef, scrollParent); + + const { rerender } = render(); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + + scrollParent.dispatchEvent(new Event("wheel", { bubbles: true })); + + rerender(); + + expect(scrollToIndex).toHaveBeenCalledTimes(1); + scrollParent.remove(); + }); + + it("never scrolls when there is no unread boundary, regardless of anchored-bar height changes", () => { + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"), null); + + const { rerender } = render(); + expect(scrollToIndex).not.toHaveBeenCalled(); + + rerender(); + + expect(scrollToIndex).not.toHaveBeenCalled(); + }); + + it("stops re-scrolling once the settling window has elapsed, even without any user interaction", () => { + vi.useFakeTimers(); + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const DirectHarness = makeHarness(virtuosoRef, document.createElement("div")); + + const { rerender } = render(); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4001); + rerender(); + + expect(scrollToIndex).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("keeps the same offset across a live-update reassertion triggered by a new message, not just an offset change", () => { + const scrollToIndex = vi.fn(); + const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle }; + const DirectHarness = makeHarness(virtuosoRef, document.createElement("div")); + + const { rerender } = render(); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 }); + + // A live message arriving mid-settling-window changes `items` (a new + // array reference) while the anchored bar's height is unchanged — the + // pre-existing multi-wave reassertion must reapply the *same* offset, + // not drop or double it. + rerender(); + + expect(scrollToIndex).toHaveBeenCalledTimes(2); + expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 }); + }); +}); diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx index 001554ad6a..1de2ec633d 100644 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -37,6 +37,8 @@ import { MessageListStatus, MessageItem, UnreadDivider, + anchoredBarScrollOffsetPx, + canReassertDividerScroll, getItemKey, getConversationLoadingState, getEffectiveActiveTurnId, @@ -260,21 +262,108 @@ function useVirtuosoRenderItem(args: RenderItemArgs) { * divider, and a value frozen at that first render could never be * corrected. Scrolling here imperatively, gated on a change to * dividerBeforeItemKey rather than mount, catches it whenever it resolves. + * + * `anchoredBar.offsetPx` negatively offsets the placement (see Virtuoso's + * documented sticky-header pattern) so the divider lands below the + * anchored last-prompt bar's pinned overlay instead of underneath it. The + * bar's real height typically arrives an async tick after mount, after + * the first placement already ran with offset 0 — {@link + * canReassertDividerScroll} allows exactly one more correction once it + * does, under the same settling-window/no-user-scroll gate + * `useScrollToDividerOrBottom` (message-list-native.tsx) uses for its + * own multi-wave corrections. + * + * The actual `scrollToIndex` call runs through `runLocked` (see + * `useProgrammaticScrollLock`) so `followOutput` can't fight a reassertion + * that fires while a live message is streaming in — without the lock, a + * `followOutput` re-evaluation triggered by that same message could snap + * the transcript back to the bottom and silently cancel the correction. */ -function useScrollToDividerOnceResolved( +export function useScrollToDividerOnceResolved( virtuosoRef: React.RefObject, items: RenderItem[], firstItemIndex: number, dividerBeforeItemKey: string | null | undefined, + anchoredBar: { + offsetPx: number; + scrollParent: HTMLDivElement; + runLocked: (performScroll: () => void) => void; + }, ) { + const { offsetPx: anchoredBarOffsetPx, scrollParent, runLocked } = anchoredBar; + const isUserScrollingRef = useRef(false); + useEffect(() => { + const markUserScrolling = () => { + isUserScrollingRef.current = true; + }; + scrollParent.addEventListener("wheel", markUserScrolling, { passive: true }); + scrollParent.addEventListener("touchstart", markUserScrolling, { passive: true }); + scrollParent.addEventListener("keydown", markUserScrolling); + return () => { + scrollParent.removeEventListener("wheel", markUserScrolling); + scrollParent.removeEventListener("touchstart", markUserScrolling); + scrollParent.removeEventListener("keydown", markUserScrolling); + }; + }, [scrollParent]); + + // Mirrors useScrollToDividerOrBottom's 4s settling window: bounds how + // long the correction below can keep re-asserting after mount, + // independent of user interaction. + const mountedAtRef = useRef(null); + if (mountedAtRef.current === null) mountedAtRef.current = Date.now(); + const isWithinSettlingWindow = () => Date.now() - (mountedAtRef.current ?? 0) < 4000; + const didScrollRef = useRef(false); useEffect(() => { - if (didScrollRef.current || !dividerBeforeItemKey) return; + if (!dividerBeforeItemKey) return; const dividerIndex = items.findIndex((item) => getItemKey(item) === dividerBeforeItemKey); if (dividerIndex < 0) return; - virtuosoRef.current?.scrollToIndex({ index: firstItemIndex + dividerIndex, align: "start" }); + const canReassert = canReassertDividerScroll({ + hasDividerTarget: true, + didScrollToDivider: didScrollRef.current, + isUserScrolling: isUserScrollingRef.current, + isWithinSettlingWindow: isWithinSettlingWindow(), + }); + if (!canReassert) return; + runLocked(() => { + virtuosoRef.current?.scrollToIndex({ + index: firstItemIndex + dividerIndex, + align: "start", + offset: -anchoredBarOffsetPx || 0, + }); + }); didScrollRef.current = true; - }, [virtuosoRef, items, firstItemIndex, dividerBeforeItemKey]); + }, [ + virtuosoRef, + items, + firstItemIndex, + dividerBeforeItemKey, + anchoredBarOffsetPx, + scrollParent, + runLocked, + ]); +} + +/** Debounced `startReached` handler for lazy-loading older messages: a + * 500ms cooldown after each `loadMore()` settles prevents Virtuoso from + * re-firing `startReached` (e.g. while the prepended page is still + * settling scroll position) into a runaway fetch loop. */ +function useLoadOlderOnStartReached( + hasMore: boolean, + isLoadingMore: boolean, + loadMore: () => Promise, +) { + const loadCooldownRef = useRef(false); + return useCallback(() => { + if (hasMore && !isLoadingMore && !loadCooldownRef.current) { + loadCooldownRef.current = true; + loadMore().finally(() => { + setTimeout(() => { + loadCooldownRef.current = false; + }, 500); + }); + } + }, [hasMore, isLoadingMore, loadMore]); } function useVirtuosoCallbacks(props: VirtuosoBodyProps) { @@ -292,20 +381,13 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { const itemCount = items.length; const streamingMessageId = getStreamingAgentMessageId(messages); const firstItemIndex = useStableFirstItemIndex(items); - useScrollToDividerOnceResolved(virtuosoRef, items, firstItemIndex, dividerBeforeItemKey); const { isLocked, runLocked } = useProgrammaticScrollLock(props.scrollParent); - - const loadCooldownRef = useRef(false); - const handleStartReached = useCallback(() => { - if (hasMore && !isLoadingMore && !loadCooldownRef.current) { - loadCooldownRef.current = true; - loadMore().finally(() => { - setTimeout(() => { - loadCooldownRef.current = false; - }, 500); - }); - } - }, [hasMore, isLoadingMore, loadMore]); + useScrollToDividerOnceResolved(virtuosoRef, items, firstItemIndex, dividerBeforeItemKey, { + offsetPx: anchoredBarScrollOffsetPx(props.anchoredBarHeight), + scrollParent: props.scrollParent, + runLocked, + }); + const handleStartReached = useLoadOlderOnStartReached(hasMore, isLoadingMore, loadMore); const handleScrollToMessage = useCallback( (messageId: string, options?: { align?: "start" | "center" }) => { diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 6d00df4189..3b0be24e33 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -265,6 +265,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ // The anchored bar is a desktop-only, opt-in affordance; mobile always // falls back to the scroll button. const showAnchoredBar = !isMobile && showAnchoredPromptBar; + const [anchoredBarHeight, setAnchoredBarHeight] = useState(0); const { anchoredBarVisible, scrollButtonEligible, scrollDirection } = resolveLastPromptControls(lastPromptEdge); const showScrollButton = @@ -370,6 +371,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ onLastPromptEdgeChange={setLastPromptEdge} firstMessageId={firstMessageId} onFirstMessageHiddenChange={setIsFirstMessageHidden} + anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0} stickyPromptBar={ showAnchoredBar && lastPromptMessage ? ( ) : undefined } diff --git a/apps/web/e2e/tests/chat/unread-divider.spec.ts b/apps/web/e2e/tests/chat/unread-divider.spec.ts index 67bb548c4c..102c043338 100644 --- a/apps/web/e2e/tests/chat/unread-divider.spec.ts +++ b/apps/web/e2e/tests/chat/unread-divider.spec.ts @@ -187,4 +187,44 @@ test.describe("Unread divider", () => { const newestRow = activeChat.locator(`[id="msg-${newestMessageId}"]`); expect(await isScrolledIntoView(scrollContainer, newestRow)).toBe(false); }); + + test("reserves room for the anchored last-prompt bar so it does not cover the New divider on visit start", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + await apiClient.saveUserSettings({ show_anchored_prompt_bar: true }); + + const { taskId } = await seedScrollTestConversation( + apiClient, + seedData, + "Unread Divider Anchored Bar Overlap Test", + ); + + // First visit: the divider lands at the unread boundary while the task + // description (the session's only user-authored message, seeded far + // above it) is scrolled well past — exactly the condition that opens + // the anchored bar at the same moment the divider is placed. + const session = await openTaskSession(testPage, taskId); + const activeChat = session.activeChat(); + + const bar = activeChat.getByTestId("anchored-last-prompt-bar"); + await expect(bar).toHaveAttribute("data-state", "open", { timeout: 10_000 }); + + const divider = activeChat.getByTestId("unread-divider"); + await expect(divider).toBeVisible(); + + const barContent = activeChat.getByTestId("anchored-last-prompt-content"); + await expect + .poll(async () => { + const [barBox, dividerBox] = await Promise.all([ + barContent.boundingBox(), + divider.boundingBox(), + ]); + if (!barBox || !dividerBox) return null; + return dividerBox.y - (barBox.y + barBox.height); + }) + .toBeGreaterThanOrEqual(0); + }); });