Skip to content
59 changes: 18 additions & 41 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3155,6 +3155,7 @@ function ChatViewContent(props: ChatViewProps) {
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const [timelineAutoFollowEnabled, setTimelineAutoFollowEnabled] = useState(true);
const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
Expand All @@ -3169,7 +3170,14 @@ function ChatViewContent(props: ChatViewProps) {
} | null>(null);
const anchorScrollRestoreFrameRef = useRef<number | null>(null);
const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
if (
liveFollowUserScrollGenerationRef.current === null &&
timelineScrollModeRef.current === "free-scrolling"
) {
return;
}
anchorUserScrollGenerationRef.current += 1;
setTimelineAutoFollowEnabled(false);
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
pendingTimelineAnchorRef.current = null;
Expand All @@ -3182,13 +3190,6 @@ function ChatViewContent(props: ChatViewProps) {
anchorScrollRestoreFrameRef.current = null;
}
}, []);
const cancelTimelineLiveFollowForUserNavigationRef = useRef(
cancelTimelineLiveFollowForUserNavigation,
);
useEffect(() => {
cancelTimelineLiveFollowForUserNavigationRef.current =
cancelTimelineLiveFollowForUserNavigation;
}, [cancelTimelineLiveFollowForUserNavigation]);
const getActiveTimelineTurnMetrics = useCallback(
(list?: LegendListRef | null) => {
const resolvedList = list ?? legendListRef.current;
Expand Down Expand Up @@ -3241,6 +3242,7 @@ function ChatViewContent(props: ChatViewProps) {
// gesture opts out.
const scrollToEnd = useCallback((animated = false) => {
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand All @@ -3249,38 +3251,6 @@ function ChatViewContent(props: ChatViewProps) {
setShowScrollToBottom(false);
void legendListRef.current?.scrollToEnd?.({ animated });
}, []);
useEffect(() => {
let removeListeners: (() => void) | null = null;
const frame = requestAnimationFrame(() => {
const scrollNode = legendListRef.current?.getScrollableNode();
if (!scrollNode) {
return;
}
const handleManualNavigation = () => {
cancelTimelineLiveFollowForUserNavigationRef.current();
};
scrollNode.addEventListener("wheel", handleManualNavigation, {
passive: true,
});
scrollNode.addEventListener("touchmove", handleManualNavigation, {
passive: true,
});
scrollNode.addEventListener("pointerdown", handleManualNavigation, {
passive: true,
});
removeListeners = () => {
scrollNode.removeEventListener("wheel", handleManualNavigation);
scrollNode.removeEventListener("touchmove", handleManualNavigation);
scrollNode.removeEventListener("pointerdown", handleManualNavigation);
};
});

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

const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
if (pendingTimelineAnchorRef.current === messageId) {
pendingTimelineAnchorRef.current = null;
Expand Down Expand Up @@ -3382,14 +3352,17 @@ function ChatViewContent(props: ChatViewProps) {
setShowScrollToBottom(false);
return;
}
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
if (isAtEnd) {
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
} else {
if (!isAtEndRef.current) return;
isAtEndRef.current = false;
setTimelineAutoFollowEnabled(false);
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.maybeExecute();
Expand Down Expand Up @@ -3465,6 +3438,7 @@ function ChatViewContent(props: ChatViewProps) {
useEffect(() => {
setPullRequestDialogState(null);
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand Down Expand Up @@ -4030,6 +4004,7 @@ function ChatViewContent(props: ChatViewProps) {
// anchored end-space target so it lands near the top while the response
// streams into the reserved space below it.
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -4467,6 +4442,7 @@ function ChatViewContent(props: ChatViewProps) {

// Position this sent row once LegendList has measured the anchored tail.
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -5099,6 +5075,7 @@ function ChatViewContent(props: ChatViewProps) {
onAnchorReady={onTimelineAnchorReady}
onAnchorSizeChanged={onTimelineAnchorSizeChanged}
contentInsetEndAdjustment={composerOverlayHeight}
autoFollowEnabled={timelineAutoFollowEnabled}
onIsAtEndChange={onIsAtEndChange}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
/>
Expand Down
61 changes: 60 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,72 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
export const TIMELINE_CONTENT_MAX_WIDTH = 768;
export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;

export type TimelineNavigationInput =
| { readonly type: "wheel"; readonly deltaY: number }
| { readonly type: "touch"; readonly previousY: number | null; readonly currentY: number | null }
| { readonly type: "keyboard"; readonly key: string; readonly shiftKey: boolean };

export function timelineNavigationInputMovesTowardHistory(input: TimelineNavigationInput) {
switch (input.type) {
case "wheel":
return input.deltaY < 0;
case "touch":
return (
input.previousY !== null && input.currentY !== null && input.currentY > input.previousY
);
case "keyboard":
return (
input.key === "ArrowUp" ||
input.key === "PageUp" ||
input.key === "Home" ||
(input.key === " " && input.shiftKey)
);
}
}

export interface TimelineScrollableNodeState {
readonly clientHeight: number;
readonly scrollHeight: number;
readonly scrollTop: number;
}

export function timelineScrollableNodeCanNavigateTowardHistory(
scrollNode: TimelineScrollableNodeState | null | undefined,
): boolean {
return Boolean(
scrollNode && scrollNode.scrollHeight > scrollNode.clientHeight && scrollNode.scrollTop > 0,
);
}

export function resolveTimelineScrollableNodeIsAtEnd(
scrollNode: TimelineScrollableNodeState | null | undefined,
): boolean | undefined {
if (!scrollNode) {
return undefined;
}
return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1;
}

export function timelineManualNavigationReachedEnd({
previousScrollTop,
scrollTop,
isAtEnd,
}: {
readonly previousScrollTop: number;
readonly scrollTop: number;
readonly isAtEnd: boolean;
}) {
return isAtEnd && scrollTop > previousScrollTop;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.

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

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

export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
Expand Down
100 changes: 99 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ function buildProps() {
onAnchorReady: () => {},
onAnchorSizeChanged: () => {},
contentInsetEndAdjustment: 0,
autoFollowEnabled: true,
onIsAtEndChange: () => {},
onManualNavigation: () => {},
};
Expand Down Expand Up @@ -229,10 +230,16 @@ describe("MessagesTimeline", () => {
} = await import("./MessagesTimeline.logic");

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

expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");
expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);
expect(
Expand All @@ -256,6 +263,84 @@ describe("MessagesTimeline", () => {
expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
});

it("recognizes directional user input without inferring intent from layout offsets", async () => {
const {
resolveTimelineScrollableNodeIsAtEnd,
timelineManualNavigationReachedEnd,
timelineNavigationInputMovesTowardHistory,
timelineScrollableNodeCanNavigateTowardHistory,
} = await import("./MessagesTimeline.logic");

expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: -0.1 })).toBe(true);
expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: 0.1 })).toBe(false);
expect(
timelineNavigationInputMovesTowardHistory({ type: "touch", previousY: 100, currentY: 100.1 }),
).toBe(true);
expect(
timelineNavigationInputMovesTowardHistory({
type: "keyboard",
key: "PageUp",
shiftKey: false,
}),
).toBe(true);
expect(
timelineScrollableNodeCanNavigateTowardHistory({
clientHeight: 400,
scrollHeight: 400,
scrollTop: 0,
}),
).toBe(false);
expect(
timelineScrollableNodeCanNavigateTowardHistory({
clientHeight: 400,
scrollHeight: 800,
scrollTop: 0,
}),
).toBe(false);
expect(
timelineScrollableNodeCanNavigateTowardHistory({
clientHeight: 400,
scrollHeight: 800,
scrollTop: 400,
}),
).toBe(true);
expect(
resolveTimelineScrollableNodeIsAtEnd({
clientHeight: 400,
scrollHeight: 800,
scrollTop: 398.9,
}),
).toBe(false);
expect(
resolveTimelineScrollableNodeIsAtEnd({
clientHeight: 400,
scrollHeight: 800,
scrollTop: 399.9,
}),
).toBe(true);
expect(
resolveTimelineScrollableNodeIsAtEnd({
clientHeight: 400,
scrollHeight: 800,
scrollTop: 400,
}),
).toBe(true);
expect(
timelineManualNavigationReachedEnd({
previousScrollTop: 400,
scrollTop: 399.9,
isAtEnd: true,
}),
).toBe(false);
expect(
timelineManualNavigationReachedEnd({
previousScrollTop: 399.9,
scrollTop: 400,
isAtEnd: true,
}),
).toBe(true);
});

it("anchors a sent attachment message using its measured height", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const onAnchorReady = vi.fn();
Expand Down Expand Up @@ -338,6 +423,19 @@ describe("MessagesTimeline", () => {
expect(markup).toContain('data-user-message-collapsible="false"');
});

it("disables LegendList auto-follow while the reader is away from the live edge", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
autoFollowEnabled={false}
timelineEntries={[buildUserTimelineEntry("Earlier message.")]}
/>,
);

expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
});

it("renders inline terminal labels with the composer chip UI", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
Expand Down
Loading
Loading