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 e73303f126..e598083a0f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts @@ -236,6 +236,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 cf7c6729a7..38f59804c3 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/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 1b1692c2cd..374f727432 100644 --- a/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts +++ b/src/engines/ChatPanel/ChatHistory/viewport/useTranscriptViewport.ts @@ -7,6 +7,20 @@ import { useState, } from "react"; +import type { + BeginTranscriptNavigation, + TranscriptNavigationEnd, + 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, @@ -18,25 +32,12 @@ 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 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,65 +58,19 @@ 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; } -// 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; } @@ -209,6 +164,7 @@ export function restoreTranscriptAnchor( */ export function useTranscriptViewport({ sessionKey, + navigationScopeKey = sessionKey ?? "", contentKey, itemCount, tailGapPx = 0, @@ -234,6 +190,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 +249,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 +269,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 +279,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 +329,7 @@ export function useTranscriptViewport({ const anchor = anchorRef.current; if (!anchor) { + if (userScrollPendingRef.current) return; anchorRef.current = captureTranscriptAnchor(element); return; } @@ -338,15 +347,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 +377,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 +391,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 +439,7 @@ export function useTranscriptViewport({ useLayoutEffect(() => { if (sessionKeyRef.current === sessionKey) return; + endNavigation("scope"); sessionKeyRef.current = sessionKey; generationRef.current += 1; cancelPendingFrame(); @@ -414,12 +456,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 +492,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 +623,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 +645,7 @@ export function useTranscriptViewport({ }; }, [ cancelPendingFrame, + endNavigation, followTail, handleScroll, reconcileLayout, @@ -596,10 +656,11 @@ export function useTranscriptViewport({ useEffect( () => () => { + endNavigation("unmount"); generationRef.current += 1; cancelPendingFrame(); }, - [cancelPendingFrame] + [cancelPendingFrame, endNavigation] ); return { @@ -607,6 +668,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", () => {