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
73 changes: 33 additions & 40 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ import {
isLatestTurnSettled,
} from "../session-logic";
import { type LegendListRef } from "@legendapp/list/react";
import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";
import {
getAnchoredTurnMetrics,
isTimelineLayoutFollowEnabled,
reduceTimelineLayoutFollowState,
type TimelineLayoutFollowAction,
type TimelineScrollMode,
} from "./chat/timelineScrollAnchoring";
import {
buildPendingUserInputAnswers,
derivePendingUserInputProgress,
Expand Down Expand Up @@ -1278,6 +1284,22 @@ function ChatViewContent(props: ChatViewProps) {
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const layoutScrollThreadKeyRef = useRef(routeThreadKey);
layoutScrollThreadKeyRef.current = routeThreadKey;
const [layoutScrollState, setLayoutScrollState] = useState(() => ({
threadKey: routeThreadKey,
enabled: true,
}));
const maintainScrollAtEndOnLayout = isTimelineLayoutFollowEnabled(
layoutScrollState,
routeThreadKey,
);
const updateTimelineLayoutFollow = useCallback((action: TimelineLayoutFollowAction) => {
setLayoutScrollState((current) => {
const threadKey = layoutScrollThreadKeyRef.current;
return reduceTimelineLayoutFollowState(current, threadKey, action);
});
}, []);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const optimisticUserMessagesRef = useRef(optimisticUserMessages);
Expand Down Expand Up @@ -3522,6 +3544,10 @@ function ChatViewContent(props: ChatViewProps) {
} | null>(null);
const anchorScrollRestoreFrameRef = useRef<number | null>(null);
const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
updateTimelineLayoutFollow({
type: "manual-navigation",
isAtEnd: isAtEndRef.current,
});
anchorUserScrollGenerationRef.current += 1;
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
Expand All @@ -3535,13 +3561,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 @@ -3594,6 +3613,7 @@ function ChatViewContent(props: ChatViewProps) {
// gesture opts out.
const scrollToEnd = useCallback((animated = false) => {
isAtEndRef.current = true;
updateTimelineLayoutFollow({ type: "return-to-end" });
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand All @@ -3602,38 +3622,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 @@ -3735,6 +3723,7 @@ function ChatViewContent(props: ChatViewProps) {
setShowScrollToBottom(false);
return;
}
updateTimelineLayoutFollow({ type: "at-end-change", isAtEnd });
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
if (isAtEnd) {
Expand Down Expand Up @@ -3818,6 +3807,7 @@ function ChatViewContent(props: ChatViewProps) {
useEffect(() => {
setPullRequestDialogState(null);
isAtEndRef.current = true;
updateTimelineLayoutFollow({ type: "return-to-end" });
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand Down Expand Up @@ -4736,6 +4726,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;
updateTimelineLayoutFollow({ type: "return-to-end" });
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -5179,6 +5170,7 @@ function ChatViewContent(props: ChatViewProps) {

// Position this sent row once LegendList has measured the anchored tail.
isAtEndRef.current = true;
updateTimelineLayoutFollow({ type: "return-to-end" });
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -5827,6 +5819,7 @@ function ChatViewContent(props: ChatViewProps) {
onAnchorReady={onTimelineAnchorReady}
onAnchorSizeChanged={onTimelineAnchorSizeChanged}
contentInsetEndAdjustment={composerOverlayHeight}
maintainScrollAtEndOnLayout={maintainScrollAtEndOnLayout}
onIsAtEndChange={onIsAtEndChange}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
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 const TIMELINE_OVERLAY_SCROLLBAR_HIT_WIDTH = 16;

export interface TimelineEndState {
readonly isAtEnd?: boolean;
Expand All @@ -25,6 +26,31 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo
return state?.isNearEnd ?? state?.isAtEnd;
}

export function isTimelineScrollbarPointerDown(input: {
readonly pointerType: string;
readonly targetIsCurrentTarget: boolean;
readonly clientX: number;
readonly viewportRight: number;
readonly offsetWidth: number;
readonly clientWidth: number;
readonly scrollHeight: number;
readonly clientHeight: number;
}): boolean {
if (
input.pointerType !== "mouse" ||
!input.targetIsCurrentTarget ||
input.scrollHeight <= input.clientHeight ||
!Number.isFinite(input.clientX) ||
!Number.isFinite(input.viewportRight)
) {
return false;
}

const measuredScrollbarWidth = Math.max(0, input.offsetWidth - input.clientWidth);
const hitWidth = Math.max(TIMELINE_OVERLAY_SCROLLBAR_HIT_WIDTH, measuredScrollbarWidth);
return input.clientX >= input.viewportRight - hitWidth && input.clientX <= input.viewportRight;
}

export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);
return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ vi.mock("@legendapp/list/react", async () => {
size?: boolean;
shouldRestorePosition?: (item: { id: string }) => boolean;
};
onWheel?: () => void;
onTouchMove?: () => void;
onPointerDown?: () => void;
ref?: Ref<LegendListRef>;
}) => {
if (props.anchoredEndSpace) {
Expand Down Expand Up @@ -90,6 +93,9 @@ vi.mock("@legendapp/list/react", async () => {
? props.maintainVisibleContentPosition.size
: undefined
}
data-manual-navigation-wheel={Boolean(props.onWheel)}
data-manual-navigation-touch={Boolean(props.onTouchMove)}
data-manual-navigation-pointer={Boolean(props.onPointerDown)}
>
{props.ListHeaderComponent}
{props.data.map((item) => (
Expand Down Expand Up @@ -194,6 +200,7 @@ function buildProps() {
onAnchorReady: () => {},
onAnchorSizeChanged: () => {},
contentInsetEndAdjustment: 0,
maintainScrollAtEndOnLayout: true,
onIsAtEndChange: () => {},
onManualNavigation: () => {},
};
Expand Down Expand Up @@ -357,6 +364,35 @@ describe("MessagesTimeline", () => {
expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
});

it("only treats a press on the vertical scrollbar as pointer navigation", async () => {
const { isTimelineScrollbarPointerDown } = await import("./MessagesTimeline.logic");
const scrollbarPointer = {
pointerType: "mouse",
targetIsCurrentTarget: true,
clientX: 994,
viewportRight: 1000,
offsetWidth: 800,
clientWidth: 800,
scrollHeight: 1200,
clientHeight: 600,
};

expect(isTimelineScrollbarPointerDown(scrollbarPointer)).toBe(true);
expect(
isTimelineScrollbarPointerDown({ ...scrollbarPointer, targetIsCurrentTarget: false }),
).toBe(false);
expect(isTimelineScrollbarPointerDown({ ...scrollbarPointer, clientX: 900 })).toBe(false);
expect(isTimelineScrollbarPointerDown({ ...scrollbarPointer, pointerType: "touch" })).toBe(
false,
);
expect(
isTimelineScrollbarPointerDown({
...scrollbarPointer,
scrollHeight: scrollbarPointer.clientHeight,
}),
).toBe(false);
});

it("anchors a sent attachment message using its measured height", () => {
const onAnchorReady = vi.fn();
const onAnchorSizeChanged = vi.fn();
Expand Down Expand Up @@ -424,6 +460,24 @@ describe("MessagesTimeline", () => {
expect(markup).toContain('data-user-message-footer="true"');
});

it("preserves the reader's position when the composer resizes away from the live edge", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
maintainScrollAtEndOnLayout={false}
timelineEntries={[buildUserTimelineEntry("Earlier message.")]}
/>,
);

expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
expect(markup).toContain('data-maintain-scroll-at-end-data-change="true"');
expect(markup).toContain('data-maintain-scroll-at-end-item-layout="true"');
expect(markup).toContain('data-maintain-scroll-at-end-layout="false"');
expect(markup).toContain('data-manual-navigation-wheel="true"');
expect(markup).toContain('data-manual-navigation-touch="true"');
expect(markup).toContain('data-manual-navigation-pointer="true"');
});

it("does not render collapse controls for short user messages", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
Expand Down
30 changes: 29 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useState,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
Expand Down Expand Up @@ -67,6 +68,7 @@ import { MessageCopyButton } from "./MessageCopyButton";
import {
computeStableMessagesTimelineRows,
deriveMessagesTimelineRows,
isTimelineScrollbarPointerDown,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
resolveTimelineIsAtEnd,
Expand Down Expand Up @@ -180,6 +182,7 @@ interface MessagesTimelineProps {
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
contentInsetEndAdjustment: number;
maintainScrollAtEndOnLayout: boolean;
onIsAtEndChange: (isAtEnd: boolean) => void;
onManualNavigation: () => void;
hideEmptyPlaceholder?: boolean;
Expand Down Expand Up @@ -215,6 +218,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onAnchorReady,
onAnchorSizeChanged,
contentInsetEndAdjustment,
maintainScrollAtEndOnLayout,
onIsAtEndChange,
onManualNavigation,
hideEmptyPlaceholder = false,
Expand Down Expand Up @@ -385,6 +389,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}
}, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]);

const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
const scrollNode = event.currentTarget;
if (
isTimelineScrollbarPointerDown({
pointerType: event.pointerType,
targetIsCurrentTarget: event.target === scrollNode,
clientX: event.clientX,
viewportRight: scrollNode.getBoundingClientRect().right,
offsetWidth: scrollNode.offsetWidth,
clientWidth: scrollNode.clientWidth,
scrollHeight: scrollNode.scrollHeight,
clientHeight: scrollNode.clientHeight,
})
) {
onManualNavigation();
}
},
[onManualNavigation],
);

useEffect(() => {
const frame = requestAnimationFrame(handleScroll);
return () => cancelAnimationFrame(frame);
Expand Down Expand Up @@ -502,7 +527,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
on: {
dataChange: true,
itemLayout: true,
layout: true,
layout: maintainScrollAtEndOnLayout,
},
}
}
Expand All @@ -511,6 +536,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
size: false,
}}
onScroll={handleScroll}
onWheel={onManualNavigation}
onTouchMove={onManualNavigation}
onPointerDown={handlePointerDown}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "chat-timeline-scroll-fade",
Expand Down
Loading
Loading