Skip to content
Merged
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
8 changes: 6 additions & 2 deletions src/engines/ChatPanel/ChatHistory/ChatHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ const ChatHistory: React.FC<ChatHistoryProps> = ({
});
const viewport = useChatViewportController({
activeId,
currentPageIndex: projection.currentPageIndex,
bottomInset,
browserAddToConversationNav,
displayTotalFlatItems: projection.displayTotalFlatItems,
Expand Down Expand Up @@ -157,7 +158,7 @@ const ChatHistory: React.FC<ChatHistoryProps> = ({
turnPageListOpen: projection.turnPageListOpen,
turnPaginationEnabled,
virtualListRef: historyState.virtualListRef,
onExplicitNavigation: viewport.detachForNavigation,
onExplicitNavigation: viewport.beginNavigation,
});
const emptyState = useChatEmptyState({
isAgentWorking: platform.isAgentWorking,
Expand All @@ -171,6 +172,9 @@ const ChatHistory: React.FC<ChatHistoryProps> = ({
sessionId: activeId,
chatHistory: historyState.chatHistory,
flatItems: projection.flatItems,
sourceItems: projection.activeProjectionHistory,
originalToFlatIndex: projection.originalToFlatIndex,
groupHeaders: projection.groupHeaders,
groupCounts: projection.groupCounts,
groupMeta: projection.groupMeta,
pages: projection.pages,
Expand All @@ -179,7 +183,7 @@ const ChatHistory: React.FC<ChatHistoryProps> = ({
setTurnPageSelection: projection.setTurnPageSelection,
virtualListRef: historyState.virtualListRef,
chatContainerRef: historyState.chatContainerRef,
onExplicitNavigation: viewport.detachForNavigation,
onExplicitNavigation: viewport.beginNavigation,
});
// Agent-started sessions carry no message the reader wrote: their user-role
// turns are the parent's dispatches. Resolve the parent once here so every
Expand Down
153 changes: 35 additions & 118 deletions src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,11 @@
* - `ChatHistoryListActiveGroupReporter.ts` — the scroll-driven active
* group index/pin reporter hook.
*/
import { useVirtualizer } from "@tanstack/react-virtual";
import { elementScroll, useVirtualizer } from "@tanstack/react-virtual";
import React, {
memo,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useReducer,
Expand All @@ -35,10 +34,6 @@ import { AgentStatusTrail } from "@src/engines/ChatPanel/blocks/primitives";
import { CHAT_PANEL_TRANSCRIPT_TOP_PADDING_PX } from "@src/engines/ChatPanel/header/chatPanelHeaderLayout";

import type { OptimizedChatItem } from "../chatItemPipeline/types";
import {
findChatSearchTargetElement,
scrollSearchTargetIntoView,
} from "../hooks/chatSearch";
import { getUnloadedTurnMeta } from "../hooks/useChatGroups";
import { GroupItemRenderer } from "../renderers";
import { useChatHistoryListActiveGroupReporter } from "./ChatHistoryListActiveGroupReporter";
Expand All @@ -52,6 +47,7 @@ import {
resolveActiveGroupPinState,
resolveVisibleGroupIndices,
} from "./ChatHistoryListLayout";
import { useChatHistoryListNavigation } from "./ChatHistoryListNavigation";
import type {
ChatHistoryListProps,
RowGroupMeta,
Expand Down Expand Up @@ -100,6 +96,7 @@ const ChatHistoryList: React.FC<ChatHistoryListProps> = memo(
staticScrollerRef,
onScrollRootChange,
onRowLayoutCommit,
isNavigating,
newEventDividerLabel = null,
}) => {
// Planning indicator state in refs so polling ticks don't invalidate
Expand Down Expand Up @@ -158,28 +155,25 @@ const ChatHistoryList: React.FC<ChatHistoryListProps> = memo(
() => buildChatGroupRenderKeys(turnIds, groupFallbackIds),
[groupFallbackIds, turnIds]
);
const flatIndexToGroupIndex = useMemo(() => {
const indexes: number[] = [];
for (const group of virtualGroups) {
for (let offset = 0; offset < group.itemCount; offset++) {
indexes[group.startFlatIndex + offset] = group.groupIndex;
}
}
return indexes;
}, [virtualGroups]);
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual exposes imperative helpers that cannot be memoized safely.
const virtualizer = useVirtualizer({
count: virtualGroups.length,
scrollToFn: (offset, options, instance) => {
if (!isNavigating?.()) elementScroll(offset, options, instance);
},
getScrollElement: () => virtualScrollerRef.current,
estimateSize: () => 360,
overscan: 4,
getItemKey: (index) =>
groupRenderKeys[index] ?? `chat-group-index:${index}`,
});
const virtualItems = virtualizer.getVirtualItems();
const measuredRows = useRef(new WeakMap<Element, number>()).current;
const layoutRevision = useRef(0);
// Row sizes the committed row offsets were computed from.
const committedRowSizesRef = useRef(new Map<number, number>());
useLayoutEffect(() => {
layoutRevision.current += 1;
committedRowSizesRef.current = new Map(
virtualItems.map((item) => [item.index, item.size])
);
Expand All @@ -197,19 +191,29 @@ const ChatHistoryList: React.FC<ChatHistoryListProps> = memo(
if (!node) return;
if (!rowResizeObserverRef.current) {
rowResizeObserverRef.current = new ResizeObserver((entries) => {
let measured = false;
const resizedRows: Array<{ index: number; size: number }> = [];
for (const entry of entries) {
if (!entry.target.isConnected) continue;
const blockSize =
entry.borderBoxSize[0]?.blockSize ??
entry.target.getBoundingClientRect().height;
const index = virtualizer.indexFromElement(
entry.target as HTMLDivElement
);
const size = Math.round(blockSize);
if (measuredRows.get(entry.target) !== size) measured = true;
measuredRows.set(entry.target, size);
if (committedRowSizesRef.current.get(index) === size) continue;
resizedRows.push({ index, size });
}
if (resizedRows.length === 0) return;
if (resizedRows.length === 0) {
if (measured) {
layoutRevision.current += 1;
onRowLayoutCommitRef.current?.();
}
return;
}
// Rows re-wrap on every frame of a pane resize. TanStack moves
// scrollTop as soon as it learns a size, but moves the rows only on
// its next asynchronous render, so this frame would paint shifted
Expand All @@ -230,7 +234,7 @@ const ChatHistoryList: React.FC<ChatHistoryListProps> = memo(
observer.unobserve(node);
};
},
[virtualizer]
[measuredRows, virtualizer]
);

useEffect(() => {
Expand All @@ -255,108 +259,21 @@ const ChatHistoryList: React.FC<ChatHistoryListProps> = memo(
});
}, [onRangeChanged, virtualGroups, virtualItems]);

useImperativeHandle(
useChatHistoryListNavigation({
virtualListRef,
() => ({
revealTranscriptAnchor: (anchorId) => {
const scrollRoot =
virtualScrollerRef.current ?? staticScrollerRef?.current;
if (!scrollRoot) return false;
const mounted = Array.from(
scrollRoot.querySelectorAll<HTMLElement>(
"[data-transcript-anchor-id]"
)
).some(
(element) =>
element.getAttribute("data-transcript-anchor-id") === anchorId
);
if (mounted) return true;

const groupIndex = groupRenderKeys.indexOf(anchorId);
if (groupIndex < 0 || scrollRoot !== virtualScrollerRef.current) {
return false;
}
virtualizer.scrollToIndex(groupIndex, {
align: "start",
behavior: "auto",
});
return false;
},
scrollToGroup: ({ groupIndex, behavior = "smooth" }) => {
const boundedGroupIndex = Math.max(
0,
Math.min(groupIndex, virtualGroups.length - 1)
);
const staticScrollRoot = staticScrollerRef?.current;
const staticGroup = staticScrollRoot?.querySelector<HTMLElement>(
`[data-chat-group-index="${boundedGroupIndex}"]`
);
if (staticScrollRoot && staticGroup) {
const rootRect = staticScrollRoot.getBoundingClientRect();
const groupRect = staticGroup.getBoundingClientRect();
staticScrollRoot.scrollTo({
top: staticScrollRoot.scrollTop + groupRect.top - rootRect.top,
behavior,
});
return;
}
virtualizer.scrollToIndex(boundedGroupIndex, {
align: "start",
behavior,
});
},
scrollToChatTarget: ({
eventId,
itemId,
flatIndex,
behavior = "auto",
}) => {
const scrollRoot =
virtualScrollerRef.current ?? staticScrollerRef?.current;
if (!scrollRoot) return;

const scrollToDomTarget = (): boolean => {
const target = findChatSearchTargetElement(scrollRoot, {
eventId,
itemId,
flatIndex,
});
if (!target) return false;
scrollSearchTargetIntoView(scrollRoot, target, behavior);
return true;
};

if (scrollToDomTarget()) return;

if (
flatIndex === undefined ||
scrollRoot !== virtualScrollerRef.current
) {
return;
}

const groupIndex = flatIndexToGroupIndex[flatIndex] ?? 0;
virtualizer.scrollToIndex(groupIndex, {
align: "start",
behavior: "auto",
});

window.requestAnimationFrame(() => {
if (!scrollToDomTarget()) {
window.requestAnimationFrame(scrollToDomTarget);
}
});
},
}),
[
flatIndexToGroupIndex,
groupRenderKeys,
staticScrollerRef,
virtualGroups.length,
virtualizer,
virtualScrollerRef,
]
);
virtualScrollerRef,
staticScrollerRef,
virtualizer,
groupRenderKeys,
measuredRows,
committedRowSizes: committedRowSizesRef,
layoutRevision,
});
useLayoutEffect(() => {
// Mounts and projection commits can make a pending target resolvable.
// Defer viewport work out of React's commit; coalesce in the owner.
onRowLayoutCommitRef.current?.(true);
}, [virtualItems, groupRenderKeys]);
const rowGroupMeta = useMemo(
() => buildRowGroupMeta(effectiveGroupCounts),
[effectiveGroupCounts]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export function sameChatHistoryListProps(
"onScrollRootChange",
previous.onScrollRootChange === next.onScrollRootChange,
],
["isNavigating", previous.isNavigating === next.isNavigating],
[
"onRowLayoutCommit",
previous.onRowLayoutCommit === next.onRowLayoutCommit,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { type RefObject, useImperativeHandle } from "react";

import {
findChatSearchTargetElement,
getSearchTargetScrollTop,
} from "../hooks/chatSearch/chatSearchTargetDom";
import type { ChatHistoryListHandle } from "./ChatHistoryListTypes";

/** The list supplies geometry only. The viewport owns every navigation write. */
export function useChatHistoryListNavigation({
virtualListRef,
virtualScrollerRef,
staticScrollerRef,
virtualizer,
groupRenderKeys,
measuredRows,
committedRowSizes,
layoutRevision,
}: {
virtualListRef: RefObject<ChatHistoryListHandle | null>;
virtualScrollerRef: RefObject<HTMLDivElement | null>;
staticScrollerRef?: RefObject<HTMLDivElement | null>;
virtualizer: Virtualizer<HTMLDivElement, Element>;
groupRenderKeys: string[];
measuredRows: WeakMap<Element, number>;
committedRowSizes: RefObject<Map<number, number>>;
layoutRevision: RefObject<number>;
}) {
useImperativeHandle(virtualListRef, () => {
const getRoot = () =>
virtualScrollerRef.current ?? staticScrollerRef?.current;
const findAnchor = (id: string) =>
Array.from(
getRoot()?.querySelectorAll<HTMLElement>(
"[data-transcript-anchor-id]"
) ?? []
).find((node) => node.dataset.transcriptAnchorId === id);
return {
getGroupAnchorId: (index) => groupRenderKeys[index] ?? null,
readNavigationGeometry: ({ anchorId, eventId, itemId }) => {
const root = getRoot();
if (!root) return { status: "pending" };
const index = anchorId ? groupRenderKeys.indexOf(anchorId) : -1;
if (anchorId && index < 0) return { status: "missing" };
const group = anchorId ? findAnchor(anchorId) : undefined;
const target =
eventId || itemId
? findChatSearchTargetElement(root, { eventId, itemId })
: group;
if (!group || !target) {
const offset =
index >= 0 && virtualScrollerRef.current
? virtualizer.getOffsetForIndex(index, "start")?.[0]
: undefined;
return { status: "pending", scrollTop: offset };
}
const groupRect = group.getBoundingClientRect();
const virtual = root === virtualScrollerRef.current;
if (
virtual &&
(!measuredRows.has(group) ||
committedRowSizes.current.get(index) !==
Math.round(groupRect.height))
) {
return { status: "pending" };
}
const rootRect = root.getBoundingClientRect();
const requestedTop =
eventId || itemId
? getSearchTargetScrollTop(root, target, true)
: root.scrollTop + groupRect.top - rootRect.top;
const top = Math.max(
0,
Math.min(root.scrollHeight - root.clientHeight, requestedTop)
);
return {
status: "measured",
revision: layoutRevision.current,
scrollTop: top,
anchor: {
itemId: anchorId!,
offsetFromViewportTop:
groupRect.top - rootRect.top - (top - root.scrollTop),
},
};
},
revealTranscriptAnchor: (id) => {
const root = getRoot();
if (!root) return false;
if (findAnchor(id)) return true;
const index = groupRenderKeys.indexOf(id);
const offset =
index >= 0 && virtualScrollerRef.current
? virtualizer.getOffsetForIndex(index, "start")?.[0]
: undefined;
if (offset !== undefined)
root.scrollTo({ top: offset, behavior: "auto" });
return false;
},
};
}, [
virtualScrollerRef,
staticScrollerRef,
virtualizer,
groupRenderKeys,
measuredRows,
committedRowSizes,
layoutRevision,
]);
}
Loading
Loading