fix: reserve room for the anchored last-prompt bar above the New divider (#2203)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user