Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ function ChatViewContent(props: ChatViewProps) {

const updateHeight = () => {
const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height);
if (nextHeight <= 0) return;
setComposerOverlayHeight((currentHeight) =>
currentHeight === nextHeight ? currentHeight : nextHeight,
);
Expand Down Expand Up @@ -3148,18 +3149,61 @@ function ChatViewContent(props: ChatViewProps) {
],
);

// Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
// thread switches. LegendList fires scroll events with isAtEnd=false while
// initialScrollAtEnd is settling; hiding is always immediate.
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);

// Scrolling is explicit so streamed timeline updates never take control away
// from the user after the newly sent row has been positioned once.
const scrollToEnd = useCallback((animated = false) => {
isAtEndRef.current = true;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
void legendListRef.current?.scrollToEnd?.({ animated });
}, []);
const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
const settledTimelineAnchorRef = useRef<MessageId | null>(null);
const anchorUserScrollGenerationRef = useRef(0);
const pendingAnchorScrollRestoreRef = useRef<{
readonly messageId: MessageId;
readonly offset: number;
readonly userScrollGeneration: number;
} | null>(null);
const anchorScrollRestoreFrameRef = useRef<number | null>(null);
useEffect(() => {
let removeListeners: (() => void) | null = null;
const frame = requestAnimationFrame(() => {
const scrollNode = legendListRef.current?.getScrollableNode();
if (!scrollNode) {
return;
}
const markUserScrollIntent = () => {
anchorUserScrollGenerationRef.current += 1;
pendingAnchorScrollRestoreRef.current = null;
if (anchorScrollRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorScrollRestoreFrameRef.current);
anchorScrollRestoreFrameRef.current = null;
}
};
scrollNode.addEventListener("wheel", markUserScrollIntent, { passive: true });
scrollNode.addEventListener("touchmove", markUserScrollIntent, { passive: true });
scrollNode.addEventListener("pointerdown", markUserScrollIntent, { passive: true });
removeListeners = () => {
scrollNode.removeEventListener("wheel", markUserScrollIntent);
scrollNode.removeEventListener("touchmove", markUserScrollIntent);
scrollNode.removeEventListener("pointerdown", markUserScrollIntent);
};
});

return () => {
cancelAnimationFrame(frame);
removeListeners?.();
};
}, [activeThread?.id]);

const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
if (positionedTimelineAnchorRef.current === messageId) {
return;
Expand Down Expand Up @@ -3211,7 +3255,11 @@ function ChatViewContent(props: ChatViewProps) {
return;
}
if (pendingAnchorScrollRestoreRef.current === null) {
pendingAnchorScrollRestoreRef.current = { messageId, offset: scrollOffset };
pendingAnchorScrollRestoreRef.current = {
messageId,
offset: scrollOffset,
userScrollGeneration: anchorUserScrollGenerationRef.current,
};
}
if (anchorScrollRestoreFrameRef.current !== null) {
return;
Expand All @@ -3220,18 +3268,23 @@ function ChatViewContent(props: ChatViewProps) {
anchorScrollRestoreFrameRef.current = null;
const pending = pendingAnchorScrollRestoreRef.current;
pendingAnchorScrollRestoreRef.current = null;
if (pending && settledTimelineAnchorRef.current === pending.messageId) {
void legendListRef.current?.scrollToOffset({ offset: pending.offset, animated: false });
if (
pending &&
settledTimelineAnchorRef.current === pending.messageId &&
pending.userScrollGeneration === anchorUserScrollGenerationRef.current
) {
const list = legendListRef.current;
const currentScrollOffset = list?.getState().scroll;
if (
typeof currentScrollOffset === "number" &&
Math.abs(currentScrollOffset - pending.offset) <= 2
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverted anchor scroll restore

Medium Severity

In onTimelineAnchorSizeChanged, scroll restoration only applies if the current scroll offset is within 2px of the saved position. This causes the viewport to jump instead of staying pinned to the anchored message when layout drift, common during streaming, shifts the scroll position by more than 2px.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit da040f6. Configure here.

}
});
}, []);

// Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
// thread switches. LegendList fires scroll events with isAtEnd=false while
// initialScrollAtEnd is settling; hiding is always immediate.
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
Expand Down Expand Up @@ -4870,19 +4923,21 @@ function ChatViewContent(props: ChatViewProps) {
onIsAtEndChange={onIsAtEndChange}
/>

{/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
{/* scroll to end pill — shown when user has scrolled away from the live edge */}
{showScrollToBottom && (
<div
className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"
style={{ bottom: composerOverlayHeight + 4 }}
>
<button
type="button"
aria-label="Scroll to end"
title="Scroll to end"
onClick={() => scrollToEnd(true)}
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
>
<ChevronDownIcon className="size-3.5" />
Scroll to bottom
Scroll to end
</button>
</div>
)}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../..
import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";

export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
export const TIMELINE_MINIMAP_MIN_ITEMS = 2;

export interface TimelineEndState {
readonly isAtEnd?: boolean;
readonly isNearEnd?: boolean;
}

export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
return state?.isNearEnd ?? state?.isAtEnd;
}

function computeElapsedMs(startIso: string, endIso: string): number | null {
const start = Date.parse(startIso);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ function buildUserTimelineEntry(text: string) {
}

describe("MessagesTimeline", () => {
it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {
const { resolveTimelineIsAtEnd } = await import("./MessagesTimeline.logic");

expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();
});

it("anchors a sent attachment message using its measured height", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const onAnchorReady = vi.fn();
Expand Down
Loading
Loading