fix: reserve room for the anchored last-prompt bar above the New divider (#2203)

This commit is contained in:
Clément Fiere
2026-08-03 17:33:43 +02:00
committed by GitHub
parent 13b9f4e43f
commit d014fd56fe
10 changed files with 527 additions and 23 deletions
@@ -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<Parameters<typeof AnchoredLastPromptBar>[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);
});
});
@@ -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<HTMLDivElement | null>,
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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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({
)}
>
<div className="min-h-0 overflow-hidden">
<div className="flex items-start gap-2 px-4 py-4">
<div
ref={contentRef}
data-testid="anchored-last-prompt-content"
className="flex items-start gap-2 px-4 py-4"
>
{showScrollToLastPrompt && (
<ScrollToLastPromptButton
onClick={onScrollUp}
@@ -0,0 +1,102 @@
import { useRef } from "react";
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/state/dockview-store", () => ({
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<HTMLDivElement>(null);
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx);
return (
<div ref={scrollRef}>
<div id="msg-m1" />
<div id={`msg-${DIVIDER_KEY}`} />
</div>
);
}
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(<Harness itemCount={2} anchoredBarOffsetPx={0} />);
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(<Harness itemCount={2} anchoredBarOffsetPx={76} />);
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(<Harness itemCount={2} anchoredBarOffsetPx={0} />);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
container.querySelector("div")?.dispatchEvent(new Event("wheel", { bubbles: true }));
rerender(<Harness itemCount={2} anchoredBarOffsetPx={76} />);
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(
<Harness itemCount={2} anchoredBarOffsetPx={0} dividerKey={null} />,
);
expect(scrollIntoView).not.toHaveBeenCalled();
rerender(<Harness itemCount={2} anchoredBarOffsetPx={76} dividerKey={null} />);
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(<Harness itemCount={2} anchoredBarOffsetPx={0} />);
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(<Harness itemCount={2} anchoredBarOffsetPx={76} />);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
@@ -17,6 +17,7 @@ import {
MessageListStatus,
MessageItem,
UnreadDivider,
anchoredBarScrollOffsetPx,
getItemKey,
getConversationLoadingState,
getEffectiveActiveTurnId,
@@ -106,7 +107,7 @@ function MessageRow({
return (
<div
id={`msg-${key}`}
className="pb-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))] sm:scroll-mt-0"
className="pb-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))] sm:scroll-mt-[var(--anchored-bar-h,0px)]"
style={{ overflowAnchor: "none" }}
>
{dividerBeforeItemKey === key && <UnreadDivider />}
@@ -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<HTMLDivElement | null>,
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,
]);
@@ -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<string, Message>();
@@ -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);
@@ -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(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={0} />);
expect(scrollToIndex).toHaveBeenCalledTimes(1);
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: 0 });
rerender(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={0} />);
expect(scrollToIndex).toHaveBeenCalledTimes(1);
scrollParent.dispatchEvent(new Event("wheel", { bubbles: true }));
rerender(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={0} />);
expect(scrollToIndex).not.toHaveBeenCalled();
rerender(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={0} />);
expect(scrollToIndex).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4001);
rerender(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={76} />);
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(<DirectHarness offsetPx={76} renderItems={itemsWithLiveMessage} />);
expect(scrollToIndex).toHaveBeenCalledTimes(2);
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 });
});
});
@@ -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<VirtuosoHandle | null>,
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<number | null>(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<number>,
) {
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" }) => {
@@ -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 ? (
<AnchoredLastPromptBar
@@ -377,6 +379,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
isVisible={anchoredBarVisible}
onScrollUp={scrollToLastPrompt}
showScrollToLastPrompt={showScrollToLastPrompt}
onHeightChange={setAnchoredBarHeight}
/>
) : undefined
}
@@ -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);
});
});