From 086585db674025d5603077a3de4df1d4c7d6e2c2 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sat, 26 Sep 2026 20:23:55 +0800 Subject: [PATCH 1/2] fix(chat): stabilize long-history navigation Give the transcript viewport sole ownership of navigation targets and guard each request by scope and generation. Resolve search destinations from the full projection so collapsed and paged messages expand and settle correctly. Verification: - 68 Vitest files, 639 tests passed - pnpm typecheck passed - pnpm lint passed - pnpm check:circular passed (8145 modules) - pnpm check:test-placement passed (635 directories) - BuildFast packaged app and three consecutive rendered E2E runs passed - packaged-app navigation, search, pagination, restart, and database checks passed --- .../ChatPanel/ChatHistory/ChatHistory.tsx | 8 +- .../components/ChatHistoryList.tsx | 153 ++------- .../components/ChatHistoryListEquality.ts | 1 + .../components/ChatHistoryListNavigation.ts | 111 +++++++ .../components/ChatHistoryListTypes.ts | 17 +- .../components/ChatHistoryView.tsx | 2 + .../ChatHistoryListRowResize.test.ts | 13 +- .../__tests__/chatSearchProjection.test.ts | 44 +-- ...eChatNavigationController.viewport.test.ts | 15 +- ...atNavigationController.virtualized.test.ts | 304 ++++++++++++++++++ .../hooks/__tests__/useChatSearch.test.ts | 173 ++++++++-- .../hooks/chatSearch/chatSearchTargetDom.ts | 25 +- .../ChatHistory/hooks/chatSearchProjection.ts | 52 ++- .../hooks/useChatNavigationController.ts | 31 +- .../ChatHistory/hooks/useChatSearch.ts | 228 +++++++------ .../hooks/useChatViewportController.ts | 11 + .../renderers/GroupHeaderRenderer.test.ts | 7 + .../renderers/GroupHeaderRenderer.tsx | 2 +- .../__tests__/useTranscriptViewport.test.ts | 167 +++++++++- .../viewport/transcriptNavigation.ts | 43 +++ .../viewport/useTranscriptViewport.ts | 146 ++++++++- ...agent-org-root-group-follow-up-ui.spec.mjs | 144 +++++++-- 22 files changed, 1312 insertions(+), 385 deletions(-) create mode 100644 src/engines/ChatPanel/ChatHistory/components/ChatHistoryListNavigation.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.virtualized.test.ts create mode 100644 src/engines/ChatPanel/ChatHistory/viewport/transcriptNavigation.ts diff --git a/src/engines/ChatPanel/ChatHistory/ChatHistory.tsx b/src/engines/ChatPanel/ChatHistory/ChatHistory.tsx index d697b1595a..1dfac975bb 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.tsx +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.tsx @@ -119,6 +119,7 @@ const ChatHistory: React.FC = ({ }); const viewport = useChatViewportController({ activeId, + currentPageIndex: projection.currentPageIndex, bottomInset, browserAddToConversationNav, displayTotalFlatItems: projection.displayTotalFlatItems, @@ -157,7 +158,7 @@ const ChatHistory: React.FC = ({ turnPageListOpen: projection.turnPageListOpen, turnPaginationEnabled, virtualListRef: historyState.virtualListRef, - onExplicitNavigation: viewport.detachForNavigation, + onExplicitNavigation: viewport.beginNavigation, }); const emptyState = useChatEmptyState({ isAgentWorking: platform.isAgentWorking, @@ -171,6 +172,9 @@ const ChatHistory: React.FC = ({ 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, @@ -179,7 +183,7 @@ const ChatHistory: React.FC = ({ 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 diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx index 9ae7f64400..2db66f934c 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryList.tsx @@ -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, @@ -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"; @@ -52,6 +47,7 @@ import { resolveActiveGroupPinState, resolveVisibleGroupIndices, } from "./ChatHistoryListLayout"; +import { useChatHistoryListNavigation } from "./ChatHistoryListNavigation"; import type { ChatHistoryListProps, RowGroupMeta, @@ -100,6 +96,7 @@ const ChatHistoryList: React.FC = memo( staticScrollerRef, onScrollRootChange, onRowLayoutCommit, + isNavigating, newEventDividerLabel = null, }) => { // Planning indicator state in refs so polling ticks don't invalidate @@ -158,18 +155,12 @@ const ChatHistoryList: React.FC = 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, @@ -177,9 +168,12 @@ const ChatHistoryList: React.FC = memo( groupRenderKeys[index] ?? `chat-group-index:${index}`, }); const virtualItems = virtualizer.getVirtualItems(); + const measuredRows = useRef(new WeakMap()).current; + const layoutRevision = useRef(0); // Row sizes the committed row offsets were computed from. const committedRowSizesRef = useRef(new Map()); useLayoutEffect(() => { + layoutRevision.current += 1; committedRowSizesRef.current = new Map( virtualItems.map((item) => [item.index, item.size]) ); @@ -197,8 +191,10 @@ const ChatHistoryList: React.FC = 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; @@ -206,10 +202,18 @@ const ChatHistoryList: React.FC = memo( 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 @@ -230,7 +234,7 @@ const ChatHistoryList: React.FC = memo( observer.unobserve(node); }; }, - [virtualizer] + [measuredRows, virtualizer] ); useEffect(() => { @@ -255,108 +259,21 @@ const ChatHistoryList: React.FC = 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( - "[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( - `[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] diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts index 81c8923f88..7e956aeb75 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts @@ -235,6 +235,7 @@ export function sameChatHistoryListProps( "onScrollRootChange", previous.onScrollRootChange === next.onScrollRootChange, ], + ["isNavigating", previous.isNavigating === next.isNavigating], [ "onRowLayoutCommit", previous.onRowLayoutCommit === next.onRowLayoutCommit, diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListNavigation.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListNavigation.ts new file mode 100644 index 0000000000..9d6126b830 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListNavigation.ts @@ -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; + virtualScrollerRef: RefObject; + staticScrollerRef?: RefObject; + virtualizer: Virtualizer; + groupRenderKeys: string[]; + measuredRows: WeakMap; + committedRowSizes: RefObject>; + layoutRevision: RefObject; +}) { + useImperativeHandle(virtualListRef, () => { + const getRoot = () => + virtualScrollerRef.current ?? staticScrollerRef?.current; + const findAnchor = (id: string) => + Array.from( + getRoot()?.querySelectorAll( + "[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, + ]); +} diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts index 644a468cc8..a886f91035 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListTypes.ts @@ -14,20 +14,17 @@ import type { AgentStatusTrailState } from "@src/engines/ChatPanel/hooks/agentSt import type { OptimizedChatItem } from "../chatItemPipeline/types"; import type { GroupHeaderRenderPart } from "../renderers/GroupHeaderRenderer"; +import type { TranscriptNavigationGeometry } from "../viewport/transcriptNavigation"; export type EventSummary = NonNullable; export interface ChatHistoryListHandle { - scrollToGroup: (options: { - groupIndex: number; - behavior?: ScrollBehavior; - }) => void; - scrollToChatTarget: (options: { + getGroupAnchorId: (groupIndex: number) => string | null; + readNavigationGeometry: (target: { + anchorId: string; eventId?: string; itemId?: string; - flatIndex?: number; - behavior?: ScrollBehavior; - }) => void; + }) => TranscriptNavigationGeometry; /** Mount an off-screen virtual group before restoring its exact pixel anchor. */ revealTranscriptAnchor: (anchorId: string) => boolean; } @@ -94,7 +91,9 @@ export interface ChatHistoryListProps { * Called after re-measured virtual rows were committed synchronously, so the * viewport owner can correct scroll against the offsets about to paint. */ - onRowLayoutCommit?: () => void; + onRowLayoutCommit?: (duringReactCommit?: boolean) => void; + /** While navigating, the viewport aligns the target after committed measurement. */ + isNavigating?: () => boolean; /** * When set, `GroupItemRenderer` paints a `NewEventDivider` with this * label above each group's last item. Subagent panes opt in so the diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index 0e1b1f9c76..3165128bb0 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -187,6 +187,7 @@ const ChatHistoryView: React.FC = ({ const { conversationMinimapScrolling, detachForNavigation, + isNavigating, footerSpacerHeight, handleChatListScrollStateChange, handleRangeChanged, @@ -567,6 +568,7 @@ const ChatHistoryView: React.FC = ({ staticScrollerRef={staticScrollerRef} onScrollRootChange={setScrollRoot} onRowLayoutCommit={reconcileLayout} + isNavigating={isNavigating} newEventDividerLabel={newEventDividerLabel} /> diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListRowResize.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListRowResize.test.ts index ee86f5785f..d59209502c 100644 --- a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListRowResize.test.ts +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListRowResize.test.ts @@ -177,8 +177,9 @@ describe("ChatHistoryList row resize", () => { it("commits re-wrapped row offsets inside the observer callback, then reconciles the viewport", () => { const committedOffsetsAtReconcile: string[] = []; - const onRowLayoutCommit = vi.fn(() => { - committedOffsetsAtReconcile.push(row(1).style.transform); + const onRowLayoutCommit = vi.fn((duringReactCommit?: boolean) => { + if (!duringReactCommit) + committedOffsetsAtReconcile.push(row(1).style.transform); }); act(() => root.render(createElement(ChatHistoryList, listProps(onRowLayoutCommit))) @@ -191,16 +192,20 @@ describe("ChatHistoryList row resize", () => { // can already show the new offset here. expect(row(1).style.transform).toBe("translateY(520px)"); }); - expect(onRowLayoutCommit).toHaveBeenCalledOnce(); + expect(onRowLayoutCommit).toHaveBeenCalledWith(); expect(committedOffsetsAtReconcile).toEqual(["translateY(520px)"]); }); - it("ignores observer notifications that match the committed row sizes", () => { + it("reports the first measurement even at the estimated height, then ignores repeats", () => { const onRowLayoutCommit = vi.fn(); act(() => root.render(createElement(ChatHistoryList, listProps(onRowLayoutCommit))) ); + onRowLayoutCommit.mockClear(); + act(() => resize(0, ESTIMATED_ROW_SIZE + 0.4)); + expect(onRowLayoutCommit).toHaveBeenCalledExactlyOnceWith(); + onRowLayoutCommit.mockClear(); act(() => resize(0, ESTIMATED_ROW_SIZE + 0.4)); expect(onRowLayoutCommit).not.toHaveBeenCalled(); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts index 22c5824176..a0a284b252 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/chatSearchProjection.test.ts @@ -14,9 +14,8 @@ import { import { buildEventIdProjectionIndex, collectChatItemEventIds, - resolvePageIndexForFlatIndex, - toDisplayFlatIndex, } from "../chatSearchProjection"; +import { projectChatGroups } from "../useChatGroupsProjection"; function event(id: string): SessionEvent { return { @@ -50,6 +49,30 @@ function item( } describe("chatSearchProjection", () => { + it("keeps exact ownership for collapsed events and empty turn headers", () => { + const allItems = [item("user-a"), item("answer-a"), item("user-b")]; + const groups = projectChatGroups(allItems, { + isTurnHeaderItem: (row) => row.chunk_id.startsWith("user-"), + defaultTurnCollapsed: true, + tailTurnPhase: "complete", + }); + const index = buildEventIdProjectionIndex( + groups.flatItems, + groups.groupCounts, + groups.groupMeta, + allItems, + groups.originalToFlatIndex, + groups.groupHeaders + ); + expect(index.get("user-a")).toMatchObject({ + groupIndex: 0, + turnId: "user-a", + }); + expect(index.get("user-b")).toMatchObject({ + groupIndex: 1, + turnId: "user-b", + }); + }); it("collects nested activity stack event ids", () => { const stack = item("stack-1", { type: "activityStackGroup", @@ -79,23 +102,6 @@ describe("chatSearchProjection", () => { itemChunkId: "evt-2", }); }); - - it("resolves pagination page and display-local flat index", () => { - const pages = [ - { flatStartIndex: 0, flatEndIndex: 2 }, - { flatStartIndex: 2, flatEndIndex: 4 }, - ]; - - expect(resolvePageIndexForFlatIndex(3, pages)).toBe(1); - expect(toDisplayFlatIndex(3, pages[1])).toBe(1); - }); - - it("keeps global flat indices when pagination is off (no page slice)", () => { - const firstPageOnly = { flatStartIndex: 0, flatEndIndex: 2 }; - - expect(toDisplayFlatIndex(5, undefined)).toBe(5); - expect(toDisplayFlatIndex(5, firstPageOnly)).toBeNull(); - }); }); describe("chatSearch DOM helpers", () => { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.viewport.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.viewport.test.ts index 57a6acbda2..f1fc10f717 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.viewport.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.viewport.test.ts @@ -6,7 +6,7 @@ import { expect, it, vi } from "vitest"; import { useTranscriptViewport } from "../../viewport/useTranscriptViewport"; import { useChatNavigationController } from "../useChatNavigationController"; -it("reaches an older turn when row measurement precedes the native scroll event", () => { +it("aligns an already mounted turn and preserves it through resize", () => { Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); const frames = new Map(); let frameId = 0; @@ -64,6 +64,7 @@ it("reaches an older turn when row measurement precedes the native scroll event" function Harness() { const viewport = useTranscriptViewport({ sessionKey: "private-history", + navigationScopeKey: JSON.stringify(["private-history", null]), contentKey: "two-turns", itemCount: 2, }); @@ -85,13 +86,17 @@ it("reaches an older turn when row measurement precedes the native scroll event" turnPaginationEnabled: false, virtualListRef: { current: { - scrollToGroup: ({ behavior }) => - scroller.scrollTo({ top: 0, behavior }), - scrollToChatTarget: vi.fn(), + getGroupAnchorId: () => "older", + readNavigationGeometry: () => ({ + status: "measured", + revision: 1, + scrollTop: 0, + anchor: { itemId: "older", offsetFromViewportTop: 0 }, + }), revealTranscriptAnchor: () => true, }, }, - onExplicitNavigation: viewport.detachForNavigation, + onExplicitNavigation: viewport.beginNavigation, }); useLayoutEffect(() => { navigate = navigation.handleConversationMinimapNavigate; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.virtualized.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.virtualized.test.ts new file mode 100644 index 0000000000..cf4e064c01 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatNavigationController.virtualized.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +import { + act, + createElement, + createRef, + useCallback, + useLayoutEffect, +} from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; + +import { HIDDEN_AGENT_STATUS_TRAIL_STATE } from "@src/engines/ChatPanel/hooks/agentStatusTrailMath"; + +import type { OptimizedChatItem } from "../../chatItemPipeline/types"; +import ChatHistoryList, { + type ChatHistoryListHandle, +} from "../../components/ChatHistoryList"; +import { useTranscriptViewport } from "../../viewport/useTranscriptViewport"; +import { useChatNavigationController } from "../useChatNavigationController"; + +// Only body rendering is replaced. The list, viewport and TanStack range/size +// machinery are real; DOM geometry and browser event delivery are controlled. +vi.mock("../../renderers", () => ({ GroupItemRenderer: () => null })); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it("keeps an initially unmounted turn after distant navigation and row measurement", () => { + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + const frames = new Map(); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + frames.set(++frameId, cb); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + const observers = new Set<{ + nodes: Set; + callback: ResizeObserverCallback; + }>(); + vi.stubGlobal( + "ResizeObserver", + class { + nodes = new Set(); + constructor(public callback: ResizeObserverCallback) { + observers.add(this); + } + observe(node: Element) { + this.nodes.add(node); + } + unobserve(node: Element) { + this.nodes.delete(node); + } + disconnect() { + this.nodes.clear(); + observers.delete(this); + } + } + ); + const heights = new Map(); + let scrollPending = false; + const isRoot = (el: HTMLElement) => + el.dataset.testid === "chat-history-scroll-container"; + const height = (el: HTMLElement): number => + isRoot(el) + ? 400 + : el.dataset.index !== undefined + ? (heights.get(Number(el.dataset.index)) ?? 360) + : Number.parseFloat(el.style.height) || 400; + for (const property of ["clientHeight", "offsetHeight"] as const) { + vi.spyOn(HTMLElement.prototype, property, "get").mockImplementation( + function (this: HTMLElement) { + return height(this); + } + ); + } + for (const property of ["clientWidth", "offsetWidth"] as const) { + vi.spyOn(HTMLElement.prototype, property, "get").mockReturnValue(600); + } + vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( + function (this: HTMLElement) { + return isRoot(this) + ? Number.parseFloat( + (this.firstElementChild as HTMLElement)?.style.height + ) || 400 + : height(this); + } + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + const root = this.closest( + '[data-testid="chat-history-scroll-container"]' + ); + const top = + this.dataset.index === undefined + ? 0 + : Number( + this.style.transform.match(/translateY\(([-\d.]+)px\)/)?.[1] ?? 0 + ) - (root?.scrollTop ?? 0); + return { + top, + bottom: top + height(this), + height: height(this), + left: 0, + right: 600, + width: 600, + x: 0, + y: top, + toJSON() {}, + }; + } + ); + vi.stubGlobal("scrollTo", vi.fn()); + const originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = function ( + options?: ScrollToOptions | number, + y?: number + ) { + const top = typeof options === "number" ? (y ?? 0) : (options?.top ?? 0); + const next = Math.max( + 0, + Math.min(this.scrollHeight - this.clientHeight, top) + ); + if (next !== this.scrollTop) { + this.scrollTop = next; + scrollPending = true; + } + }; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const flatItems = Array.from({ length: 57 }, (_, i) => ({ + chunk_id: `body-${i}`, + type: "activity", + event: { id: `event-${i}` }, + })) as OptimizedChatItem[]; + const counts = flatItems.map(() => 1); + const turnIds = flatItems.map((_, i) => `turn-${i}`); + let navigate = (_index: number) => {}; + const virtualListRef = createRef(); + const scrollerRef = createRef(); + function Harness() { + const ensureAnchorMounted = useCallback( + (id: string) => + virtualListRef.current?.revealTranscriptAnchor(id) ?? false, + [] + ); + const viewport = useTranscriptViewport({ + sessionKey: "history", + navigationScopeKey: JSON.stringify(["history", null]), + contentKey: "57-turns", + itemCount: 57, + ensureAnchorMounted, + }); + const navigation = useChatNavigationController({ + activeId: "history", + agentOrgOverviewAvailable: false, + currentPageIndex: 0, + displayGroupCounts: counts, + displayGroupHeaders: [], + displayGroupMeta: [], + displaySourceGroupIndices: counts.map((_, i) => i), + displayTotalFlatItems: 57, + pages: [], + setTurnPageListOpen: vi.fn(), + setTurnPageSortAscending: vi.fn(), + turnPageListOpen: false, + turnPaginationEnabled: false, + virtualListRef, + onExplicitNavigation: viewport.beginNavigation, + }); + useLayoutEffect(() => { + navigate = navigation.handleConversationMinimapNavigate; + }); + return createElement(ChatHistoryList, { + flatItems, + groupCounts: counts, + turnIds, + totalFlatItems: 57, + footerSpacerHeight: 0, + bottomInset: 0, + topPaddingPx: 0, + planningIndicatorCount: 0, + planningVariantIndex: 0, + planningFooterMode: "planning", + statusTrail: HIDDEN_AGENT_STATUS_TRAIL_STATE, + statusTrailSessionId: null, + virtualListRef, + virtualListDataKey: "history", + getIsWpGeneWorking: () => false, + renderGroupHeader: () => null, + onAtBottomStateChange: viewport.handleScroll, + onRangeChanged: () => {}, + onEndReached: () => {}, + isNavigating: viewport.isNavigating, + virtualScrollerRef: scrollerRef, + onScrollRootChange: viewport.setScrollRoot, + onRowLayoutCommit: viewport.reconcileLayout, + }); + } + const scroller = () => + host.querySelector( + '[data-testid="chat-history-scroll-container"]' + )!; + const frame = () => + act(() => { + const pending = [...frames.entries()]; + frames.clear(); + pending.forEach(([id, cb]) => cb(id)); + }); + const scroll = () => + act(() => { + if (scrollPending) { + scrollPending = false; + scroller().dispatchEvent(new Event("scroll")); + } + }); + const resize = () => + act(() => { + for (const observer of [...observers]) { + const entries = [...observer.nodes] + .filter((n) => n.isConnected) + .map( + (node) => + ({ + target: node, + borderBoxSize: [ + { blockSize: height(node as HTMLElement), inlineSize: 600 }, + ], + contentRect: node.getBoundingClientRect(), + }) as unknown as ResizeObserverEntry + ); + observer.callback(entries, observer as unknown as ResizeObserver); + } + }); + try { + act(() => root.render(createElement(Harness))); + for (let i = 0; i < 4; i++) { + frame(); + scroll(); + resize(); + } + expect(scroller().scrollTop).toBeGreaterThan(19000); + expect(host.querySelector('[data-chat-group-index="0"]')).toBeNull(); + act(() => navigate(0)); + // A layout callback can precede native scroll delivery and virtual range + // replacement: the old mounted window must never become the destination. + resize(); + frame(); + scroll(); + heights.set(0, 620); + heights.set(1, 180); + for (let i = 0; i < 4; i++) { + resize(); + scroll(); + frame(); + } + const oldest = host.querySelector( + '[data-chat-group-index="0"]' + ); + expect(oldest).not.toBeNull(); + expect(oldest!.getBoundingClientRect().top).toBe(0); + heights.set(0, 800); + resize(); + scroll(); + frame(); + expect(oldest!.getBoundingClientRect().top).toBe(0); + act(() => { + navigate(20); + navigate(35); + }); + for (let i = 0; i < 4; i++) { + frame(); + scroll(); + resize(); + } + expect( + host + .querySelector('[data-chat-group-index="35"]')! + .getBoundingClientRect().top + ).toBe(0); + act(() => navigate(0)); + act(() => + scroller().dispatchEvent(new WheelEvent("wheel", { deltaY: 120 })) + ); + const userTop = scroller().scrollTop + 120; + scroller().scrollTop = userTop; + act(() => scroller().dispatchEvent(new Event("scroll"))); + for (let i = 0; i < 4; i++) { + resize(); + scroll(); + frame(); + } + expect(scroller().scrollTop).toBe(userTop); + expect(host.querySelector('[data-chat-group-index="0"]')).toBeNull(); + } finally { + act(() => root.unmount()); + host.remove(); + HTMLElement.prototype.scrollTo = originalScrollTo; + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + } +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatSearch.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatSearch.test.ts index 457034b346..e93e8d8725 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatSearch.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatSearch.test.ts @@ -13,6 +13,7 @@ import { import { agentOrgExecutionNavigationAtom } from "../../agentOrgExecutionNavigation"; import { useTranscriptViewport } from "../../viewport/useTranscriptViewport"; +import { getSearchTargetScrollTop } from "../chatSearch/chatSearchTargetDom"; import { type UseChatSearchOptions, type UseChatSearchReturn, @@ -286,7 +287,7 @@ describe("chat search highlight scheduling", () => { }); it("selects the exact execution page before scrolling to its formal group", async () => { - const scrollToGroup = vi.fn(); + const getGroupAnchorId = vi.fn(() => "target-anchor"); const event = { ...options.chatHistory[0], args: { @@ -302,8 +303,8 @@ it("selects the exact execution page before scrolling to its formal group", asyn ...options, virtualListRef: { current: { - scrollToGroup, - scrollToChatTarget: vi.fn(), + getGroupAnchorId, + readNavigationGeometry: vi.fn(() => ({ status: "pending" as const })), revealTranscriptAnchor: vi.fn(), }, }, @@ -371,10 +372,12 @@ it("selects the exact execution page before scrolling to its formal group", asyn root.render(createElement(Provider, { store }, createElement(Harness))) ); await advance(32); - expect(scrollToGroup).toHaveBeenCalledWith({ - groupIndex: 0, - behavior: "auto", - }); + const target = vi.mocked(options.onExplicitNavigation).mock.calls.at(-1)![0]; + expect(target.scopeKey).toBe(JSON.stringify(["session-1", 0])); + target.readGeometry(); + expect(getGroupAnchorId).toHaveBeenCalledWith(0); + expect(store.get(agentOrgExecutionNavigationAtom)).not.toBeNull(); + act(() => target.onEnd?.("settled")); expect(mocks.navigate).not.toHaveBeenCalled(); expect(store.get(agentOrgExecutionNavigationAtom)).toBeNull(); }); @@ -382,7 +385,7 @@ it("selects the exact execution page before scrolling to its formal group", asyn it.each([false, true])( "locates an execution without a rendered input event (pagination: %s)", async (paginated) => { - const scrollToGroup = vi.fn(); + const getGroupAnchorId = vi.fn(() => "target-anchor"); const execution = { turnIntentId: "report", participantId: "coordinator", @@ -422,8 +425,8 @@ it.each([false, true])( currentPageIndex: 1, virtualListRef: { current: { - scrollToGroup, - scrollToChatTarget: vi.fn(), + getGroupAnchorId, + readNavigationGeometry: vi.fn(() => ({ status: "pending" as const })), revealTranscriptAnchor: vi.fn(), }, }, @@ -442,18 +445,21 @@ it.each([false, true])( ); expect(mocks.expandTurn).toHaveBeenCalledTimes(1); await advance(32); - expect(scrollToGroup).toHaveBeenCalledWith({ - groupIndex: paginated ? 0 : 1, - behavior: "auto", - }); + const target = vi + .mocked(options.onExplicitNavigation) + .mock.calls.at(-1)![0]; + target.readGeometry(); + expect(getGroupAnchorId).toHaveBeenCalledWith(paginated ? 0 : 1); expect( - options.virtualListRef.current?.scrollToChatTarget - ).not.toHaveBeenCalled(); + options.virtualListRef.current?.readNavigationGeometry + ).toHaveBeenCalledWith({ anchorId: "target-anchor" }); + expect(store.get(agentOrgExecutionNavigationAtom)).not.toBeNull(); + act(() => target.onEnd?.("settled")); expect(store.get(agentOrgExecutionNavigationAtom)).toBeNull(); } ); -it("keeps a distant execution visible when expansion resizes before the scroll event", async () => { +it("keeps an already mounted execution visible after navigation completes", async () => { const scroller = document.createElement("div"); Object.defineProperties(scroller, { clientHeight: { value: 400 }, @@ -513,18 +519,20 @@ it("keeps a distant execution visible when expansion resizes before the scroll e virtualListRef: { current: { revealTranscriptAnchor: () => true, - scrollToChatTarget: vi.fn(), - scrollToGroup: () => { - scroller.scrollTo({ top: 1600 }); - // WebKit observes the expanded content before delivering its scroll event. - resize(); - }, + getGroupAnchorId: () => "report", + readNavigationGeometry: () => ({ + status: "measured", + revision: 1, + scrollTop: 1600, + anchor: { itemId: "report", offsetFromViewportTop: 0 }, + }), }, }, }; function ViewportHarness() { const viewport = useTranscriptViewport({ sessionKey: options.sessionId, + navigationScopeKey: JSON.stringify([options.sessionId, null]), contentKey: "expanded-report", itemCount: 2, }); @@ -534,7 +542,7 @@ it("keeps a distant execution visible when expansion resizes before the scroll e }, [setScrollRoot]); useChatSearch({ ...options, - onExplicitNavigation: viewport.detachForNavigation, + onExplicitNavigation: viewport.beginNavigation, }); return null; } @@ -562,3 +570,120 @@ it("keeps a distant execution visible when expansion resizes before the scroll e vi.unstubAllGlobals(); } }); + +it("keeps an oversized search result aligned at its start across repeated geometry reads", () => { + const scroller = document.createElement("div"); + const target = document.createElement("div"); + Object.defineProperty(scroller, "clientHeight", { value: 400 }); + scroller.getBoundingClientRect = () => ({ top: 0, bottom: 400 }) as DOMRect; + target.getBoundingClientRect = () => + ({ + top: 800 - scroller.scrollTop, + bottom: 1800 - scroller.scrollTop, + height: 1000, + }) as DOMRect; + scroller.scrollTop = getSearchTargetScrollTop(scroller, target, true); + expect(scroller.scrollTop).toBe(752); + expect(getSearchTargetScrollTop(scroller, target, true)).toBe(752); +}); + +it("expands a collapsed search event on its destination page and resolves its latest projection", async () => { + const event = { ...options.chatHistory[0], displayText: "needle" }; + const hidden = { chunk_id: "chunk-1", type: "activity" as const, event }; + const summary = { + ...hidden, + chunk_id: "summary", + event: { + ...event, + id: "summary", + chunk_id: "summary", + displayText: "answer", + }, + }; + const getGroupAnchorId = vi.fn(() => "turn-anchor"); + const readNavigationGeometry = vi.fn(() => ({ status: "pending" as const })); + options = { + ...options, + chatHistory: [event], + flatItems: [summary], + sourceItems: [hidden, summary], + originalToFlatIndex: new Map([ + [0, 0], + [1, 0], + ]), + groupCounts: [1], + groupMeta: [ + { + turnId: "turn-1", + durationMs: 0, + itemCount: 1, + bodyEventCount: 1, + hasBody: true, + previewText: "needle", + startMs: null, + endMs: null, + unloadedTurn: null, + }, + ], + pages: [ + { + startGroupIndex: 0, + endGroupIndex: 0, + flatStartIndex: 0, + flatEndIndex: 1, + cursorIdeSummary: null, + }, + ], + turnPaginationEnabled: true, + currentPageIndex: 1, + virtualListRef: { + current: { + getGroupAnchorId, + readNavigationGeometry, + revealTranscriptAnchor: vi.fn(), + }, + }, + }; + act(() => + root.render(createElement(Provider, { store }, createElement(Harness))) + ); + query("needle"); + await advance(1000); + const target = vi.mocked(options.onExplicitNavigation).mock.calls.at(-1)![0]; + expect(target.id).toBe("event-1"); + expect(target.scopeKey).toBe(JSON.stringify(["session-1", 0])); + expect(options.setTurnPageSelection).toHaveBeenCalledWith({ + sessionId: "session-1", + pageIndex: 0, + }); + expect(mocks.expandTurn).toHaveBeenCalledWith({ + turnId: "turn-1", + collapsed: false, + }); + options = { + ...options, + currentPageIndex: 0, + flatItems: [{ ...hidden, chunk_id: "expanded-chunk" }], + sourceItems: [{ ...hidden, chunk_id: "expanded-chunk" }], + }; + act(() => + root.render(createElement(Provider, { store }, createElement(Harness))) + ); + target.readGeometry(); + expect(readNavigationGeometry).toHaveBeenCalledWith({ + anchorId: "turn-anchor", + eventId: "event-1", + itemId: "expanded-chunk", + }); + options = { + ...options, + chatHistory: [], + flatItems: [], + sourceItems: [], + groupCounts: [], + }; + act(() => + root.render(createElement(Provider, { store }, createElement(Harness))) + ); + expect(target.readGeometry()).toEqual({ status: "missing" }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts index 8e3acf99b1..54c92bfe51 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearch/chatSearchTargetDom.ts @@ -106,10 +106,10 @@ export function findSearchTargetElement( const SEARCH_SCROLL_IN_VIEW_PADDING_PX = 48; -export function scrollSearchTargetIntoView( +export function getSearchTargetScrollTop( scrollRoot: HTMLElement, element: HTMLElement, - behavior: ScrollBehavior = "auto" + alignOversizedToStart = false ) { const rootRect = scrollRoot.getBoundingClientRect(); const elRect = element.getBoundingClientRect(); @@ -119,10 +119,15 @@ export function scrollSearchTargetIntoView( elRect.top >= rootRect.top + padding && elRect.bottom <= rootRect.bottom - padding ) { - return; + return scrollRoot.scrollTop; } const elementTop = elRect.top - rootRect.top + scrollRoot.scrollTop; + if ( + alignOversizedToStart && + elRect.height > scrollRoot.clientHeight - 2 * padding + ) + return Math.max(0, elementTop - padding); let targetTop = scrollRoot.scrollTop; if (elRect.top < rootRect.top + padding) { @@ -131,10 +136,16 @@ export function scrollSearchTargetIntoView( targetTop = elementTop + elRect.height - scrollRoot.clientHeight + padding; } - scrollRoot.scrollTo({ - top: Math.max(0, targetTop), - behavior, - }); + return Math.max(0, targetTop); +} + +export function scrollSearchTargetIntoView( + scrollRoot: HTMLElement, + element: HTMLElement, + behavior: ScrollBehavior = "auto" +) { + const top = getSearchTargetScrollTop(scrollRoot, element); + if (top !== scrollRoot.scrollTop) scrollRoot.scrollTo({ top, behavior }); } export function resolveVisibleSearchResultIndex( diff --git a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts index 7d5c0e77d8..f20739dd1b 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/chatSearchProjection.ts @@ -1,6 +1,5 @@ import type { OptimizedChatItem } from "../chatItemPipeline/types"; import type { ChatGroupMeta } from "./useChatGroups"; -import type { ChatTurnPage } from "./useChatTurnPagination"; interface ChatSearchProjectionTarget { globalFlatIndex: number; @@ -51,13 +50,28 @@ function buildFlatIndexToGroupIndex(groupCounts: readonly number[]): number[] { export function buildEventIdProjectionIndex( flatItems: readonly OptimizedChatItem[], groupCounts: readonly number[], - groupMeta: readonly Pick[] + groupMeta: readonly Pick[], + sourceItems: readonly OptimizedChatItem[] = flatItems, + originalToFlatIndex?: ReadonlyMap, + groupHeaders: readonly (OptimizedChatItem | null)[] = [] ): Map { const flatToGroup = buildFlatIndexToGroupIndex(groupCounts); + const headerGroups = new Map( + groupHeaders.flatMap((item, index) => + item ? [[item, index] as const] : [] + ) + ); const index = new Map(); - flatItems.forEach((item, globalFlatIndex) => { - const groupIndex = flatToGroup[globalFlatIndex] ?? 0; + sourceItems.forEach((item, sourceIndex) => { + const globalFlatIndex = originalToFlatIndex + ? originalToFlatIndex.get(sourceIndex) + : sourceIndex; + if (globalFlatIndex === undefined) return; + // Collapsing removes rows, not their message identity. Empty headers must + // retain their own group even when their flat offset touches another group. + const groupIndex = + headerGroups.get(item) ?? flatToGroup[globalFlatIndex] ?? 0; const target: ChatSearchProjectionTarget = { globalFlatIndex, groupIndex, @@ -71,33 +85,3 @@ export function buildEventIdProjectionIndex( return index; } - -export function resolvePageIndexForFlatIndex( - globalFlatIndex: number, - pages: readonly Pick[] -): number | null { - for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) { - const page = pages[pageIndex]; - if ( - globalFlatIndex >= page.flatStartIndex && - globalFlatIndex < page.flatEndIndex - ) { - return pageIndex; - } - } - return null; -} - -export function toDisplayFlatIndex( - globalFlatIndex: number, - page: Pick | undefined -): number | null { - if (!page) return globalFlatIndex; - if ( - globalFlatIndex < page.flatStartIndex || - globalFlatIndex >= page.flatEndIndex - ) { - return null; - } - return globalFlatIndex - page.flatStartIndex; -} diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts index 3b73be5483..198686740e 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatNavigationController.ts @@ -1,5 +1,9 @@ import { useCallback, useMemo, useState } from "react"; +import { + type BeginTranscriptNavigation, + chatNavigationScopeKey, +} from "../viewport/transcriptNavigation"; import { useAgentOrgOverviewDisclosure } from "./useAgentOrgOverviewDisclosure"; import type { useChatHistoryProjectionModel } from "./useChatHistoryProjectionModel"; import type { UseChatHistoryStateReturn } from "./useChatHistoryState"; @@ -44,7 +48,7 @@ interface UseChatNavigationControllerOptions { turnPageListOpen: boolean; turnPaginationEnabled: boolean; virtualListRef: UseChatHistoryStateReturn["virtualListRef"]; - onExplicitNavigation: () => void; + onExplicitNavigation: BeginTranscriptNavigation; } /** Owns user navigation state for overview, minimap and pinned turn chrome. */ @@ -101,14 +105,27 @@ export function useChatNavigationController({ ); const handleConversationMinimapNavigate = useCallback( (groupIndex: number) => { - onExplicitNavigation(); - // Commit the destination before row measurement captures a reading anchor. - virtualListRef.current?.scrollToGroup({ - groupIndex, - behavior: "auto", + const anchorId = virtualListRef.current?.getGroupAnchorId(groupIndex); + if (!anchorId) return; + onExplicitNavigation({ + id: anchorId, + scopeKey: chatNavigationScopeKey( + activeId, + turnPaginationEnabled ? currentPageIndex : null + ), + readGeometry: () => + virtualListRef.current?.readNavigationGeometry({ anchorId }) ?? { + status: "pending", + }, }); }, - [onExplicitNavigation, virtualListRef] + [ + activeId, + currentPageIndex, + onExplicitNavigation, + turnPaginationEnabled, + virtualListRef, + ] ); const conversationHistoryPageIndex = resolveConversationHistoryPageIndex({ activeGroupIndex, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts index 1e031367b9..a0f4bddf56 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatSearch.ts @@ -25,6 +25,10 @@ import { import { agentOrgExecutionNavigationAtom } from "../agentOrgExecutionNavigation"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import type { ChatHistoryListHandle } from "../components/ChatHistoryList"; +import { + type BeginTranscriptNavigation, + chatNavigationScopeKey, +} from "../viewport/transcriptNavigation"; import { EMPTY_CHAT_SEARCH_SYNC, buildChatSearchSyncState, @@ -41,11 +45,7 @@ import { searchChatHistoryLocally, wrapNextSearchResultIndex, } from "./chatSearchHelpers"; -import { - buildEventIdProjectionIndex, - resolvePageIndexForFlatIndex, - toDisplayFlatIndex, -} from "./chatSearchProjection"; +import { buildEventIdProjectionIndex } from "./chatSearchProjection"; import type { ChatGroupMeta } from "./useChatGroups"; import { useChatSearchShortcut } from "./useChatSearchShortcut"; import type { ChatTurnPage } from "./useChatTurnPagination"; @@ -61,6 +61,9 @@ export interface UseChatSearchOptions { sessionId: string | null; chatHistory: MappedSearchResult["item"][]; flatItems: OptimizedChatItem[]; + sourceItems?: OptimizedChatItem[]; + originalToFlatIndex?: ReadonlyMap; + groupHeaders?: (OptimizedChatItem | null)[]; groupCounts: number[]; groupMeta: ChatGroupMeta[]; pages: ChatTurnPage[]; @@ -69,7 +72,7 @@ export interface UseChatSearchOptions { setTurnPageSelection: Dispatch>; virtualListRef: RefObject; chatContainerRef: RefObject; - onExplicitNavigation: () => void; + onExplicitNavigation: BeginTranscriptNavigation; debounceMs?: number; maxResults?: number; } @@ -149,6 +152,9 @@ export function useChatSearch( sessionId, chatHistory, flatItems, + sourceItems, + originalToFlatIndex, + groupHeaders, groupCounts, groupMeta, pages, @@ -186,8 +192,10 @@ export function useChatSearch( const modesRef = useRef(modes); modesRef.current = modes; const suppressScrollSyncRef = useRef(false); - const pendingScrollResultRef = useRef(null); - const pendingScrollNeedsLayoutRef = useRef(false); + const groupMetaRef = useRef(groupMeta); + groupMetaRef.current = groupMeta; + const chatHistoryRef = useRef(chatHistory); + chatHistoryRef.current = chatHistory; const [executionNavigation, setExecutionNavigation] = useAtom( agentOrgExecutionNavigationAtom @@ -201,8 +209,23 @@ export function useChatSearch( const setCollapseState = useSetAtom(setCollapseStateAtom); const projectionIndex = useMemo( - () => buildEventIdProjectionIndex(flatItems, groupCounts, groupMeta), - [flatItems, groupCounts, groupMeta] + () => + buildEventIdProjectionIndex( + flatItems, + groupCounts, + groupMeta, + sourceItems, + originalToFlatIndex, + groupHeaders + ), + [ + flatItems, + groupCounts, + groupMeta, + sourceItems, + originalToFlatIndex, + groupHeaders, + ] ); const projectionIndexRef = useRef(projectionIndex); projectionIndexRef.current = projectionIndex; @@ -223,64 +246,59 @@ export function useChatSearch( searchGenerationRef.current += 1; }, [setChatSearchSync]); - const finishPendingScroll = useCallback( - (result: SearchResult) => { - const eventId = result.item.id || result.item.chunk_id || ""; - const projection = eventId - ? projectionIndexRef.current.get(eventId) - : undefined; - - let targetPageIndex = currentPageIndexRef.current; - if (turnPaginationEnabled && projection) { - const resolvedPage = resolvePageIndexForFlatIndex( - projection.globalFlatIndex, - pagesRef.current - ); - if (resolvedPage !== null) { - targetPageIndex = resolvedPage; - } - } - - // Non-paginated view renders the full flat list; passing a turn page - // slice here would clip indices outside the first page to null. - const targetPage = turnPaginationEnabled - ? pagesRef.current[targetPageIndex] - : undefined; - const displayFlatIndex = projection - ? toDisplayFlatIndex(projection.globalFlatIndex, targetPage) - : null; - - virtualListRef.current?.scrollToChatTarget({ - eventId, - itemId: projection?.itemChunkId, - flatIndex: displayFlatIndex ?? undefined, - behavior: "auto", - }); - - window.setTimeout(() => { - suppressScrollSyncRef.current = false; - }, 80); - }, - [turnPaginationEnabled, virtualListRef] - ); - const scrollToSearchResult = useCallback( (result: SearchResult) => { - onExplicitNavigation(); const eventId = result.item.id || result.item.chunk_id || ""; const projection = eventId ? projectionIndexRef.current.get(eventId) : undefined; - const resolvedPage = + const pageIndex = turnPaginationEnabled && projection - ? resolvePageIndexForFlatIndex( - projection.globalFlatIndex, - pagesRef.current + ? pagesRef.current.findIndex( + (page) => + projection.groupIndex >= page.startGroupIndex && + projection.groupIndex <= page.endGroupIndex ) - : null; - const needsFlatItemsLayout = - Boolean(projection?.turnId) || - (resolvedPage !== null && resolvedPage !== currentPageIndexRef.current); + : -1; + const resolvedPage = pageIndex >= 0 ? pageIndex : null; + const targetPageIndex = resolvedPage ?? currentPageIndexRef.current; + onExplicitNavigation({ + id: eventId, + scopeKey: chatNavigationScopeKey( + sessionId, + turnPaginationEnabled ? targetPageIndex : null + ), + readGeometry: () => { + const current = projectionIndexRef.current.get(eventId); + if (!current) { + return { + status: chatHistoryRef.current.some( + (item) => item.id === eventId || item.chunk_id === eventId + ) + ? "pending" + : "missing", + }; + } + const page = turnPaginationEnabled + ? pagesRef.current[targetPageIndex] + : undefined; + const anchorId = virtualListRef.current?.getGroupAnchorId( + current.groupIndex - (page?.startGroupIndex ?? 0) + ); + if (!anchorId) return { status: "pending" }; + return ( + virtualListRef.current?.readNavigationGeometry({ + anchorId, + eventId, + itemId: current.itemChunkId, + }) ?? { status: "pending" } + ); + }, + onEnd: () => { + suppressScrollSyncRef.current = false; + }, + }); + suppressScrollSyncRef.current = true; if (projection?.turnId) { setTurnCollapseOverride({ @@ -298,21 +316,8 @@ export function useChatSearch( sessionId, }); } - - suppressScrollSyncRef.current = true; - - if (needsFlatItemsLayout) { - pendingScrollResultRef.current = result; - pendingScrollNeedsLayoutRef.current = true; - return; - } - - window.requestAnimationFrame(() => { - finishPendingScroll(result); - }); }, [ - finishPendingScroll, navigateToEvent, onExplicitNavigation, sessionId, @@ -320,22 +325,10 @@ export function useChatSearch( setTurnCollapseOverride, setTurnPageSelection, turnPaginationEnabled, + virtualListRef, ] ); - useEffect(() => { - if (!pendingScrollNeedsLayoutRef.current) return; - const result = pendingScrollResultRef.current; - if (!result) return; - - pendingScrollNeedsLayoutRef.current = false; - pendingScrollResultRef.current = null; - - window.requestAnimationFrame(() => { - finishPendingScroll(result); - }); - }, [currentPageIndex, finishPendingScroll, flatItems, groupCounts]); - useEffect(() => { if (!executionNavigation || executionNavigation.sessionId !== sessionId) { preparedExecutionNavigationRef.current = null; @@ -351,39 +344,42 @@ export function useChatSearch( page.startGroupIndex <= groupIndex && page.endGroupIndex >= groupIndex ); if (turnPaginationEnabled && targetPage < 0) return; - if ( - turnPaginationEnabled && - targetPage >= 0 && - targetPage !== currentPageIndex - ) { + if (preparedExecutionNavigationRef.current === executionNavigation) return; + preparedExecutionNavigationRef.current = executionNavigation; + onExplicitNavigation({ + id: executionNavigation.turnIntentId, + scopeKey: chatNavigationScopeKey( + sessionId, + turnPaginationEnabled ? targetPage : null + ), + readGeometry: () => { + const index = groupMetaRef.current.findIndex( + (meta) => + meta.execution?.turnIntentId === executionNavigation.turnIntentId + ); + if (index < 0) return { status: "missing" }; + const page = turnPaginationEnabled + ? pagesRef.current[targetPage] + : undefined; + const anchorId = virtualListRef.current?.getGroupAnchorId( + index - (page?.startGroupIndex ?? 0) + ); + return anchorId + ? virtualListRef.current!.readNavigationGeometry({ anchorId }) + : { status: "pending" }; + }, + onEnd: () => + setExecutionNavigation((current) => + current === executionNavigation ? null : current + ), + }); + if (turnPaginationEnabled && targetPage !== currentPageIndex) { setTurnPageSelection({ sessionId, pageIndex: targetPage }); - return; } - if (preparedExecutionNavigationRef.current !== executionNavigation) { - preparedExecutionNavigationRef.current = executionNavigation; - setTurnCollapseOverride({ - turnId: `agent-org-execution-${executionNavigation.turnIntentId}`, - collapsed: false, - }); - } - // Execution anchors belong to group headers, not searchable body items. - // Wait for the expanded page layout, then use the same group navigation - // as the minimap, including executions with no inbox or body event. - const frame = window.requestAnimationFrame(() => { - if (!virtualListRef.current) return; - // Detach in the same frame as the actual movement. Detaching during - // preparation lets the viewport capture the old position first, then - // restore it when the expanded content reports its new size. - onExplicitNavigation(); - virtualListRef.current.scrollToGroup({ - groupIndex: turnPaginationEnabled - ? groupIndex - pages[currentPageIndex].startGroupIndex - : groupIndex, - behavior: "auto", - }); - setExecutionNavigation(null); + setTurnCollapseOverride({ + turnId: `agent-org-execution-${executionNavigation.turnIntentId}`, + collapsed: false, }); - return () => window.cancelAnimationFrame(frame); }, [ executionNavigation, sessionId, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts index 379a31f37b..b64f699701 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts @@ -6,6 +6,7 @@ import type { ScrollNavState, } from "../ChatHistory.types"; import { CHAT_FOOTER_SPACER } from "../config/chatFooterSpacer"; +import { chatNavigationScopeKey } from "../viewport/transcriptNavigation"; import { useTranscriptViewport } from "../viewport/useTranscriptViewport"; import { useChatFooterSpacer } from "./useChatFooterSpacer"; import type { UseChatHistoryStateReturn } from "./useChatHistoryState"; @@ -15,6 +16,7 @@ const FLOATING_MINIMAP_IDLE_DELAY_MS = 1_200; interface UseChatViewportControllerOptions { activeId: string | null; + currentPageIndex: number; bottomInset: number; browserAddToConversationNav: BrowserAddToConversationNavState; displayTotalFlatItems: number; @@ -38,6 +40,7 @@ interface UseChatViewportControllerOptions { */ export function useChatViewportController({ activeId, + currentPageIndex, bottomInset, browserAddToConversationNav, displayTotalFlatItems, @@ -84,6 +87,8 @@ export function useChatViewportController({ ); const { detachForNavigation, + beginNavigation, + isNavigating, followTail, handleScroll, preserveForLayoutMutation, @@ -92,6 +97,10 @@ export function useChatViewportController({ showScrollToBottom, } = useTranscriptViewport({ sessionKey: activeId, + navigationScopeKey: chatNavigationScopeKey( + activeId, + turnPaginationEnabled ? currentPageIndex : null + ), contentKey: tailFollowKey, itemCount: displayTotalFlatItems, tailGapPx: CHAT_FOOTER_SPACER.MIN_WHEN_FULL_PX, @@ -150,6 +159,8 @@ export function useChatViewportController({ return { conversationMinimapScrolling, detachForNavigation, + beginNavigation, + isNavigating, footerSpacerHeight, handleChatListScrollStateChange, handleRangeChanged, diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.test.ts b/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.test.ts index ed1a6852a8..3984688871 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.test.ts +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.test.ts @@ -117,6 +117,13 @@ describe("GroupHeaderRenderer after a round the agent never worked in", () => { expect(marginTopOf(2, hasBody)).toBe(""); }); + it("exposes an empty user turn's exact message identity for search", () => { + marginTopOf(1, [false, false, true]); + expect( + container.querySelector('[data-chat-event-ids="user-1"]')?.textContent + ).toContain("[Request interrupted by user]"); + }); + it("keeps the round gap after a round with a body", () => { expect(marginTopOf(1, [true, false, true])).toBe( `${CHAT_FOOTER_SPACER.ROUND_GAP_PX}px` diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.tsx index f61328e826..4b0f773de5 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupHeaderRenderer.tsx @@ -266,7 +266,7 @@ export const GroupHeaderRenderer: React.FC = memo( return (
0 ? { marginTop: roundGap } : undefined} > diff --git a/src/engines/ChatPanel/ChatHistory/viewport/__tests__/useTranscriptViewport.test.ts b/src/engines/ChatPanel/ChatHistory/viewport/__tests__/useTranscriptViewport.test.ts index 67bcc57dda..c43d417ebf 100644 --- a/src/engines/ChatPanel/ChatHistory/viewport/__tests__/useTranscriptViewport.test.ts +++ b/src/engines/ChatPanel/ChatHistory/viewport/__tests__/useTranscriptViewport.test.ts @@ -3,6 +3,7 @@ import { act, createElement, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TranscriptNavigationTarget } from "../transcriptNavigation"; import { type UseTranscriptViewportReturn, useTranscriptViewport, @@ -34,13 +35,16 @@ describe("useTranscriptViewport", () => { sessionKey, contentKey, localSubmitKey, + navigationScopeKey, }: { sessionKey: string; contentKey: string; localSubmitKey?: string | null; + navigationScopeKey?: string; }) { const value = useTranscriptViewport({ sessionKey, + navigationScopeKey, contentKey, itemCount: 2, localSubmitKey, @@ -55,7 +59,8 @@ describe("useTranscriptViewport", () => { const render = ( sessionKey: string, contentKey: string, - localSubmitKey?: string | null + localSubmitKey?: string | null, + navigationScopeKey?: string ) => { act(() => root.render( @@ -63,6 +68,7 @@ describe("useTranscriptViewport", () => { sessionKey, contentKey, localSubmitKey, + navigationScopeKey, }) ) ); @@ -499,4 +505,163 @@ describe("useTranscriptViewport", () => { expect(resizeDisconnect).toHaveBeenCalledTimes(10); expect(frames).toHaveLength(0); }); + const navigationTarget = ( + id = "turn-a", + top = 100 + ): TranscriptNavigationTarget => ({ + id, + scopeKey: "session-a", + onEnd: vi.fn(), + readGeometry: vi.fn(() => ({ + status: "measured" as const, + revision: 1, + scrollTop: top, + anchor: { itemId: id, offsetFromViewportTop: 0 }, + })), + }); + + it("does not capture intermediate rows while mounting; measured target becomes the reading anchor", () => { + const target = navigationTarget(); + let measured = false; + target.readGeometry = () => + measured + ? { + status: "measured", + revision: 2, + scrollTop: 100, + anchor: { itemId: "turn-a", offsetFromViewportTop: 0 }, + } + : { status: "pending", scrollTop: 300 }; + act(() => viewport.beginNavigation(target)); + act(flushFrames); + act(() => viewport.handleScroll(false)); + act(triggerResize); + expect(viewport.isNavigating()).toBe(true); + expect(scrollRoot.scrollTop).toBe(300); + expect(target.onEnd).not.toHaveBeenCalled(); + measured = true; + act(triggerResize); + act(flushFrames); + expect(scrollRoot.scrollTop).toBe(100); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("settled"); + firstAnchorTop = 175; + act(triggerResize); + expect(scrollRoot.scrollTop).toBe(175); + }); + + it("rejects an old scheduled frame after a newer destination takes ownership", () => { + const first = navigationTarget(); + const second = navigationTarget("turn-b", 500); + let firstGeneration = 0; + act(() => { + firstGeneration = viewport.beginNavigation(first); + }); + const staleFrame = [...frames.values()][0]; + act(() => { + expect(viewport.beginNavigation(second)).toBeGreaterThan(firstGeneration); + }); + act(() => staleFrame(0)); + expect(first.readGeometry).not.toHaveBeenCalled(); + expect(frames).toHaveLength(1); + act(flushFrames); + expect(first.onEnd).toHaveBeenCalledExactlyOnceWith("superseded"); + expect(second.onEnd).toHaveBeenCalledExactlyOnceWith("settled"); + expect(scrollRoot.scrollTop).toBe(500); + }); + + it.each(["wheel", "touchstart", "keydown", "pointerdown"])( + "lets %s take over before the next native scroll", + (kind) => { + const target = navigationTarget(); + act(() => viewport.beginNavigation(target)); + const event = + kind === "wheel" + ? new WheelEvent(kind, { deltaY: 120 }) + : kind === "keydown" + ? new KeyboardEvent(kind, { key: "ArrowUp" }) + : kind === "pointerdown" + ? new MouseEvent(kind, { button: 0, clientX: 499 }) + : new Event(kind); + act(() => scrollRoot.dispatchEvent(event)); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("user"); + scrollRoot.scrollTop = 250; + act(() => viewport.handleScroll(false)); + firstAnchorTop += 40; + act(triggerResize); + act(flushFrames); + expect(scrollRoot.scrollTop).toBe(290); + expect(viewport.isNavigating()).toBe(false); + } + ); + + it("waits for an intentional destination page and cancels a different page selection", () => { + render("session-a", "content", null, "page-0"); + act(flushFrames); + const target = { ...navigationTarget(), scopeKey: "page-1" }; + act(() => viewport.beginNavigation(target)); + act(flushFrames); + expect(target.readGeometry).not.toHaveBeenCalled(); + render("session-a", "expanded", null, "page-1"); + act(flushFrames); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("settled"); + const next = { ...navigationTarget(), scopeKey: "page-2" }; + act(() => viewport.beginNavigation(next)); + render("session-a", "content", null, "page-3"); + act(flushFrames); + expect(next.onEnd).toHaveBeenCalledExactlyOnceWith("scope"); + expect(next.readGeometry).not.toHaveBeenCalled(); + }); + + it("cancels session changes and invalidates already queued work", () => { + const target = navigationTarget(); + act(() => viewport.beginNavigation(target)); + const staleFrame = [...frames.values()][0]; + render("session-b", "content"); + act(() => staleFrame(0)); + act(flushFrames); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("scope"); + expect(target.readGeometry).not.toHaveBeenCalled(); + }); + + it("leaves the current position when the target disappears", () => { + const target = navigationTarget(); + target.readGeometry = () => ({ status: "missing" }); + act(() => viewport.beginNavigation(target)); + act(flushFrames); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("missing"); + expect(scrollRoot.scrollTop).toBe(600); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("does no navigation work while hidden and revalidates once on return", () => { + const target = navigationTarget(); + act(() => viewport.beginNavigation(target)); + visibilityState = "hidden"; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + act(triggerResize); + act(flushFrames); + expect(frames).toHaveLength(0); + expect(target.readGeometry).not.toHaveBeenCalled(); + visibilityState = "visible"; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + act(flushFrames); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("settled"); + expect(frames).toHaveLength(0); + }); + + it("returns pending navigation and callbacks to baseline when unmounted", () => { + const target = navigationTarget(); + act(() => viewport.beginNavigation(target)); + act(() => root.render(null)); + expect(target.onEnd).toHaveBeenCalledExactlyOnceWith("unmount"); + expect(frames).toHaveLength(0); + }); + it("does not retry a refused browser scroll every frame", () => { + scrollTo.mockImplementation(() => {}); + act(() => viewport.beginNavigation(navigationTarget())); + act(flushFrames); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(frames).toHaveLength(0); + expect(viewport.isNavigating()).toBe(true); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/viewport/transcriptNavigation.ts b/src/engines/ChatPanel/ChatHistory/viewport/transcriptNavigation.ts new file mode 100644 index 0000000000..07a9d63b11 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/viewport/transcriptNavigation.ts @@ -0,0 +1,43 @@ +export interface TranscriptViewportAnchor { + itemId: string; + offsetFromViewportTop: number; +} + +/** Geometry belongs to the committed list; reader intent belongs to the viewport. */ +export type TranscriptNavigationGeometry = + | { status: "missing" } + | { status: "pending"; scrollTop?: number } + | { + status: "measured"; + revision: number; + scrollTop: number; + anchor: TranscriptViewportAnchor; + }; + +export type TranscriptNavigationEnd = + | "settled" + | "superseded" + | "user" + | "scope" + | "missing" + | "follow" + | "unmount"; + +export interface TranscriptNavigationTarget { + id: string; + /** Destination page. Expansion changes layout, not this scope. */ + scopeKey: string; + readGeometry: () => TranscriptNavigationGeometry; + onEnd?: (reason: TranscriptNavigationEnd) => void; +} + +export type BeginTranscriptNavigation = ( + target: TranscriptNavigationTarget +) => number; + +export function chatNavigationScopeKey( + sessionId: string | null, + pageIndex: number | null +): string { + return JSON.stringify([sessionId, pageIndex]); +} diff --git a/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts b/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts index 1b1692c2cd..4ed9b7fe00 100644 --- a/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts +++ b/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts @@ -7,6 +7,12 @@ import { useState, } from "react"; +import type { + BeginTranscriptNavigation, + TranscriptNavigationEnd, + TranscriptNavigationTarget, + TranscriptViewportAnchor, +} from "./transcriptNavigation"; import { INITIAL_TRANSCRIPT_VIEWPORT_POLICY_STATE, type TranscriptFollowMode, @@ -30,13 +36,11 @@ const KEYBOARD_SCROLL_KEYS = new Set([ const KEYBOARD_LINE_DELTA_PX = 40; const KEYBOARD_PAGE_DELTA_RATIO = 0.9; -export interface TranscriptViewportAnchor { - itemId: string; - offsetFromViewportTop: number; -} +export type { TranscriptViewportAnchor } from "./transcriptNavigation"; export interface UseTranscriptViewportOptions { sessionKey: string | null; + navigationScopeKey?: string; contentKey: string; itemCount: number; /** Distance intentionally retained between the content tail and scroll bottom. */ @@ -57,13 +61,15 @@ export interface UseTranscriptViewportReturn { handleScroll: (reportedAtTail?: boolean) => void; followTail: () => void; detachForNavigation: () => void; + beginNavigation: BeginTranscriptNavigation; + isNavigating: () => boolean; preserveForLayoutMutation: () => void; /** * Apply the follow/anchor policy to the layout that is about to paint. * For content owners that commit geometry outside the observed elements * (a virtualizer flushing re-measured rows from its own ResizeObserver). */ - reconcileLayout: () => void; + reconcileLayout: (duringReactCommit?: boolean) => void; showScrollToBottom: boolean; mode: TranscriptFollowMode; } @@ -209,6 +215,7 @@ export function restoreTranscriptAnchor( */ export function useTranscriptViewport({ sessionKey, + navigationScopeKey = sessionKey ?? "", contentKey, itemCount, tailGapPx = 0, @@ -234,6 +241,11 @@ export function useTranscriptViewport({ const anchorRef = useRef(null); const sessionKeyRef = useRef(sessionKey); const generationRef = useRef(0); + const navigationRef = useRef<{ + generation: number; + target: TranscriptNavigationTarget; + } | null>(null); + const navigationScopeRef = useRef(navigationScopeKey); const pendingFrameRef = useRef(null); const anchorRevealAttemptsRef = useRef(0); const lastAtTailRef = useRef(true); @@ -288,6 +300,19 @@ export function useTranscriptViewport({ pendingFrameRef.current = null; }, []); + const endNavigation = useCallback( + (reason: TranscriptNavigationEnd) => { + const navigation = navigationRef.current; + navigationRef.current = null; + if (!navigation) return; + generationRef.current += 1; + cancelPendingFrame(); + navigation.target.onEnd?.(reason); + }, + [cancelPendingFrame] + ); + const isNavigating = useCallback(() => navigationRef.current !== null, []); + const reconcileRef = useRef<() => void>(() => undefined); const scheduleReconcile = useCallback(() => { if (pendingFrameRef.current !== null) return; @@ -295,8 +320,8 @@ export function useTranscriptViewport({ if (!element || !isElementVisible(element)) return; const scheduledGeneration = generationRef.current; pendingFrameRef.current = requestAnimationFrame(() => { - pendingFrameRef.current = null; if (scheduledGeneration !== generationRef.current) return; + pendingFrameRef.current = null; reconcileRef.current(); }); }, []); @@ -305,6 +330,40 @@ export function useTranscriptViewport({ const element = scrollRootRef.current; if (!element || !isElementVisible(element)) return; const currentOptions = optionsRef.current; + const navigation = navigationRef.current; + if (navigation) { + if (navigation.target.scopeKey !== navigationScopeRef.current) return; + const geometry = navigation.target.readGeometry(); + if ( + navigationRef.current !== navigation || + navigation.generation !== generationRef.current + ) + return; + if (geometry.status === "missing") { + endNavigation("missing"); + return; + } + if (geometry.scrollTop === undefined) return; + const top = Math.max( + 0, + Math.min( + element.scrollHeight - element.clientHeight, + geometry.scrollTop + ) + ); + if (Math.abs(element.scrollTop - top) > 1) { + const previousTop = element.scrollTop; + element.scrollTo({ top, behavior: "auto" }); + // A refused/no-op scroll waits for layout; it must not start a frame loop. + if (element.scrollTop !== previousTop) scheduleReconcile(); + } else if (geometry.status === "measured") { + anchorRef.current = geometry.anchor; + anchorRevealAttemptsRef.current = 0; + endNavigation("settled"); + publishAtTail(isTranscriptAtTail(element, currentOptions.tailGapPx)); + } + return; + } const shouldFollow = currentOptions.followPolicy === "always" || modeRef.current === "following_tail"; @@ -321,6 +380,7 @@ export function useTranscriptViewport({ const anchor = anchorRef.current; if (!anchor) { + if (userScrollPendingRef.current) return; anchorRef.current = captureTranscriptAnchor(element); return; } @@ -338,15 +398,22 @@ export function useTranscriptViewport({ anchorRevealAttemptsRef.current += 1; scheduleReconcile(); } - }, [publishAtTail, scheduleReconcile]); + }, [endNavigation, publishAtTail, scheduleReconcile]); useLayoutEffect(() => { reconcileRef.current = reconcile; }, [reconcile]); - const reconcileLayout = useCallback(() => { - cancelPendingFrame(); - reconcileRef.current(); - }, [cancelPendingFrame]); + const reconcileLayout = useCallback( + (duringReactCommit = false) => { + if (duringReactCommit) { + scheduleReconcile(); + return; + } + cancelPendingFrame(); + reconcileRef.current(); + }, + [cancelPendingFrame, scheduleReconcile] + ); const handleScroll = useCallback( (reportedAtTail?: boolean) => { @@ -361,6 +428,10 @@ export function useTranscriptViewport({ userScrollPendingRef.current || scrollbarPointerActiveRef.current || touchScrollActiveRef.current; + if (navigationRef.current && !userInitiated) { + scheduleReconcile(); + return; + } if (userInitiated && optionsRef.current.followPolicy !== "always") { transition({ type: "user_scroll", atTail }); } @@ -371,24 +442,45 @@ export function useTranscriptViewport({ userScrollPendingRef.current = false; } }, - [publishAtTail, transition] + [publishAtTail, scheduleReconcile, transition] ); const followTail = useCallback(() => { + endNavigation("follow"); anchorRef.current = null; optionsRef.current.onExplicitFollow?.(); transition({ type: "explicit_follow" }); scheduleReconcile(); - }, [scheduleReconcile, transition]); + }, [endNavigation, scheduleReconcile, transition]); const detachForNavigation = useCallback(() => { + endNavigation("scope"); if (optionsRef.current.followPolicy === "always") return; transition({ type: "explicit_navigation" }); anchorRef.current = null; scheduleReconcile(); - }, [scheduleReconcile, transition]); + }, [endNavigation, scheduleReconcile, transition]); + + const beginNavigation = useCallback( + (target) => { + endNavigation("superseded"); + cancelPendingFrame(); + const generation = ++generationRef.current; + navigationRef.current = { generation, target }; + anchorRef.current = null; + userScrollPendingRef.current = false; + transition({ type: "explicit_navigation" }); + scheduleReconcile(); + return generation; + }, + [cancelPendingFrame, endNavigation, scheduleReconcile, transition] + ); const preserveForLayoutMutation = useCallback(() => { + if (navigationRef.current) { + scheduleReconcile(); + return; + } if (optionsRef.current.followPolicy === "always") return; const element = scrollRootRef.current; anchorRef.current = element ? captureTranscriptAnchor(element) : null; @@ -398,6 +490,7 @@ export function useTranscriptViewport({ useLayoutEffect(() => { if (sessionKeyRef.current === sessionKey) return; + endNavigation("scope"); sessionKeyRef.current = sessionKey; generationRef.current += 1; cancelPendingFrame(); @@ -414,12 +507,24 @@ export function useTranscriptViewport({ scheduleReconcile(); }, [ cancelPendingFrame, + endNavigation, localSubmitKey, scheduleReconcile, sessionKey, transition, ]); + useLayoutEffect(() => { + if (navigationScopeRef.current !== navigationScopeKey) { + navigationScopeRef.current = navigationScopeKey; + if (navigationRef.current?.target.scopeKey !== navigationScopeKey) { + endNavigation("scope"); + anchorRef.current = null; + } + } + scheduleReconcile(); + }, [endNavigation, navigationScopeKey, scheduleReconcile]); + useLayoutEffect(() => { if (itemCount <= 0) return; scheduleReconcile(); @@ -438,6 +543,11 @@ export function useTranscriptViewport({ if (!scrollRoot) return; const markUserScroll = () => { + if (navigationRef.current) { + endNavigation("user"); + anchorRef.current = null; + transition({ type: "user_scroll", atTail: false }); + } userScrollPendingRef.current = true; }; const handleWheel = (event: WheelEvent) => { @@ -564,7 +674,7 @@ export function useTranscriptViewport({ window.addEventListener("pointercancel", handlePointerUp); document.addEventListener("visibilitychange", handleVisibilityChange); - const resizeObserver = new ResizeObserver(reconcileLayout); + const resizeObserver = new ResizeObserver(() => reconcileLayout()); resizeObserver.observe(scrollRoot); if (scrollRoot.firstElementChild) { resizeObserver.observe(scrollRoot.firstElementChild); @@ -586,6 +696,7 @@ export function useTranscriptViewport({ }; }, [ cancelPendingFrame, + endNavigation, followTail, handleScroll, reconcileLayout, @@ -596,10 +707,11 @@ export function useTranscriptViewport({ useEffect( () => () => { + endNavigation("unmount"); generationRef.current += 1; cancelPendingFrame(); }, - [cancelPendingFrame] + [cancelPendingFrame, endNavigation] ); return { @@ -607,6 +719,8 @@ export function useTranscriptViewport({ handleScroll, followTail, detachForNavigation, + beginNavigation, + isNavigating, preserveForLayoutMutation, reconcileLayout, showScrollToBottom: diff --git a/tests/e2e/specs/core/agent-org-root-group-follow-up-ui.spec.mjs b/tests/e2e/specs/core/agent-org-root-group-follow-up-ui.spec.mjs index bc01d3cec1..5aa859bf9f 100644 --- a/tests/e2e/specs/core/agent-org-root-group-follow-up-ui.spec.mjs +++ b/tests/e2e/specs/core/agent-org-root-group-follow-up-ui.spec.mjs @@ -1,4 +1,4 @@ -/* global describe, before, afterEach, it, browser */ +/* global describe, before, afterEach, it, browser, process */ import { captureTerminalFailure, runFormalTerminalScenario, @@ -50,6 +50,41 @@ async function openExactExecutionDetails( marker, { requireDistantTarget = false } = {} ) { + // Reopening loads only the newest Group page. Reach the reply through the + // rendered paging control before asserting its execution navigation. + for (let page = 0; page < 10; page++) { + let ready; + await browser.waitUntil( + async () => { + ready = await execJS(` + const rows = Array.from(document.querySelectorAll('[data-testid="agent-org-group-projection-item"]')); + const found = rows.some(row => row.getAttribute('data-item-kind') === 'assistant_reply' && + (row.textContent || '').includes(${JSON.stringify(marker)})); + const older = document.querySelector('[data-testid="agent-org-group-projection-load-older"]'); + return { found, count: rows.length, canLoad: Boolean(older && !older.disabled) }; + `); + return ready.found || ready.canLoad; + }, + { + timeout: REPLY_TIMEOUT_MS, + timeoutMsg: `Group reply ${marker} is unavailable`, + } + ); + if (ready.found) break; + await ( + await browser.$('[data-testid="agent-org-group-projection-load-older"]') + ).click(); + await browser.waitUntil( + async () => + (await execJS( + `return document.querySelectorAll('[data-testid="agent-org-group-projection-item"]').length;` + )) > ready.count, + { + timeout: REPLY_TIMEOUT_MS, + timeoutMsg: `Older Group page did not load for ${marker}`, + } + ); + } const targetLookup = await execJS(` const row = Array.from(document.querySelectorAll('[data-testid="agent-org-group-projection-item"]')) .find(row => row.getAttribute('data-item-kind') === 'assistant_reply' && (row.textContent || '').includes(${JSON.stringify(marker)})); @@ -134,36 +169,101 @@ async function openExactExecutionDetails( return Array.from(document.querySelectorAll('[data-testid="turn-collapse-toggle"][aria-expanded="false"]')).length; `); if (collapsedRounds < 1) { - throw new Error("Dense private history did not exercise a collapsed round"); + throw new Error( + "Dense private history did not exercise a collapsed round" + ); } + const oldestMounted = await execJS(`return Boolean(document.querySelector( + '[data-testid="chat-history-scroll-container"] [data-chat-group-index="0"]'));`); + if (oldestMounted) + throw new Error("Oldest turn was mounted before distant navigation"); await (await browser.$('[aria-label^="Go to turn 1 of "]')).click(); - let oldestVisibleSince = null; + await waitForPrivateTurn(0); + const toggle = await browser.$( + '[data-chat-group-index="0"] [data-testid="turn-collapse-toggle"]' + ); + if (!(await toggle.isExisting())) + throw new Error("Oldest turn has no rendered collapse control"); + await toggle.click(); + await waitForPrivateTurn(0); + const originalSize = await browser.getWindowSize(); + try { + await browser.setWindowSize(900, 760); + await waitForPrivateTurn(0); + // Two real clicks exercise supersession without an intervening settle wait. + const markers = await browser.$$('[aria-label^="Go to turn "]'); + const last = markers[markers.length - 1]; + await last.click(); + await (await browser.$('[aria-label^="Go to turn 1 of "]')).click(); + await waitForPrivateTurn(0); + } finally { + await browser.setWindowSize(originalSize.width, originalSize.height); + } + // Search must expand a message omitted by the collapsed row projection. + await ( + await browser.$('[data-testid="chat-panel-header-more-button"]') + ).click(); + await ( + await browser.$('//*[@role="menuitem" and contains(., "Find in chat")]') + ).click(); + const searchInput = await browser.$("[data-find-card] input"); + await searchInput.waitForExist(); + await searchInput.click(); + // This WebDriver sets el.value directly, which React's value tracker ignores. + // Enter text through the rendered input's native setter and input event. + await execJS(` + const input = document.querySelector('[data-find-card] input'); + if (!(input instanceof HTMLInputElement)) throw new Error('Search input missing'); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'history 32 short'); + input.dispatchEvent(new Event('input', { bubbles: true })); + `); await browser.waitUntil( - async () => { - const visible = await execJS(` + async () => (await searchInput.getValue()) === "history 32 short", + { timeout: 10_000, timeoutMsg: "Search input did not accept typed text" } + ); + await waitForPrivateTurn( + 36, + `E2E Idle Root follow-up ${RUN_ID} history 32 short` + ); + const expanded = await browser.$( + '[data-chat-group-index="36"] [data-testid="turn-collapse-toggle"]' + ); + if ((await expanded.getAttribute("aria-expanded")) !== "true") + throw new Error("Search did not expand its exact hidden message turn"); + await browser.keys("Escape"); + } + await openRenderedGroupChatView(); + await waitForRenderedGroupChatActive("return from exact execution details"); +} + +async function waitForPrivateTurn(groupIndex, searchText = null) { + let oldestVisibleSince = null; + await browser.waitUntil( + async () => { + const visible = await execJS(` const scroller = document.querySelector('[data-testid="chat-history-scroll-container"]'); - const oldest = scroller?.querySelector('[data-chat-group-index="0"]'); + const group = scroller?.querySelector('[data-chat-group-index="${groupIndex}"]'); + const searchText = ${JSON.stringify(searchText)}; + const oldest = searchText + ? Array.from(group?.querySelectorAll('[data-chat-event-ids]') ?? []).find(node => node.textContent.includes(searchText)) + : group; if (!scroller || !oldest) return false; const root = scroller.getBoundingClientRect(); const target = oldest.getBoundingClientRect(); return target.height > 0 && target.top >= root.top - 2 && target.top < root.bottom; `); - if (!visible) oldestVisibleSince = null; - else oldestVisibleSince ??= Date.now(); - return ( - oldestVisibleSince !== null && Date.now() - oldestVisibleSince >= 500 - ); - }, - { - timeout: REPLY_TIMEOUT_MS, - interval: 100, - timeoutMsg: - "Oldest private turn did not remain visible after minimap navigation", - } - ); - } - await openRenderedGroupChatView(); - await waitForRenderedGroupChatActive("return from exact execution details"); + if (!visible) oldestVisibleSince = null; + else oldestVisibleSince ??= Date.now(); + return ( + oldestVisibleSince !== null && Date.now() - oldestVisibleSince >= 500 + ); + }, + { + timeout: REPLY_TIMEOUT_MS, + interval: 100, + timeoutMsg: `Private turn ${groupIndex} did not remain visible after navigation`, + } + ); } describe("Agent Org Root Group follow-up rendered UI", () => { From ed37c37a3c7c07118936da6977d2545c0ccc5db6 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sun, 27 Sep 2026 00:58:44 +0800 Subject: [PATCH 2/2] refactor(chat): split viewport input helpers Keep the transcript viewport entry point under the changed-file length limit by moving its unchanged keyboard, wheel, and scrollbar predicates into a focused input helper module. Verification: - changed-file length check passed for 15 TypeScript files - 3 focused Vitest files, 37 tests passed - pnpm typecheck passed - pnpm lint passed - pnpm check:circular passed (8146 modules) - pnpm check:test-placement passed (635 directories) Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../viewport/transcriptViewportInput.ts | 65 ++++++++++++++++++ .../viewport/useTranscriptViewport.ts | 67 +++---------------- 2 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 src/engines/ChatPanel/ChatHistory/viewport/transcriptViewportInput.ts diff --git a/src/engines/ChatPanel/ChatHistory/viewport/transcriptViewportInput.ts b/src/engines/ChatPanel/ChatHistory/viewport/transcriptViewportInput.ts new file mode 100644 index 0000000000..e19f9816f5 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/viewport/transcriptViewportInput.ts @@ -0,0 +1,65 @@ +export const KEYBOARD_SCROLL_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + " ", + "Spacebar", +]); + +export const KEYBOARD_LINE_DELTA_PX = 40; +export const KEYBOARD_PAGE_DELTA_RATIO = 0.9; + +// Descendant controls own activation and navigation keys before transcript scrolling. +export function isInteractiveKeyboardTarget( + target: EventTarget | null +): boolean { + return ( + target instanceof Element && + target.closest( + "input, textarea, select, button, a[href], summary, [contenteditable='true'], [role='textbox'], [role='button'], [role='slider'], [role='tab'], [role='menuitem']" + ) !== null + ); +} + +/** A descendant owns wheel intent while it can scroll in that direction. */ +export function descendantOwnsWheel( + event: WheelEvent, + root: HTMLElement +): boolean { + let element = event.target instanceof Element ? event.target : null; + while (element && element !== root) { + if (element.scrollHeight > element.clientHeight) { + const style = getComputedStyle(element); + if (style.overflowY === "auto" || style.overflowY === "scroll") { + const canScroll = + event.deltaY < 0 + ? element.scrollTop > 0 + : element.scrollTop + element.clientHeight < element.scrollHeight; + if ( + canScroll || + style.overscrollBehaviorY === "contain" || + style.overscrollBehaviorY === "none" + ) { + return true; + } + } + } + element = element.parentElement; + } + return false; +} + +export function isScrollbarPointerDown( + event: PointerEvent, + element: HTMLElement +): boolean { + if (event.button !== 0) return false; + const rect = element.getBoundingClientRect(); + const nativeScrollbarWidth = Math.max( + 0, + element.offsetWidth - element.clientWidth + ); + return event.clientX >= rect.right - Math.max(12, nativeScrollbarWidth); +} diff --git a/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts b/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts index 4ed9b7fe00..374f727432 100644 --- a/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts +++ b/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts @@ -13,6 +13,14 @@ import type { TranscriptNavigationTarget, TranscriptViewportAnchor, } from "./transcriptNavigation"; +import { + KEYBOARD_LINE_DELTA_PX, + KEYBOARD_PAGE_DELTA_RATIO, + KEYBOARD_SCROLL_KEYS, + descendantOwnsWheel, + isInteractiveKeyboardTarget, + isScrollbarPointerDown, +} from "./transcriptViewportInput"; import { INITIAL_TRANSCRIPT_VIEWPORT_POLICY_STATE, type TranscriptFollowMode, @@ -24,17 +32,6 @@ export const TRANSCRIPT_ANCHOR_ATTRIBUTE = "data-transcript-anchor-id"; const AT_TAIL_EPSILON_PX = 4; const MAX_ANCHOR_REVEAL_ATTEMPTS = 2; -const KEYBOARD_SCROLL_KEYS = new Set([ - "ArrowUp", - "ArrowDown", - "PageUp", - "PageDown", - "Home", - " ", - "Spacebar", -]); -const KEYBOARD_LINE_DELTA_PX = 40; -const KEYBOARD_PAGE_DELTA_RATIO = 0.9; export type { TranscriptViewportAnchor } from "./transcriptNavigation"; @@ -74,54 +71,6 @@ export interface UseTranscriptViewportReturn { mode: TranscriptFollowMode; } -// Descendant controls own activation and navigation keys before transcript scrolling. -function isInteractiveKeyboardTarget(target: EventTarget | null): boolean { - return ( - target instanceof Element && - target.closest( - "input, textarea, select, button, a[href], summary, [contenteditable='true'], [role='textbox'], [role='button'], [role='slider'], [role='tab'], [role='menuitem']" - ) !== null - ); -} - -/** A descendant owns wheel intent while it can scroll in that direction. */ -function descendantOwnsWheel(event: WheelEvent, root: HTMLElement): boolean { - let element = event.target instanceof Element ? event.target : null; - while (element && element !== root) { - if (element.scrollHeight > element.clientHeight) { - const style = getComputedStyle(element); - if (style.overflowY === "auto" || style.overflowY === "scroll") { - const canScroll = - event.deltaY < 0 - ? element.scrollTop > 0 - : element.scrollTop + element.clientHeight < element.scrollHeight; - if ( - canScroll || - style.overscrollBehaviorY === "contain" || - style.overscrollBehaviorY === "none" - ) { - return true; - } - } - } - element = element.parentElement; - } - return false; -} - -function isScrollbarPointerDown( - event: PointerEvent, - element: HTMLElement -): boolean { - if (event.button !== 0) return false; - const rect = element.getBoundingClientRect(); - const nativeScrollbarWidth = Math.max( - 0, - element.offsetWidth - element.clientWidth - ); - return event.clientX >= rect.right - Math.max(12, nativeScrollbarWidth); -} - function isElementVisible(element: HTMLElement): boolean { return document.visibilityState !== "hidden" && element.clientHeight > 0; }