From 842e4e00770a4e2ebdd8528f8f42a7768ed9297a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:06:24 -0800 Subject: [PATCH] perf(native-chat): draw only the visible rows of an opened tool run (#41552) * perf(native-chat): draw only the visible rows of an opened tool run An opened tool run rendered every call it held, so a long autonomous stretch kept thousands of rows, their DOM and their per-row state alive while only a dozen were visible in the run's capped box. The run now owns a keyed window over its rows (the shared TanStack virtualizer plus the existing row+offset scroll anchor): - rows keep today's keys and order; small runs and Find-with-a-query draw every row through the same keyed tree, so nothing remounts at the switch - the reader is remembered as a row and offset (pixel top while unmeasured), following the end only for new work, and forgetting on close as before - the box's cap keeps the measured height of opened details, including ones scrolled out of view; the full task list now remembers its open state - the first/last rows, a diff reveal target, recently interacted rows, the focused row's Tab neighbours and a text selection's span stay drawn Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): finish tool-run follow, keep reveals, bound per-move work Review repairs for the windowed tool run: - a follow started by new work always gets its completion frame, so a result that adds no row cannot leave it armed to chase later detail growth - the row anchor restores only when rows arrive or the pane shows again, so a diff reveal or the reader's own scrolling is not pulled back - opened-detail room is kept incrementally and travels with the run's remembered position across outer windowing - focus neighbours, item keys and window rows no longer cost work per move proportional to the run's length; selection is tracked only while visible - thoughts with no words are not keyboard stops; the restored row stays drawn until the reader moves Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): finish hidden follows, let reveals win, keep draw-all scrolling free Second review repair for the windowed tool run: - a follow is certified only by a real, visible tail measurement, and that measurement wakes it even when no size changed; a hidden pane resumes it - a diff reveal cancels any pending restore and follow on every request - a hidden pane returns to its row (not its reset pixel offset) on show - draw-every-row mode (Find, short runs) rebuilds nothing as the reader scrolls; selection spans re-derive after the pane was hidden - remembered detail heights are copied only when one changes Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): certify a tool-run follow only by a laid-out tail A follow's landing is confirmed only when the last row that draws something has a real, non-zero size; a measurement callback carrying a cached or zero size no longer ends it early, and a run whose rows draw nothing has no landing to wait for. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): keep draw-all tool runs cheap per scroll and prove follow wake paths Draw-all (Find) no longer records the scroll anchor or rebuilds the selection span on every scroll, and its trailing spacer skips the end scan. Tests now cover a task list crossing the window threshold and Find, and pin both follow-completion paths. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): skip anchor scans under a wide selection and recover the reader's row after A selection wider than a window keeps its rows drawn, so recording the anchor on each scroll would scan them all; recording now waits, as it does under Find. The reader's row is then taken from the virtualizer's offsets when recording resumes or the pane hides, so showing it again does not return to an older row. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): type the tool-run selection-span gate and settle the anchor on hide Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): merge duplicate tool-run window import Co-Authored-By: Claude Opus 5.5 (1M context) * chore(native-chat): prune the structured session's compiler bailout after the main sync Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): keep the tool-run measurement callback current without a render-time ref write Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): keep a reader who jumps past an open run's drawn rows where they jumped The window redraws inside the scroll that moves it, and a restore still pending from earlier work could run first and send the reader back to the row they left. The reader's own scroll now points the anchor at their row from the window's offsets before that redraw. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): re-point the anchor on reader scrolls only while the run is windowed Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): keep a hidden tool run's saved row from its box's reset offset A hidden box resets its offset and may report that as a scroll or as the offset at unmount. Scroll recording (the run's and the anchor hook's) and the hook's unmount record now wait while the pane is hidden, so the row kept at hide time is the one a returning reader lands on. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): give an opened tool run sole ownership of its reader's place The run and the shared scroll-anchor hook each decided when a saved row still owned the box, and each local exclusion left another path for a stale restore or capture. The run now keeps one row-and-offset place from the window's full measurement model, admits a landing only on mount or show, lets every reader scroll retire pending work first, records nothing while hidden, and takes a reveal before any landing. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): confirm every tool-run landing correction and model the browser's scroll report in tests A real change (size, row list, the held row's measurement) now grants a landing a fresh, bounded confirmation budget, so a correction made after an earlier budget ran out is still confirmed. A pending landing's own writes no longer re-arm following. The reader harness delivers the scroll event a browser sends after an offset change. Co-Authored-By: Claude Opus 5.5 (1M context) * test(native-chat): keep the reveal-return case on one memory record and before the reveal's scroll report Co-Authored-By: Claude Opus 5.5 (1M context) * test(native-chat): return the reader harness's scroll writes to their earlier behaviour (rig-stop ruling) Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): keep a revealed file's own row as the tool run's saved place A reveal now records the card's row and offset from the page as the card aligns, rather than reading the window's size model, which may still hold that row's collapsed height. Remount tests report the landing's own scroll the way a browser would; the pinned reveal case needs no report. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(native-chat): read an opened tool run's reader place from the rows actually drawn The window's size model can hold a row's old height for a frame after it grows, so a scroll report read through it could record a later row than the reader sees. Capture now takes the row at the box top from the drawn rows' own geometry, found by a binary search over the run's in-flow children, and uses the model only where the top lies in an undrawn gap; a box with no geometry keeps the last place. Co-Authored-By: Claude Opus 5.5 (1M context) * test(native-chat): type the reader-position owner test's rows without assertions Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- config/react-compiler-bailout-baseline.txt | 1 + config/react-compiler-uncompiled-files.txt | 2 + .../native-chat/NativeChatResolvedView.tsx | 25 +- .../NativeChatStructuredSession.tsx | 59 +- .../native-chat-disclosure-store.ts | 12 +- .../tools/runs/NativeChatToolRun.tsx | 25 +- ...iveChatToolRunMemberList.identity.test.tsx | 2 + .../runs/NativeChatToolRunMemberList.tsx | 122 ++-- .../NativeChatToolRunMembers.reader.test.tsx | 667 ++++++++++++++++++ .../tools/runs/NativeChatToolRunMembers.tsx | 353 +++++++-- .../NativeChatToolRunMembers.window.test.tsx | 146 ++++ .../tools/runs/native-chat-tool-run-rows.ts | 123 ++++ .../native-chat-tool-run-window-context.tsx | 33 + .../use-native-chat-tool-run-detail-room.ts | 80 +++ .../runs/use-native-chat-tool-run-pins.ts | 200 ++++++ ...ive-chat-tool-run-reader-position.test.tsx | 228 ++++++ ...se-native-chat-tool-run-reader-position.ts | 341 +++++++++ .../runs/use-native-chat-tool-run-window.ts | 106 +++ .../tools/tasks/NativeChatTaskList.tsx | 9 +- 19 files changed, 2366 insertions(+), 168 deletions(-) create mode 100644 src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.reader.test.tsx create mode 100644 src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.window.test.tsx create mode 100644 src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-rows.ts create mode 100644 src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-window-context.tsx create mode 100644 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-detail-room.ts create mode 100644 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-pins.ts create mode 100644 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.test.tsx create mode 100644 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.ts create mode 100644 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 1c84381c535..f8205003fc2 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -250,6 +250,7 @@ 2 src/renderer/src/components/native-chat/session/terminal/read/use-native-chat-live-session.ts 3 src/renderer/src/components/native-chat/session/terminal/read/use-native-chat-retained-session.ts 1 src/renderer/src/components/native-chat/tools/lines/NativeChatToolOutput.tsx +1 src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts 1 src/renderer/src/components/native-chat/usage/NativeChatAccountUsageCard.tsx 2 src/renderer/src/components/native-chat/usage/use-native-chat-account-usage.ts 1 src/renderer/src/components/native-chat/viewport/find/use-native-chat-find.ts diff --git a/config/react-compiler-uncompiled-files.txt b/config/react-compiler-uncompiled-files.txt index 7c7b729df86..3eb96f759b9 100644 --- a/config/react-compiler-uncompiled-files.txt +++ b/config/react-compiler-uncompiled-files.txt @@ -28,6 +28,8 @@ src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-s src/renderer/src/components/editor/markdown-preview/search/use-markdown-preview-search-reveal.ts src/renderer/src/components/editor/markdown-preview/viewport/use-markdown-preview-scroll-anchor.ts src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx +src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.tsx +src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts src/renderer/src/components/right-sidebar/file-explorer/panel/FileExplorerVirtualRows.tsx src/renderer/src/components/right-sidebar/file-explorer/tree/FileExplorerFilesTreePane.tsx src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 959a59b78a8..678bb28d302 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -10,6 +10,7 @@ import { useNativeChatLaunchDraftSignal } from './drafts/use-native-chat-launch- import { useNativeChatRetainedSession } from './session/terminal/read/use-native-chat-retained-session' import { selectNativeChatViewState } from './session/status/native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' +import { NativeChatToolRunWindowProvider } from './tools/runs/native-chat-tool-run-window-context' import { useNativeChatInteractiveSendReveal, useNativeChatRevealLatest @@ -349,17 +350,19 @@ export function NativeChatResolvedView({ ) : viewState.kind === 'empty' ? ( ) : ( - + + + )} {/* A collapsed card stays mounted but hidden, so a partly answered question survives. */} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 16d82549415..2ee97beb567 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -20,6 +20,7 @@ import { NativeChatEmptyState } from '@/components/native-chat/host-access/Nativ import { NativeChatEmptyPane } from '@/components/native-chat/host-access/NativeChatEmptyPane' import { NativeChatLoadingCue } from '@/components/native-chat/host-access/NativeChatLoadingCue' import { NativeChatMessageList } from '@/components/native-chat/NativeChatMessageList' +import { NativeChatToolRunWindowProvider } from '@/components/native-chat/tools/runs/native-chat-tool-run-window-context' import { NativeChatContextClearFade } from '@/components/native-chat/conversation/projection/NativeChatContextClearFade' import { useStructuredNativeChatSubmitReveal } from '@/components/native-chat/pane/actions/use-structured-native-chat-submit-reveal' import { useStructuredPromptResponseHold } from '@/components/native-chat/prompts/use-structured-prompt-response-hold' @@ -301,34 +302,36 @@ export function NativeChatStructuredSession(props: Omit - - {(timedRows) => ( - - )} - + + + {(timedRows) => ( + + )} + + diff --git a/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts b/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts index 254f3e048d7..b073fa17249 100644 --- a/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts +++ b/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts @@ -2,8 +2,16 @@ import { createContext, useCallback, useContext, useMemo, useState } from 'react' -/** Where the reader left an opened run's own scroll box. */ -export type NativeChatRunScroll = { top: number; followsEnd: boolean } +/** A reader's place in an opened run's box: a row, and how far into that row its top edge sits. */ +export type NativeChatRunPosition = { key: string; offset: number } + +/** Where the reader left an opened run's own scroll box: a row and offset, or the end. */ +export type NativeChatRunScroll = { + position: NativeChatRunPosition | null + followsEnd: boolean + /** Measured heights of details opened in rows the window no longer draws, so the box keeps their room. */ + detailHeights: ReadonlyMap +} export type NativeChatDisclosureStore = { read: (key: string) => boolean | undefined diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx index ac01158e0f2..59e4432ce34 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRun.tsx @@ -28,7 +28,7 @@ import type { } from '../../subagents/native-chat-subagent-sections' import { NativeChatToolRunIcon } from '../lines/NativeChatToolIcon' import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts' -import { NativeChatToolRunMembers, revealNativeChatToolRunMember } from './NativeChatToolRunMembers' +import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers' import { NativeChatToolRunMemberList } from './NativeChatToolRunMemberList' import { NativeChatBrowserRunPageButton } from './NativeChatBrowserPageButton' import { NativeChatImageAttachments } from '../../media/images/NativeChatImageAttachments' @@ -243,22 +243,19 @@ export function NativeChatToolRun({ className="mb-0 mt-1" /> - - - + /> ) diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.identity.test.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.identity.test.tsx index dbcbff38e64..7cd45c2fa12 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.identity.test.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.identity.test.tsx @@ -43,6 +43,8 @@ function MemberDisclosureHarness({ blocks={blocks} headerBlocks={blocks} disclosureId="message-1" + followKey={blocks.length} + startsAtEnd={false} /> ) : null} diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.tsx index 7b15187ab20..35b179b6144 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMemberList.tsx @@ -1,17 +1,15 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import type { NativeChatBlock, NativeChatToolCallBlock } from '../../../../../../shared/native-chat/native-chat-types' -import { pairNativeChatToolResults } from '../../../../../../shared/native-chat/tools/native-chat-tool-pairing' -import { nativeChatToolLineIdentity } from '../../../../../../shared/native-chat/tools/native-chat-tool-line-identity' import { NativeChatDiffCard } from '../diffs/NativeChatDiffCard' import { NativeChatTaskList } from '../tasks/NativeChatTaskList' import { NativeChatToolLine } from '../lines/NativeChatToolLine' -import { buildEditCards } from '../../../../../../shared/native-chat/tools/native-chat-edit-cards' -import { buildNativeChatTaskListRows } from '../../../../../../shared/native-chat/input/native-chat-task-list-history' import type { NativeChatDiffReveal } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs' import type { NativeChatToolRunAsides } from './NativeChatToolRun' +import { buildNativeChatToolRunRows, type NativeChatToolRunRow } from './native-chat-tool-run-rows' +import { NativeChatToolRunMembers } from './NativeChatToolRunMembers' /** Mount only while expanded so collapsed runs avoid member pairing and diff work. */ export function NativeChatToolRunMemberList({ @@ -22,7 +20,10 @@ export function NativeChatToolRunMemberList({ revealedDiff, onRevealDiff, disclosureId, - asides + asides, + followKey, + startsAtEnd, + memoryKey }: { /** Every tool block of the run; `headerBlocks` is those minus its asks. */ blocks: NativeChatBlock[] @@ -34,40 +35,44 @@ export function NativeChatToolRunMemberList({ revealedDiff?: NativeChatDiffReveal onRevealDiff?: (element: HTMLElement) => void disclosureId?: string + followKey: number + startsAtEnd: boolean + memoryKey?: string }): React.JSX.Element { - const taskLists = useMemo( + // One flat keyed row list with or without asides, so a row gaining its first never remounts a line. + const rows = useMemo( () => - buildNativeChatTaskListRows(blocks, { - todowrite: previousTodoWrite, - update_plan: previousUpdatePlan + buildNativeChatToolRunRows({ + blocks, + headerBlocks, + asides, + previousTodoWrite, + previousUpdatePlan }), - [blocks, previousTodoWrite, previousUpdatePlan] - ) - const { editCards, consumedResults } = useMemo(() => buildEditCards(blocks), [blocks]) - const { resultByCall, pairedResults } = useMemo( - () => pairNativeChatToolResults(headerBlocks), - [headerBlocks] + [asides, blocks, headerBlocks, previousTodoWrite, previousUpdatePlan] ) - const seen = new Map() - const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => { - const taskList = taskLists.rows.get(block) - if (taskList) { - return - } - if (taskLists.consumedResults.has(block)) { - return null - } - const edit = editCards.get(block) - if (edit) { - return ( -
- {edit.files.map((file, fileIndex) => ( + const renderRow = useCallback( + (row: NativeChatToolRunRow): React.ReactNode => { + switch (row.kind) { + case 'aside': + return row.element + case 'tasks': + return ( + + ) + case 'edit': + return row.files.map((file, fileIndex) => ( - ))} -
- ) - } - // A result its call now owns is drawn by that call's line, not as a row of its own. - if (consumedResults.has(block) || pairedResults.has(block)) { - return null - } - const lineIdentity = nativeChatToolLineIdentity(block, seen) - return ( - - ) - } + )) + case 'line': + return ( + + ) + } + }, + [disclosureId, onRevealDiff, revealedDiff] + ) - // One flat keyed list with or without asides, so a row gaining its first never remounts a line. return ( - <> - {headerBlocks.flatMap((block, blockIndex) => [ - ...(asides?.before.get(block) ?? []), - member(block, blockIndex) - ])} - {asides?.after} - + ) } diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.reader.test.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.reader.test.tsx new file mode 100644 index 00000000000..f5146e6968c --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.reader.test.tsx @@ -0,0 +1,667 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { useMemo } from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatBlock } from '../../../../../../shared/native-chat/native-chat-types' +import type { NativeChatDiffReveal } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs' +import { NativeChatToolRun } from './NativeChatToolRun' +import { NativeChatToolRunWindowContext } from './native-chat-tool-run-window-context' +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from '../../messages/disclosure/native-chat-disclosure-store' + +const BOX_HEIGHT_PX = 288 +/** Measured rows are taller than the 26px estimate, so a pixel position alone lands on the wrong row. */ +const ROW_HEIGHT_PX = 40 +/** Every row's laid-out height unless a test says otherwise. */ +const layout = { rowPx: ROW_HEIGHT_PX } +const ROW = '[data-native-chat-tool-run-row]' +/** Rows that have grown in place, by row key. */ +const grownRows = new Map() +/** A hidden pane is display:none: nothing in it has a size. */ +const pane = { hidden: false } + +const resizeObservers = new Set() +class StubResizeObserver implements ResizeObserver { + readonly targets = new Set() + constructor(readonly callback: ResizeObserverCallback) { + resizeObservers.add(this) + } + observe(target: Element): void { + this.targets.add(target) + } + unobserve(target: Element): void { + this.targets.delete(target) + } + disconnect(): void { + this.targets.clear() + resizeObservers.delete(this) + } +} + +/** Deliver what a browser's ResizeObserver would after `element` changed size. */ +function resized(element: Element, height: number): void { + for (const observer of resizeObservers) { + if (observer.targets.has(element)) { + const size = { blockSize: height, inlineSize: 400 } + const entry = { + target: element, + contentRect: element.getBoundingClientRect(), + borderBoxSize: [size], + contentBoxSize: [size], + devicePixelContentBoxSize: [size] + } satisfies ResizeObserverEntry + observer.callback([entry], observer) + } + } +} + +function rowHeight(element: Element): number { + return grownRows.get(element.getAttribute('data-native-chat-tool-run-row') ?? '') ?? layout.rowPx +} + +/** happy-dom lays nothing out: stack the run box's children by their stated heights. */ +function layOutRunBox(): void { + const heightOf = (element: Element): number => { + if (pane.hidden) { + return 0 + } + if (element.matches(ROW)) { + return rowHeight(element) + } + return element instanceof HTMLElement ? Number.parseFloat(element.style.height) || 0 : 0 + } + const box = (element: Element): HTMLElement | null => + element.closest('[data-native-chat-tool-run-members]') + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + if (pane.hidden) { + return 0 + } + if (this.hasAttribute('data-native-chat-tool-run-members')) { + return BOX_HEIGHT_PX + } + return this.matches(ROW) ? rowHeight(this) : 0 + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + return this.hasAttribute('data-native-chat-tool-run-members') ? BOX_HEIGHT_PX : 0 + }) + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + const content = this.hasAttribute('data-native-chat-tool-run-members') + ? this.firstElementChild + : null + return content ? Array.from(content.children).reduce((sum, c) => sum + heightOf(c), 0) : 0 + }) + // A browser clamps scrollTop to what the box can reach; an unclamped box would hide a wrong scroll. + const scrollTops = new WeakMap() + vi.spyOn(Element.prototype, 'scrollTop', 'get').mockImplementation(function (this: Element) { + return scrollTops.get(this) ?? 0 + }) + vi.spyOn(Element.prototype, 'scrollTop', 'set').mockImplementation(function ( + this: Element, + value: number + ) { + const max = this instanceof HTMLElement ? Math.max(0, this.scrollHeight - this.clientHeight) : 0 + scrollTops.set(this, Math.min(Math.max(0, value), max)) + }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + const scroller = box(this) + if (!scroller || pane.hidden) { + return DOMRect.fromRect({ x: 0, y: 0, width: 0, height: 0 }) + } + if (this === scroller) { + return DOMRect.fromRect({ x: 0, y: 0, width: 400, height: BOX_HEIGHT_PX }) + } + const row = this.closest(ROW) ?? this + let top = -scroller.scrollTop + for (const child of Array.from(scroller.firstElementChild?.children ?? [])) { + if (child === row) { + return DOMRect.fromRect({ x: 0, y: top, width: 400, height: heightOf(child) }) + } + top += heightOf(child) + } + return DOMRect.fromRect({ x: 0, y: 0, width: 0, height: 0 }) + }) +} + +function calls(count: number): NativeChatBlock[] { + return Array.from({ length: count }, (_, index) => ({ + type: 'tool-call' as const, + name: 'Read', + callId: `call-${index}`, + input: { file_path: `src/file-${index}.ts` } + })) +} + +const PATCH = + '*** Begin Patch\n*** Update File: first.ts\n ctx\n-was\n+now\n*** Update File: second.ts\n@@ -1,1 +1,1 @@\n-a\n+b\n*** End Patch' + +/** A long settled run with one two-file edit far from its top. */ +function editRun(count: number, editAt: number): NativeChatBlock[] { + const blocks = calls(count) + blocks.splice( + editAt, + 0, + { + type: 'tool-call', + name: 'apply_patch', + callId: 'edit-1', + input: { input: PATCH }, + state: 'completed' + }, + { type: 'tool-result', callId: 'edit-1', output: 'Success. Updated the following files' } + ) + return blocks +} + +// Stable, as a real transcript's callback is: a new one each render would make every card reveal again. +const ignoreRevealTarget = (): void => {} + +function Transcript({ + blocks, + mounted = true, + live = false, + visible = true, + find = false, + revealedDiff +}: { + blocks: NativeChatBlock[] + mounted?: boolean + live?: boolean + visible?: boolean + find?: boolean + revealedDiff?: NativeChatDiffReveal +}) { + const disclosures = useNativeChatDisclosures() + const windowFacts = useMemo(() => ({ isVisible: visible, findActive: find }), [visible, find]) + return ( + + + {mounted ? ( + + ) : null} + + + ) +} + +function membersBox(container: HTMLElement): HTMLElement { + const element = container.querySelector('[data-native-chat-tool-run-members]') + if (!element) { + throw new Error('members box did not render') + } + return element +} + +/** The row at the box's top edge and how far into it the box starts. */ +function topRow(container: HTMLElement): { key: string | null; offset: number } { + for (const row of Array.from(container.querySelectorAll(ROW))) { + const rect = row.getBoundingClientRect() + if (rect.bottom > 0) { + return { key: row.getAttribute('data-native-chat-tool-run-row'), offset: -rect.top } + } + } + return { key: null, offset: 0 } +} + +/** Deliver frames and the anchor's idle record on the fake clock: no wall-clock waits. */ +async function settle(): Promise { + for (let pass = 0; pass < 4; pass++) { + await act(async () => { + vi.advanceTimersByTime(250) + }) + } +} + +/** A drawn row's laid-out height changes, as a browser's ResizeObserver reports it. */ +async function growRow(container: HTMLElement, key: string, height: number): Promise { + const row = container.querySelector(`[data-native-chat-tool-run-row="${key}"]`) + if (!row) { + throw new Error(`row ${key} is not drawn`) + } + grownRows.set(key, height) + await act(async () => { + resized(row, height) + }) + await settle() +} + +async function scrollBox(container: HTMLElement, top: number): Promise { + const box = membersBox(container) + await act(async () => { + box.scrollTop = top + fireEvent.scroll(box) + }) + await settle() +} + +/** The browser's report of a landing's own scroll write, which this harness does not send: it may only + * let the window catch up to where the box already is, never move it. */ +async function reportLandingScroll(container: HTMLElement): Promise { + const box = membersBox(container) + const landed = box.scrollTop + await act(async () => { + fireEvent.scroll(box) + }) + await settle() + expect(box.scrollTop).toBe(landed) +} + +/** The browser's first size report for each drawn row, which it sends in the frame a row appears and this + * harness only sends when asked: rows the window drew while it believed the box was scrolling get theirs here. */ +async function reportDrawnRowSizes(container: HTMLElement): Promise { + await act(async () => { + for (const row of Array.from(container.querySelectorAll(ROW))) { + resized(row, rowHeight(row)) + } + }) + await settle() +} + +describe('NativeChatToolRun opened-run reader', () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame', 'cancelAnimationFrame'] + }) + grownRows.clear() + layout.rowPx = ROW_HEIGHT_PX + pane.hidden = false + vi.stubGlobal('ResizeObserver', StubResizeObserver) + layOutRunBox() + }) + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('returns to the same row and offset after the transcript unmounts it and work arrives', async () => { + const { container, rerender } = render() + await settle() + await scrollBox(container, 9_013) + const before = topRow(container) + expect(before.key).not.toBeNull() + expect(before.offset).toBeGreaterThan(0) + + rerender() + rerender() + // Evicted again before that return lands: the place it was landing on is what it keeps. + rerender() + rerender() + await settle() + await reportLandingScroll(container) + + const after = topRow(container) + expect(after.key).toBe(before.key) + expect(Math.abs(after.offset - before.offset)).toBeLessThanOrEqual(1) + }) + + it('keeps a revealed edit at the top over the place the reader had scrolled to', async () => { + const blocks = editRun(600, 400) + const { container, rerender } = render() + await settle() + await scrollBox(container, 2_000) + const reveal = { messageId: 'message-1', editKey: 'apply_patch:400', fileIndex: 1 } + + rerender() + await settle() + expect(topRow(container).key).toBe('edit:apply_patch:400') + + await scrollBox(container, 2_000) + rerender() + await settle() + expect(topRow(container).key).toBe('edit:apply_patch:400') + }) + + it('forgets its place when the reader closes and reopens it', async () => { + const { container } = render() + await settle() + await scrollBox(container, 9_013) + const header = container.querySelector('[data-native-chat-tool-run-state]') + if (!header) { + throw new Error('run header did not render') + } + await act(async () => { + fireEvent.click(header) + }) + await settle() + await act(async () => { + fireEvent.click(header) + }) + await settle() + expect(membersBox(container).scrollTop).toBe(0) + expect(topRow(container).key).toBe('call:call-0') + }) + + it('follows new work at the end, but not a detail growing after it lands', async () => { + const { container, rerender } = render() + await settle() + const box = membersBox(container) + const atEnd = (): boolean => box.scrollTop >= box.scrollHeight - BOX_HEIGHT_PX - 1 + expect(atEnd()).toBe(true) + + // A burst larger than the window, then a result that adds no row. + rerender() + await settle() + expect(atEnd()).toBe(true) + const withResult = [ + ...calls(260), + { type: 'tool-result' as const, callId: 'call-259', output: 'done' } + ] + rerender() + await settle() + expect(atEnd()).toBe(true) + + // In-place growth that is not new work leaves the reader where they are. + const landed = box.scrollTop + const tail = container.querySelector('[data-native-chat-tool-run-row="call:call-259"]') + if (!tail) { + throw new Error('tail row is not drawn') + } + grownRows.set('call:call-259', 400) + await act(async () => { + resized(tail, 400) + }) + await settle() + expect(box.scrollTop).toBe(landed) + + // A reader scrolled away stays put when more work arrives. + await scrollBox(container, 1_000) + rerender() + await settle() + expect(box.scrollTop).toBe(1_000) + }) + + it('lets a reveal win over a restore that has not landed yet', async () => { + const blocks = editRun(600, 400) + const { container, rerender } = render() + await settle() + await scrollBox(container, 2_000) + rerender() + rerender() + // No settling: the returning restore is still pending when the reveal arrives. + const reveal = { messageId: 'message-1', editKey: 'apply_patch:400', fileIndex: 1 } + rerender() + await settle() + expect(topRow(container).key).toBe('edit:apply_patch:400') + + // A reveal made in the same render that shows the pane again wins over the place it kept while hidden, + // and is what the run keeps even before the browser reports the reveal's scroll: hidden again at once and + // shown under the same request (so no fresh reveal), it returns to the revealed edit. + await scrollBox(container, 2_000) + pane.hidden = true + rerender( + + ) + pane.hidden = false + rerender() + pane.hidden = true + rerender( + + ) + pane.hidden = false + rerender() + await settle() + // The revealed row stays drawn while its request stands, so no scroll report is needed to see it. + expect(topRow(container).key).toBe('edit:apply_patch:400') + }) + + it('stops following when the reader scrolls before new work has settled', async () => { + const { container, rerender } = render() + await settle() + const box = membersBox(container) + rerender() + await act(async () => { + box.scrollTop = 1_000 + fireEvent.scroll(box) + }) + await settle() + expect(box.scrollTop).toBe(1_000) + }) + + it('finishes a follow that started while the pane was hidden once it shows again', async () => { + const { container, rerender } = render() + await settle() + pane.hidden = true + rerender() + rerender() + await settle() + pane.hidden = false + rerender() + await settle() + const box = membersBox(container) + expect(box.scrollTop).toBe(box.scrollHeight - BOX_HEIGHT_PX) + + // Once landed, growth that is not new work leaves the reader where they are. + const landed = box.scrollTop + await growRow(container, 'call:call-229', 300) + expect(box.scrollTop).toBe(landed) + }) + + it('finishes a follow whose tail first measures exactly as estimated', async () => { + // 26px rows: a live run opens following before any row is drawn; the tail's first real + // measurement then changes no size, so only that measurement can end the follow. + layout.rowPx = 26 + const { container } = render() + await settle() + const box = membersBox(container) + expect(box.scrollTop).toBe(box.scrollHeight - BOX_HEIGHT_PX) + // The follow ended, so growth that is not new work leaves the reader in place. + const landed = box.scrollTop + await growRow(container, 'call:call-199', 300) + expect(box.scrollTop).toBe(landed) + }) + + it('waits for a tail with no size yet, then lands on it once it has one', async () => { + const { container, rerender } = render() + await settle() + grownRows.set('call:call-259', 0) + rerender() + await settle() + // A measurement with no size does not count as landing. + await growRow(container, 'call:call-259', 0) + // The tail now lays out: the follow that waited for it lands on the new end. + await growRow(container, 'call:call-259', 40) + const box = membersBox(container) + expect(box.scrollTop).toBe(box.scrollHeight - BOX_HEIGHT_PX) + const landed = box.scrollTop + await growRow(container, 'call:call-259', 300) + expect(box.scrollTop).toBe(landed) + }) + + it('returns to where the reader scrolled with Find open after the pane hides and shows', async () => { + const { container, rerender } = render() + await settle() + rerender() + await settle() + await scrollBox(container, 9_013) + const before = topRow(container) + expect(before.key).toBe('call:call-225') + + // A hidden box loses its offset; only the remembered row brings the reader back. + pane.hidden = true + rerender() + membersBox(container).scrollTop = 0 + pane.hidden = false + rerender() + await settle() + expect(topRow(container)).toEqual(before) + + // The landing finished without any size change: later growth elsewhere leaves the reader in place. + await growRow(container, 'call:call-900', 300) + expect(topRow(container)).toEqual(before) + }) + + it('keeps a reader who jumps past the drawn rows of a live run where they jumped', async () => { + layout.rowPx = 26 + const { container, rerender } = render() + await settle() + const box = membersBox(container) + const opened = container.querySelector('[data-native-chat-tool-run-row="call:call-28"] button') + if (!opened) { + throw new Error('row call-28 is not drawn') + } + await act(async () => { + fireEvent.click(opened) + }) + await growRow(container, 'call:call-28', 68) + for (let count = 30; count <= 62; count++) { + rerender() + await settle() + } + + // A scrollbar drag lands far from every drawn row; then work arrives. + await scrollBox(container, 683) + rerender() + await settle() + expect(box.scrollTop).toBe(683) + }) + + for (const mode of ['small run shown again', 'long run with Find'] as const) { + it(`lets a reader scroll away from a followed live run without a jump (${mode})`, async () => { + layout.rowPx = 26 + const find = mode === 'long run with Find' + const first = find ? 200 : 30 + const { container, rerender } = render() + await settle() + if (!find) { + pane.hidden = true + rerender() + pane.hidden = false + rerender() + await settle() + } + for (let count = first + 1; count <= first + 15; count++) { + rerender() + await settle() + } + const box = membersBox(container) + const away = box.scrollHeight - BOX_HEIGHT_PX - 120 + await scrollBox(container, away) + expect(box.scrollTop).toBe(away) + rerender() + await settle() + expect(box.scrollTop).toBe(away) + }) + } + + for (const hiddenAfterMs of [100, 160]) { + it(`keeps the reader's row through a hidden pane that reports a scroll of its own (hidden ${hiddenAfterMs}ms after a scroll)`, async () => { + const { container, rerender } = render() + await settle() + await scrollBox(container, 9_013) + await reportDrawnRowSizes(container) + + // Hidden shortly after one more scroll, before anything deferred from it could settle. + await act(async () => { + fireEvent.scroll(membersBox(container)) + vi.advanceTimersByTime(hiddenAfterMs) + }) + // The reader's place as the pane hides is the one that must come back. + const before = topRow(container) + pane.hidden = true + rerender() + // A hidden box loses its offset and may report that as a scroll; the transcript then unmounts the run. + await scrollBox(container, 0) + rerender() + pane.hidden = false + rerender() + await settle() + await reportLandingScroll(container) + await reportDrawnRowSizes(container) + expect(topRow(container)).toEqual(before) + }) + } + + it('keeps a parked reader on its row through Find, the window threshold and a selection', async () => { + const { container, rerender } = render() + await settle() + await scrollBox(container, 613) + const before = topRow(container) + + rerender() + await settle() + expect(topRow(container)).toEqual(before) + rerender() + await settle() + expect(topRow(container)).toEqual(before) + rerender() + await settle() + expect(topRow(container)).toEqual(before) + + const box = membersBox(container) + await act(async () => { + document.getSelection()?.selectAllChildren(box) + document.dispatchEvent(new Event('selectionchange')) + }) + await settle() + expect(topRow(container)).toEqual(before) + await act(async () => { + document.getSelection()?.removeAllRanges() + document.dispatchEvent(new Event('selectionchange')) + }) + await settle() + expect(topRow(container)).toEqual(before) + }) + + it("lands on the reader's row once a pane shown before its layout finally lays out", async () => { + const { container, rerender } = render() + await settle() + await scrollBox(container, 9_013) + const before = topRow(container) + + pane.hidden = true + rerender() + membersBox(container).scrollTop = 0 + // Shown, but its box has no geometry for longer than any landing waits on frames alone. + rerender() + await settle() + await settle() + // Layout arrives; the held row reports a size that changes nothing. + pane.hidden = false + await growRow(container, before.key ?? '', 40) + expect(topRow(container)).toEqual(before) + + await growRow(container, 'call:call-900', 300) + expect(topRow(container)).toEqual(before) + }) + + it('does not keep a selection span drawn after it ended while the pane was hidden', async () => { + const { container, rerender } = render() + await settle() + const box = membersBox(container) + await act(async () => { + document.getSelection()?.selectAllChildren(box) + document.dispatchEvent(new Event('selectionchange')) + }) + expect(container.querySelectorAll(ROW).length).toBe(1_000) + + pane.hidden = true + rerender() + await act(async () => { + document.getSelection()?.removeAllRanges() + document.dispatchEvent(new Event('selectionchange')) + }) + pane.hidden = false + rerender() + await settle() + expect(container.querySelectorAll(ROW).length).toBeLessThan(100) + }) +}) diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.tsx index 0b13e5a9e63..ad8d99a461f 100644 --- a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.tsx +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.tsx @@ -1,5 +1,17 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { + Fragment, + memo, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' import { cn } from '@/lib/class-names' +import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { VIRTUALIZED_LIST_MIN_ROWS } from '@/components/virtualized-list' import { distanceFromBottom, NATIVE_CHAT_FOLLOW_REARM_PX @@ -9,14 +21,18 @@ import { MAX_NATIVE_CHAT_DISCLOSURES, NativeChatDisclosureContext } from '../../messages/disclosure/native-chat-disclosure-store' - -function openDetailHeight(content: HTMLElement): number { - let height = 0 - for (const detail of content.querySelectorAll('[data-native-chat-member-detail]')) { - height += detail.offsetHeight - } - return height -} +import { NativeChatToolRunWindowContext } from './native-chat-tool-run-window-context' +import type { NativeChatToolRunRow } from './native-chat-tool-run-rows' +import { ROW_KEY_ATTRIBUTE, useNativeChatToolRunPins } from './use-native-chat-tool-run-pins' +import { + useNativeChatToolRunWindow, + type NativeChatToolRunDrawnRow +} from './use-native-chat-tool-run-window' +import { useNativeChatToolRunDetailRoom } from './use-native-chat-tool-run-detail-room' +import { + claimNativeChatToolRunReveal, + useNativeChatToolRunReaderPosition +} from './use-native-chat-tool-run-reader-position' /** Scroll inside the run, then return its box for transcript alignment; member offsets omit internal scroll. */ export function revealNativeChatToolRunMember(member: HTMLElement): HTMLElement { @@ -25,97 +41,326 @@ export function revealNativeChatToolRunMember(member: HTMLElement): HTMLElement return member } scroller.scrollTop += member.getBoundingClientRect().top - scroller.getBoundingClientRect().top + // The revealed card is now the reader's place: a pending landing or follow must not move it again. + claimNativeChatToolRunReveal(scroller, member) return scroller } -/** An opened run's members in a bounded scroll box, so live work fills it instead of lengthening the page. */ +/** A drawn row re-renders only when its own row or renderer changes, not on every window move. */ +const WindowRow = memo(function WindowRow({ + row, + renderRow +}: { + row: NativeChatToolRunRow + renderRow: (row: NativeChatToolRunRow) => React.ReactNode +}): React.ReactNode { + return renderRow(row) +}) + +/** The drawn rows; unchanged in draw-every-row mode while the reader scrolls, so that mode costs nothing per move. */ +const WindowRows = memo(function WindowRows({ + drawn, + rows, + renderRow, + measureRow +}: { + drawn: readonly NativeChatToolRunDrawnRow[] + rows: readonly NativeChatToolRunRow[] + renderRow: (row: NativeChatToolRunRow) => React.ReactNode + measureRow: (node: HTMLDivElement | null) => void +}): React.ReactNode { + const children: React.ReactNode[] = [] + let previousEnd = 0 + for (const { index, start, end } of drawn) { + const row = rows[index] + if (!row) { + continue + } + children.push( + + {start > previousEnd + 0.5 ? ( +
+ ) : null} +
+ +
+ + ) + previousEnd = Math.max(previousEnd, end) + } + return children +}) + +/** An opened run's rows in a bounded scroll box that draws only what the reader can reach, so a long run costs a window, not its length. */ export function NativeChatToolRunMembers({ + rows, + renderRow, followKey, startsAtEnd, memoryKey, - children + revealRowKey }: { + rows: readonly NativeChatToolRunRow[] + renderRow: (row: NativeChatToolRunRow) => React.ReactNode /** How much work the run holds: a reader at the end follows it growing. Opening a detail must not count. */ followKey: number /** A live run opens on what it is doing now; a settled one reads from its top. */ startsAtEnd: boolean /** The run's disclosure key. Windowing unmounts an off-screen row; its box comes back as the reader left it. */ memoryKey?: string - children: React.ReactNode + /** The edit row a diff reveal targets; its card does the scrolling once drawn. */ + revealRowKey?: string }): React.JSX.Element { const scrollerRef = useRef(null) - const contentRef = useRef(null) - const [fades, setFades] = useState({ top: false, bottom: false }) + const { isVisible, findActive } = useContext(NativeChatToolRunWindowContext) const runScroll = useContext(NativeChatDisclosureContext)?.runScroll const [remembered] = useState(() => memoryKey === undefined ? undefined : runScroll?.get(memoryKey) ) + const returningTo = + remembered && !remembered.followsEnd ? (remembered.position ?? undefined) : undefined const followsEndRef = useRef(remembered?.followsEnd ?? startsAtEnd) - + // Drawn while a landing is pending, so it lands on the real row rather than an estimate. + const [heldRowKey, setHeldRowKey] = useState(returningTo?.key) + const [marks] = useState(createProgrammaticScrollMarks) + const [fades, setFades] = useState({ top: false, bottom: false }) + // The row whose real measurement says new work has landed: the last one that draws something + // (a thought with no words draws nothing, so it can never be measured). + const followTailKey = useMemo( + () => rows.findLast((row) => row.kind !== 'aside' || row.focusable)?.key, + [rows] + ) + const isVisibleRef = useRef(isVisible) + const followTailKeyRef = useRef(followTailKey) useLayoutEffect(() => { - if (scrollerRef.current && remembered && !remembered.followsEnd) { - scrollerRef.current.scrollTop = remembered.top - } - }, [remembered]) + isVisibleRef.current = isVisible + followTailKeyRef.current = followTailKey + }) - // Before paint: follow only if the reader was at the end before the new work. - useLayoutEffect(() => { + const rowIndexByKey = useMemo(() => new Map(rows.map((row, index) => [row.key, index])), [rows]) + // Below the threshold, or while Find searches drawn text, every row is drawn; one keyed tree serves both modes. + const drawsEveryRow = rows.length < VIRTUALIZED_LIST_MIN_ROWS || findActive + const pins = useNativeChatToolRunPins({ + rows, + rowIndexByKey, + revealRowKey, + heldRowKey, + drawsEveryRow, + isVisible, + scrollerRef + }) + const detailRoom = useNativeChatToolRunDetailRoom({ + scrollerRef, + rowIndexByKey, + initial: remembered?.detailHeights + }) + // Follow transaction: armed by new work while the reader is at the end; done once the tail has really been measured. + const followRef = useRef<{ observedTail: boolean } | null>(null) + // A real tail measurement wakes the follow pass even when it changed no size. + const [tailObservations, setTailObservations] = useState(0) + // The reader-position owner exists after the window; a measured landing row reaches it through here. + const onRowMeasuredRef = useRef<(key: string) => void>(() => {}) + const onMeasure = useCallback( + (element: HTMLDivElement) => { + const key = element.getAttribute(ROW_KEY_ATTRIBUTE) + if (key === null || !isVisibleRef.current) { + return + } + detailRoom.record(key, element) + onRowMeasuredRef.current(key) + const follow = followRef.current + // Only a laid-out tail counts: a callback can carry a cached or zero size. + if ( + follow && + !follow.observedTail && + key === followTailKeyRef.current && + element.getBoundingClientRect().height > 0 + ) { + follow.observedTail = true + setTailObservations((count) => count + 1) + } + }, + [detailRoom] + ) + const { virtualizer, measureRow, totalSize, drawn } = useNativeChatToolRunWindow({ + rows, + scrollerRef, + isVisible, + drawsEveryRow, + rangeExtractor: pins.rangeExtractor, + marks, + onMeasure + }) + + const pinToEnd = useCallback(() => { const scroller = scrollerRef.current - if (scroller && followsEndRef.current) { + if (scroller && scroller.scrollTop < scroller.scrollHeight - scroller.clientHeight - 1) { + marks.mark(scroller.scrollHeight) scroller.scrollTop = scroller.scrollHeight } - }, [followKey]) + }, [marks]) + + const onRevealed = useCallback(() => { + followRef.current = null + followsEndRef.current = false + }, []) + const reader = useNativeChatToolRunReaderPosition({ + scrollerRef, + virtualizer, + rows, + rowIndexByKey, + isVisible, + followsEndRef, + marks, + totalSize, + initial: returningTo, + setHeldRowKey, + onRevealed + }) + + useLayoutEffect(() => { + onRowMeasuredRef.current = reader.onRowMeasured + }, [reader]) + + // A following reader whose pane shows again goes back to the end. + const wasVisibleRef = useRef(isVisible) + useLayoutEffect(() => { + if (wasVisibleRef.current !== isVisible) { + wasVisibleRef.current = isVisible + if (isVisible && followsEndRef.current) { + followRef.current = { observedTail: false } + } + } + }, [isVisible]) + + // Before paint: new work starts a follow only if the reader was at the end before it. Each visible + // pass re-pins; once the tail is really measured (now, on a later measurement, or when a hidden pane + // shows again), one frame without tail growth ends it. + const followKeyRef = useRef(null) + useLayoutEffect(() => { + if (followKeyRef.current !== followKey) { + followKeyRef.current = followKey + if (followsEndRef.current) { + followRef.current = { observedTail: false } + } + } + const follow = followRef.current + if (!follow || !isVisible) { + return + } + if (!follow.observedTail && followTailKeyRef.current === undefined) { + // Nothing in the run draws: there is no landing to wait for. + follow.observedTail = true + } + if (!follow.observedTail) { + const tail = followTailKeyRef.current + const tailElement = Array.from( + scrollerRef.current?.querySelectorAll(`[${ROW_KEY_ATTRIBUTE}]`) ?? [] + ).findLast((element) => element.getAttribute(ROW_KEY_ATTRIBUTE) === tail) + // The default measurement returns a cached size without a resize; read the tail itself. + const height = tailElement?.getBoundingClientRect().height ?? 0 + if (tailElement && height > 0) { + virtualizer.resizeItem(Number(tailElement.dataset.index), height) + follow.observedTail = true + } + } + pinToEnd() + if (!follow.observedTail) { + return + } + const frame = window.requestAnimationFrame(() => { + if (followRef.current === follow && virtualizer.getTotalSize() === totalSize) { + followRef.current = null + } + }) + return () => window.cancelAnimationFrame(frame) + }, [followKey, isVisible, pinToEnd, tailObservations, totalSize, virtualizer]) + + const remember = useCallback(() => { + const scroller = scrollerRef.current + if (!scroller || !runScroll || memoryKey === undefined) { + return + } + setWithLRU( + runScroll, + memoryKey, + { + position: reader.current(), + followsEnd: followsEndRef.current, + detailHeights: detailRoom.snapshot() + }, + MAX_NATIVE_CHAT_DISCLOSURES + ) + }, [detailRoom, memoryKey, reader, runScroll]) const readPosition = useCallback(() => { const scroller = scrollerRef.current - if (!scroller) { + // A hidden pane reads zero geometry, which is not where the reader is. + if (!scroller || !isVisibleRef.current) { return } const fromEnd = distanceFromBottom(scroller) - followsEndRef.current = fromEnd <= NATIVE_CHAT_FOLLOW_REARM_PX + followsEndRef.current = !reader.landingPending() && fromEnd <= NATIVE_CHAT_FOLLOW_REARM_PX const top = scroller.scrollTop > 1 const bottom = fromEnd > 1 setFades((current) => current.top === top && current.bottom === bottom ? current : { top, bottom } ) - if (runScroll && memoryKey !== undefined) { - setWithLRU( - runScroll, - memoryKey, - { top: scroller.scrollTop, followsEnd: followsEndRef.current }, - MAX_NATIVE_CHAT_DISCLOSURES - ) - } - }, [memoryKey, runScroll]) + remember() + }, [reader, remember]) + + const onScroll = useCallback( + (event: React.UIEvent) => { + const scroller = event.currentTarget + const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight) + const byReader = !marks.consume(event.nativeEvent, scroller.scrollTop, maxScroll) + if (byReader && isVisibleRef.current) { + // The reader moved: any follow in flight yields to them. + followRef.current = null + } + // Registered on the box before the window's own listener, so this runs before the redraw it causes. + reader.onScrolled(scroller.scrollTop, byReader) + readPosition() + }, + [marks, readPosition, reader] + ) useEffect(() => { const scroller = scrollerRef.current - const content = contentRef.current - if (!scroller || !content || typeof ResizeObserver === 'undefined') { + if (!scroller || typeof ResizeObserver === 'undefined') { return } - let roomForDetails = 0 - const observer = new ResizeObserver(() => { - const opened = openDetailHeight(content) - if (opened !== roomForDetails) { - roomForDetails = opened - // A variable, not state: a detail animating open would re-render every frame. - scroller.style.setProperty('--native-chat-opened-detail', `${opened}px`) - } - readPosition() - }) - observer.observe(content) // The box's cap moves with an opened detail, which changes what lies past its edges. + const observer = new ResizeObserver(readPosition) observer.observe(scroller) + if (scroller.firstElementChild) { + observer.observe(scroller.firstElementChild) + } return () => observer.disconnect() }, [readPosition]) + // The owned place is saved as the run leaves; nothing is read from a box that may already be hidden. + useLayoutEffect(() => remember, [remember]) + + const { retainInteractedRow } = pins + const onReaderAct = useCallback( + (event: React.SyntheticEvent) => { + // Opening a detail is the reader acting, not new work: it ends any follow in flight. + followRef.current = null + retainInteractedRow(event) + }, + [retainInteractedRow] + ) + + // Drawn rows are in index order, so the last one ends the drawn extent. + const trailing = drawsEveryRow ? 0 : totalSize - (drawn.at(-1)?.end ?? 0) + return (
-
{children}
+
+ + {trailing > 0.5 ?
: null} +
) } diff --git a/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.window.test.tsx b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.window.test.tsx new file mode 100644 index 00000000000..311eafb45a5 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/NativeChatToolRunMembers.window.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { useMemo } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatBlock } from '../../../../../../shared/native-chat/native-chat-types' +import { NativeChatToolRun } from './NativeChatToolRun' +import { NativeChatToolRunWindowContext } from './native-chat-tool-run-window-context' +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from '../../messages/disclosure/native-chat-disclosure-store' + +const BOX_HEIGHT_PX = 288 +const ROW_HEIGHT_PX = 26 +/** 12 rows fill the box, 10 overscan rows on each side, and the first/last rows stay drawn. */ +const MAX_DRAWN_ROWS = 12 + 2 * 10 + 2 + +function calls(count: number): NativeChatBlock[] { + return Array.from({ length: count }, (_, index) => ({ + type: 'tool-call' as const, + name: 'Read', + callId: `call-${index}`, + input: { file_path: `src/file-${index}.ts` } + })) +} + +/** Two task-list updates (the second shows its "Full task list" control), then Read calls up to `count` rows. */ +function withTaskList(count: number): NativeChatBlock[] { + const todo = (status: string): NativeChatBlock => ({ + type: 'tool-call', + name: 'TodoWrite', + input: { todos: [{ content: 'Audit the window', status }] } + }) + return [todo('pending'), todo('in_progress'), ...calls(count - 2)] +} + +function Run({ + count, + findActive = false, + blocks = calls(count) +}: { + count: number + findActive?: boolean + blocks?: NativeChatBlock[] +}) { + const disclosures = useNativeChatDisclosures() + const windowFacts = useMemo(() => ({ isVisible: true, findActive }), [findActive]) + return ( + + + + + + ) +} + +function drawnRows(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll('[data-native-chat-tool-run-row]')) +} + +function rowButton(container: HTMLElement, index: number): HTMLElement { + const button = container.querySelector( + `[data-native-chat-tool-run-row="call:call-${index}"] button` + ) + if (!button) { + throw new Error(`row ${index} is not drawn`) + } + return button +} + +describe('NativeChatToolRun opened-run window', () => { + beforeEach(() => { + // happy-dom lays nothing out: the box and each row get the sizes the test states. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function ( + this: HTMLElement + ) { + if (this.hasAttribute('data-native-chat-tool-run-members')) { + return BOX_HEIGHT_PX + } + return this.hasAttribute('data-native-chat-tool-run-row') ? ROW_HEIGHT_PX : 0 + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) + }) + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('draws the same bounded window of a long run at 2,000 and 20,000 calls', () => { + for (const count of [2_000, 20_000]) { + const { container, unmount } = render() + const drawn = drawnRows(container) + expect(drawn.length).toBeGreaterThan(0) + expect(drawn.length).toBeLessThanOrEqual(MAX_DRAWN_ROWS) + // A settled run reads from its top, and its last row stays drawn for the tail. + expect(rowButton(container, 0)).toBeInTheDocument() + expect(rowButton(container, count - 1)).toBeInTheDocument() + unmount() + } + }) + + it('draws every row while Find has a query, then returns to the window without remounting', () => { + const { container, rerender } = render() + const first = rowButton(container, 0) + expect(drawnRows(container).length).toBeLessThanOrEqual(MAX_DRAWN_ROWS) + + rerender() + expect(drawnRows(container)).toHaveLength(200) + expect(rowButton(container, 0)).toBe(first) + + rerender() + expect(drawnRows(container).length).toBeLessThanOrEqual(MAX_DRAWN_ROWS) + expect(rowButton(container, 0)).toBe(first) + }) + + it('keeps a focused row and an opened full task list through windowing and Find', () => { + const { container, rerender } = render() + fireEvent.click(screen.getByRole('button', { name: /Full task list/ })) + const fullList = screen.getByRole('button', { name: /Full task list/ }) + expect(fullList).toHaveAttribute('aria-expanded', 'true') + const focused = rowButton(container, 10) + focused.focus() + + rerender() + expect(focused).toBeInTheDocument() + expect(document.activeElement).toBe(focused) + expect(screen.getByRole('button', { name: /Full task list/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + + rerender() + rerender() + expect(screen.getByRole('button', { name: /Full task list/ })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-rows.ts b/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-rows.ts new file mode 100644 index 00000000000..3a965b6e23f --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-rows.ts @@ -0,0 +1,123 @@ +import type { + NativeChatBlock, + NativeChatToolCallBlock, + NativeChatToolResultBlock +} from '../../../../../../shared/native-chat/native-chat-types' +import { pairNativeChatToolResults } from '../../../../../../shared/native-chat/tools/native-chat-tool-pairing' +import { nativeChatToolLineIdentity } from '../../../../../../shared/native-chat/tools/native-chat-tool-line-identity' +import { buildEditCards } from '../../../../../../shared/native-chat/tools/native-chat-edit-cards' +import type { NativeChatEditFile } from '../../../../../../shared/native-chat/tools/native-chat-edit-model' +import { + buildNativeChatTaskListRows, + type NativeChatTaskListRow +} from '../../../../../../shared/native-chat/input/native-chat-task-list-history' +import type { NativeChatToolRunAsides } from './NativeChatToolRun' + +/** One drawn row of an opened run. `focusable`: it renders a control, so keyboard order can land on it. */ +export type NativeChatToolRunRow = + | { kind: 'aside'; key: string; focusable: boolean; element: React.JSX.Element } + | { kind: 'tasks'; key: string; focusable: boolean; taskList: NativeChatTaskListRow } + | { + kind: 'edit' + key: string + focusable: true + editKey: string + files: NativeChatEditFile[] + } + | { + kind: 'line' + key: string + focusable: true + block: NativeChatBlock + result: NativeChatToolResultBlock | undefined + lineIdentity: string + } + +/** A thought with no words draws nothing, so keyboard order cannot land on it. */ +function asideDrawsControl(element: React.JSX.Element): boolean { + const props: unknown = element.props + return !( + typeof props === 'object' && + props !== null && + 'markdown' in props && + typeof props.markdown === 'string' && + props.markdown.trim() === '' + ) +} + +/** The run's rows in drawing order, with today's keys; pairing and grouping run once over the whole run. */ +export function buildNativeChatToolRunRows({ + blocks, + headerBlocks, + asides, + previousTodoWrite, + previousUpdatePlan +}: { + blocks: NativeChatBlock[] + headerBlocks: NativeChatBlock[] + asides?: NativeChatToolRunAsides + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock +}): NativeChatToolRunRow[] { + const taskLists = buildNativeChatTaskListRows(blocks, { + todowrite: previousTodoWrite, + update_plan: previousUpdatePlan + }) + const { editCards, consumedResults } = buildEditCards(blocks) + const { resultByCall, pairedResults } = pairNativeChatToolResults(headerBlocks) + const seen = new Map() + const rows: NativeChatToolRunRow[] = [] + const pushAsides = (elements: readonly React.JSX.Element[] | undefined): void => { + for (const element of elements ?? []) { + rows.push({ + kind: 'aside', + key: String(element.key), + focusable: asideDrawsControl(element), + element + }) + } + } + for (const [blockIndex, block] of headerBlocks.entries()) { + pushAsides(asides?.before.get(block)) + const taskList = taskLists.rows.get(block) + if (taskList) { + // Only an update shows its "Full task list" trigger; a first list has no control. + rows.push({ + kind: 'tasks', + key: `tasks:${blockIndex}`, + focusable: taskList.previous !== undefined, + taskList + }) + continue + } + if (taskLists.consumedResults.has(block)) { + continue + } + const edit = editCards.get(block) + if (edit) { + rows.push({ + kind: 'edit', + key: `edit:${edit.key}`, + focusable: true, + editKey: edit.key, + files: edit.files + }) + continue + } + // A result its call now owns is drawn by that call's line, not as a row of its own. + if (consumedResults.has(block) || pairedResults.has(block)) { + continue + } + const lineIdentity = nativeChatToolLineIdentity(block, seen) + rows.push({ + kind: 'line', + key: lineIdentity, + focusable: true, + block, + result: block.type === 'tool-call' ? resultByCall.get(block) : undefined, + lineIdentity + }) + } + pushAsides(asides?.after) + return rows +} diff --git a/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-window-context.tsx b/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-window-context.tsx new file mode 100644 index 00000000000..d2a383c3dc6 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/native-chat-tool-run-window-context.tsx @@ -0,0 +1,33 @@ +import { createContext, useMemo } from 'react' +import type { NativeChatFind } from '../../viewport/find/use-native-chat-find' + +/** What an opened run's own window needs from the chat around it; standalone runs get the defaults. */ +export type NativeChatToolRunWindowFacts = { + /** A hidden pane lays nothing out, so its rows must keep their last measured sizes. */ + isVisible: boolean + /** Find searches drawn text, so every row stays drawn while it has a query. */ + findActive: boolean +} + +export const NativeChatToolRunWindowContext = createContext({ + isVisible: true, + findActive: false +}) + +export function NativeChatToolRunWindowProvider({ + isVisible, + find, + children +}: { + isVisible: boolean + find: Pick + children: React.ReactNode +}): React.JSX.Element { + const findActive = find.isOpen && find.query !== '' + const facts = useMemo(() => ({ isVisible, findActive }), [findActive, isVisible]) + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-detail-room.ts b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-detail-room.ts new file mode 100644 index 00000000000..7406ff08c28 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-detail-room.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from 'react' + +/** What this row's opened details add, the same quantity the box's cap has always grown by. */ +function openedDetailHeight(row: Element): number { + let height = 0 + for (const detail of row.querySelectorAll('[data-native-chat-member-detail]')) { + height += detail.offsetHeight + } + return height +} + +/** The room the run's box gives opened details, kept per row so details scrolled out of the + * window (or the whole run evicted and back) still count; the total moves by each row's change. */ +export function useNativeChatToolRunDetailRoom({ + scrollerRef, + rowIndexByKey, + initial +}: { + scrollerRef: React.RefObject + rowIndexByKey: ReadonlyMap + initial: ReadonlyMap | undefined +}) { + const [room] = useState(() => { + const heights = new Map(initial) + let total = 0 + for (const height of heights.values()) { + total += height + } + let written = -1 + let saved: ReadonlyMap = new Map(heights) + let savedIsCurrent = true + const write = (): void => { + const scroller = scrollerRef.current + if (scroller && total !== written) { + written = total + // A variable, not state: a detail animating open would re-render every frame. + scroller.style.setProperty('--native-chat-opened-detail', `${total}px`) + } + } + const set = (key: string, height: number): void => { + if ((heights.get(key) ?? 0) === height) { + return + } + savedIsCurrent = false + total += height - (heights.get(key) ?? 0) + if (height > 0) { + heights.set(key, height) + } else { + heights.delete(key) + } + } + return { + record: (key: string, row: Element): void => { + set(key, openedDetailHeight(row)) + write() + }, + retire: (present: ReadonlyMap): void => { + for (const key of heights.keys()) { + if (!present.has(key)) { + set(key, 0) + } + } + write() + }, + // Copied only after a height changed: the reader's scrolling saves the same map again. + snapshot: (): ReadonlyMap => { + if (!savedIsCurrent) { + saved = new Map(heights) + savedIsCurrent = true + } + return saved + } + } + }) + // Rows that left the run give back their room; the first pass also applies a returning run's room. + useEffect(() => { + room.retire(rowIndexByKey) + }, [room, rowIndexByKey]) + return useMemo(() => ({ record: room.record, snapshot: room.snapshot }), [room]) +} diff --git a/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-pins.ts b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-pins.ts new file mode 100644 index 00000000000..3f8822d85a8 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-pins.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { VIRTUALIZED_LIST_MIN_ROWS } from '@/components/virtualized-list' +import { nativeChatTranscriptRange } from '../../viewport/virtualization/native-chat-pinned-rows' +import type { NativeChatToolRunRow } from './native-chat-tool-run-rows' + +export const ROW_KEY_ATTRIBUTE = 'data-native-chat-tool-run-row' +/** Clicked/focused rows stay drawn, so their own scroll and local state survive the reader scrolling away. */ +const MAX_INTERACTION_PINS = 24 + +type WindowRange = { startIndex: number; endIndex: number; overscan: number; count: number } + +function rowKeyOf(target: EventTarget | null): string | null { + const row = target instanceof Element ? target.closest(`[${ROW_KEY_ATTRIBUTE}]`) : null + return row?.getAttribute(ROW_KEY_ATTRIBUTE) ?? null +} + +/** Which rows an opened run draws beyond its window: bounded obligations, never the run's length, + * except a selection, which keeps every row between its ends so copying takes all of them. */ +export function useNativeChatToolRunPins({ + rows, + rowIndexByKey, + revealRowKey, + heldRowKey, + drawsEveryRow, + isVisible, + scrollerRef +}: { + rows: readonly NativeChatToolRunRow[] + rowIndexByKey: ReadonlyMap + revealRowKey: string | undefined + /** The row a returning reader is being restored to; drawn until they move. */ + heldRowKey: string | undefined + drawsEveryRow: boolean + isVisible: boolean + scrollerRef: React.RefObject +}) { + const [interactionKeys, setInteractionKeys] = useState([]) + const [focusedKey, setFocusedKey] = useState(null) + const [selectionSpan, setSelectionSpan] = useState(null) + const rowIndexByKeyRef = useRef(rowIndexByKey) + useLayoutEffect(() => { + rowIndexByKeyRef.current = rowIndexByKey + }, [rowIndexByKey]) + // Each row's previous and next focusable rows, built once per row list so a focus move is a lookup. + const focusNeighbors = useMemo(() => { + const previous = new Int32Array(rows.length).fill(-1) + const next = new Int32Array(rows.length).fill(-1) + let last = -1 + for (let index = 0; index < rows.length; index++) { + previous[index] = last + if (rows[index]?.focusable) { + last = index + } + } + last = -1 + for (let index = rows.length - 1; index >= 0; index--) { + next[index] = last + if (rows[index]?.focusable) { + last = index + } + } + const first = rows[0]?.focusable ? 0 : (next[0] ?? -1) + const final = rows.at(-1)?.focusable ? rows.length - 1 : (previous[rows.length - 1] ?? -1) + return { previous, next, first, final } + }, [rows]) + + // The ends (Tab's way in from either side, the tail), the reveal target, interacted rows and the focused row's next stops. + const pinned = useMemo(() => { + const indexes = new Set() + const add = (index: number | undefined): void => { + if (index !== undefined && index >= 0) { + indexes.add(index) + } + } + add(rows.length - 1) + add(focusNeighbors.first) + add(focusNeighbors.final) + for (const key of [revealRowKey, heldRowKey, ...interactionKeys]) { + add(key === undefined ? undefined : rowIndexByKey.get(key)) + } + const focused = focusedKey === null ? undefined : rowIndexByKey.get(focusedKey) + if (focused !== undefined) { + add(focusNeighbors.previous[focused]) + add(focusNeighbors.next[focused]) + } + return indexes + }, [ + focusNeighbors, + focusedKey, + heldRowKey, + interactionKeys, + revealRowKey, + rowIndexByKey, + rows.length + ]) + + // A hidden run cannot be copied from; a visible one tracks selection even while drawing every row, + // so the span is already known when it goes back to its window. + const tracksSelection = isVisible + // One array per row count: the virtualizer keeps its items while the same array comes back, so + // scrolling a run that draws every row rebuilds nothing. + const everyIndex = useMemo( + () => Array.from({ length: rows.length }, (_, index) => index), + [rows.length] + ) + // A selection's rows plus the pins, reused while the window stays inside the selection. + const span = tracksSelection && !drawsEveryRow ? selectionSpan : null + const spanned = useMemo(() => { + if (!span) { + return null + } + const indexes = new Set(pinned) + for (let index = span[0]; index <= Math.min(span[1], rows.length - 1); index++) { + indexes.add(index) + } + return Array.from(indexes).sort((a, b) => a - b) + }, [pinned, rows.length, span]) + const rangeExtractor = useCallback( + (range: WindowRange) => { + if (drawsEveryRow) { + return everyIndex + } + const first = Math.max(0, range.startIndex - range.overscan) + const last = Math.min(range.count - 1, range.endIndex + range.overscan) + if (spanned && span && span[0] <= first && last <= span[1]) { + return spanned + } + const indexes = nativeChatTranscriptRange(range, pinned) + if (!spanned) { + return indexes + } + return Array.from(new Set([...indexes, ...spanned])).sort((a, b) => a - b) + }, + [drawsEveryRow, everyIndex, pinned, span, spanned] + ) + + // An end outside the run (or on a spacer) reaches the run's matching edge. + useEffect(() => { + if (!tracksSelection) { + return + } + const onSelectionChange = (): void => { + const scroller = scrollerRef.current + const selection = document.getSelection() + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null + if (!scroller || !range || !range.intersectsNode(scroller)) { + setSelectionSpan((current) => (current === null ? current : null)) + return + } + const indexAt = (node: Node, fallback: number): number => { + if (!scroller.contains(node)) { + return fallback + } + const key = rowKeyOf(node instanceof Element ? node : node.parentElement) + return (key === null ? undefined : rowIndexByKeyRef.current.get(key)) ?? fallback + } + const start = indexAt(range.startContainer, 0) + const end = indexAt(range.endContainer, rowIndexByKeyRef.current.size - 1) + setSelectionSpan((current) => + current && current[0] === start && current[1] === end ? current : [start, end] + ) + } + // Re-derive on (re)registration: the selection may have changed while this run was hidden. + onSelectionChange() + document.addEventListener('selectionchange', onSelectionChange) + return () => document.removeEventListener('selectionchange', onSelectionChange) + }, [scrollerRef, tracksSelection]) + + const retainInteractedRow = useCallback((event: React.SyntheticEvent) => { + const key = rowKeyOf(event.target) + if (key === null) { + return + } + setInteractionKeys((current) => + current.at(-1) === key + ? current + : [...current.filter((candidate) => candidate !== key), key].slice(-MAX_INTERACTION_PINS) + ) + }, []) + const onFocusCapture = useCallback( + (event: React.FocusEvent) => { + setFocusedKey(rowKeyOf(event.target)) + retainInteractedRow(event) + }, + [retainInteractedRow] + ) + const onBlurCapture = useCallback( + (event: React.FocusEvent) => { + const next = event.relatedTarget + if (!(next instanceof Node) || !scrollerRef.current?.contains(next)) { + setFocusedKey(null) + } + }, + [scrollerRef] + ) + + // A selection wider than a window keeps that many rows drawn while the reader scrolls. + const spansSelection = span !== null && span[1] - span[0] >= VIRTUALIZED_LIST_MIN_ROWS + return { rangeExtractor, spansSelection, retainInteractedRow, onFocusCapture, onBlurCapture } +} diff --git a/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.test.tsx b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.test.tsx new file mode 100644 index 00000000000..6f3d95e753e --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, renderHook } from '@testing-library/react' +import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import type { NativeChatToolRunRow } from './native-chat-tool-run-rows' +import { ROW_KEY_ATTRIBUTE } from './use-native-chat-tool-run-pins' +import { + claimNativeChatToolRunReveal, + useNativeChatToolRunReaderPosition +} from './use-native-chat-tool-run-reader-position' +import type { NativeChatToolRunVirtualizer } from './use-native-chat-tool-run-window' + +// The window's size model, deliberately stale: the edit row is still its cached 78 px while the page has it far +// taller. Rows after it follow the cached height. +const MODEL_ROWS = [ + 'call:before', + 'edit:patch', + 'call:after-1', + 'call:after-2', + 'call:after-3', + 'call:after-4', + 'call:after-5', + 'call:after-6', + 'call:after-7', + 'call:after-8', + 'call:after-9', + 'call:after-10' +] +const MODEL_STARTS = [ + 0, 1_000, 1_078, 1_118, 1_158, 1_198, 1_238, 1_278, 1_318, 1_358, 1_398, 1_438 +] + +// Rows that draw something; the owner reads only their keys and whether they draw. +const rows: NativeChatToolRunRow[] = MODEL_ROWS.map((key) => ({ + kind: 'aside', + key, + focusable: true, + element: +})) +const rowIndexByKey = new Map(MODEL_ROWS.map((key, index) => [key, index])) +const measurementsCache = MODEL_STARTS.map((start, index) => { + const size = (MODEL_STARTS[index + 1] ?? start + 40) - start + return { index, start, size, end: start + size, key: MODEL_ROWS[index]!, lane: 0 } +}) +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner reads only measurementsCache, scrollOffset and getVirtualItemForOffset, all supplied here; a full TanStack instance is not needed to give it a fixed model. +const staleModel = { + measurementsCache, + scrollOffset: 0, + getVirtualItemForOffset: (offset: number) => + measurementsCache.findLast((item) => item.start <= offset) +} as unknown as NativeChatToolRunVirtualizer + +function setClientHeight(element: Element, height: number): void { + Object.defineProperty(element, 'clientHeight', { configurable: true, value: height }) +} + +function setRect(element: Element, top: number, height: number): void { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => DOMRect.fromRect({ x: 0, y: top, width: 300, height }) + }) +} + +/** A run box whose drawn children sit at literal page positions relative to a 288 px box at the page top. */ +function runBox( + children: { key?: string; top: number; height: number }[], + boxHeight = 288 +): { + scroller: HTMLDivElement + rowsByKey: Map +} { + const scroller = document.createElement('div') + const content = document.createElement('div') + scroller.append(content) + document.body.append(scroller) + setRect(scroller, 0, boxHeight) + setClientHeight(scroller, boxHeight) + const rowsByKey = new Map() + for (const child of children) { + const element = document.createElement('div') + if (child.key) { + element.setAttribute(ROW_KEY_ATTRIBUTE, child.key) + rowsByKey.set(child.key, element) + } + setRect(element, child.top, child.height) + content.append(element) + } + return { scroller, rowsByKey } +} + +function moveChildren( + rowsByKey: Map, + positions: Record +): void { + for (const [key, [top, height]] of Object.entries(positions)) { + setRect(rowsByKey.get(key)!, top, height) + } +} + +function owner(scroller: HTMLDivElement) { + return renderHook( + ({ isVisible }: { isVisible: boolean }) => + useNativeChatToolRunReaderPosition({ + scrollerRef: { current: scroller }, + virtualizer: staleModel, + rows, + rowIndexByKey, + isVisible, + followsEndRef: { current: false }, + marks: createProgrammaticScrollMarks(), + totalSize: 1_478, + initial: undefined, + setHeldRowKey: vi.fn(), + onRevealed: vi.fn() + }), + { initialProps: { isVisible: true } } + ) +} + +/** A card inside a drawn row scrolls itself to the box top and claims the place. */ +function revealCardIn(scroller: HTMLDivElement, row: HTMLDivElement): void { + const card = document.createElement('div') + row.append(card) + claimNativeChatToolRunReveal(scroller, card) +} + +describe('tool-run reader position with a stale size model', () => { + afterEach(() => { + cleanup() + document.body.replaceChildren() + }) + + it("keeps a revealed file's place through its own scroll report and a hide", () => { + // Box at 1,400: the edit really starts at 1,000 and is 600 tall; the next row starts at 1,600. + const { scroller, rowsByKey } = runBox([ + { top: -1_400, height: 1_000 }, + { key: 'edit:patch', top: -400, height: 600 }, + { key: 'call:after-1', top: 200, height: 40 } + ]) + const { result, rerender } = owner(scroller) + revealCardIn(scroller, rowsByKey.get('edit:patch')!) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 400 }) + + // The reveal's own native scroll report, before the edit's new height reaches the model. + result.current.onScrolled(1_400, true) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 400 }) + + rerender({ isVisible: false }) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 400 }) + }) + + it('follows the reader inside the tall row and across its edge before the model catches up', () => { + const { scroller, rowsByKey } = runBox([ + { top: -1_400, height: 1_000 }, + { key: 'edit:patch', top: -400, height: 600 }, + { key: 'call:after-1', top: 200, height: 40 } + ]) + const { result } = owner(scroller) + revealCardIn(scroller, rowsByKey.get('edit:patch')!) + + moveChildren(rowsByKey, { 'edit:patch': [-420, 600], 'call:after-1': [180, 40] }) + result.current.onScrolled(1_420, true) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 420 }) + + moveChildren(rowsByKey, { 'edit:patch': [-610, 600], 'call:after-1': [-10, 40] }) + result.current.onScrolled(1_610, true) + expect(result.current.current()).toEqual({ key: 'call:after-1', offset: 10 }) + }) + + it('takes the next drawn row across a blank row and a card margin', () => { + // Edit bottom at 1,600, a blank thought at 1,600, the next call at 1,604 (the card's margin); box at 1,602. + const { scroller } = runBox([ + { top: -1_602, height: 1_000 }, + { key: 'edit:patch', top: -602, height: 600 }, + { key: 'call:before', top: -2, height: 0 }, + { key: 'call:after-1', top: 2, height: 40 } + ]) + const { result } = owner(scroller) + result.current.onScrolled(1_602, true) + expect(result.current.current()).toEqual({ key: 'call:after-1', offset: 0 }) + }) + + it('keeps the place when the box has no geometry, even where the model has an answer', () => { + const { scroller, rowsByKey } = runBox([ + { top: -1_400, height: 1_000 }, + { key: 'edit:patch', top: -400, height: 600 } + ]) + const { result } = owner(scroller) + revealCardIn(scroller, rowsByKey.get('edit:patch')!) + setRect(scroller, 0, 0) + setClientHeight(scroller, 0) + result.current.onScrolled(0, true) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 400 }) + }) + + it('records the actual place after a clamped report and a compensation write', () => { + const { scroller, rowsByKey } = runBox([ + { top: -1_900, height: 1_000 }, + { key: 'edit:patch', top: -900, height: 1_200 } + ]) + const { result } = owner(scroller) + revealCardIn(scroller, rowsByKey.get('edit:patch')!) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 900 }) + + // The box could only reach 1,880. + moveChildren(rowsByKey, { 'edit:patch': [-880, 1_200] }) + result.current.onScrolled(1_880, true) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 880 }) + + // The window's own compensation write (not the reader) moved it to 1,912. + moveChildren(rowsByKey, { 'edit:patch': [-912, 1_200] }) + result.current.onScrolled(1_912, false) + expect(result.current.current()).toEqual({ key: 'edit:patch', offset: 912 }) + expect(scroller.scrollTop).toBe(0) + }) + + it('uses the model only where the box top lies in an undrawn gap', () => { + // A wrapper for a row no longer in the run, then a spacer: the top is undrawn, so the model answers. + const { scroller } = runBox([ + { key: 'call:gone', top: -10, height: 40 }, + { top: 30, height: 5_000 } + ]) + const { result } = owner(scroller) + result.current.onScrolled(1_130, true) + expect(result.current.current()).toEqual({ key: 'call:after-2', offset: 12 }) + }) +}) diff --git a/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.ts b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.ts new file mode 100644 index 00000000000..8aef76a6a40 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-reader-position.ts @@ -0,0 +1,341 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import type { NativeChatRunPosition } from '../../messages/disclosure/native-chat-disclosure-store' +import type { NativeChatToolRunRow } from './native-chat-tool-run-rows' +import { ROW_KEY_ATTRIBUTE } from './use-native-chat-tool-run-pins' +import type { NativeChatToolRunVirtualizer } from './use-native-chat-tool-run-window' + +const REVEAL_EVENT = 'native-chat-tool-run-reveal' +// A card can reveal in the same commit that mounts its run, before the run listens for it. The place is read from +// the card's own row as it aligns, since the window's model may not yet know that row's new height. +const unclaimedReveals = new WeakMap() +// Confirmation frames a landing may spend per real change before it waits for the next one. +const MAX_LANDING_FRAMES = 12 + +/** A card has just scrolled itself to the top of its run: that is now the reader's place. */ +export function claimNativeChatToolRunReveal(scroller: HTMLElement, member: HTMLElement): void { + const row = member.closest(`[${ROW_KEY_ATTRIBUTE}]`) + const key = row?.getAttribute(ROW_KEY_ATTRIBUTE) + const offset = row + ? scroller.getBoundingClientRect().top - row.getBoundingClientRect().top + : undefined + unclaimedReveals.set( + scroller, + key && offset !== undefined ? { key, offset: Math.max(0, offset) } : null + ) + scroller.dispatchEvent(new Event(REVEAL_EVENT)) +} + +/** A thought with no words draws nothing, so it can never be anyone's place. */ +const drawsSomething = (row: NativeChatToolRunRow): boolean => row.kind !== 'aside' || row.focusable + +// `seen` is the size and row list the frame budget was granted for; a real change grants a new budget. +// Blank rows a capture may step over after the box top before it trusts the model instead. +const MAX_BLANK_ROWS_STEPPED = 8 + +/** Where the drawn rows put the box top: a row's own place, an undrawn gap, or no usable geometry. */ +type DrawnPlace = NativeChatRunPosition | 'gap' | 'unusable' + +/** The reader's place read from the rows actually drawn at the box top, which win over a model that may still + * hold an old height. A binary search over the content's in-flow children keeps this bounded in every mode. */ +function placeFromDrawnRows( + scroller: HTMLElement, + rowIndexByKey: ReadonlyMap +): DrawnPlace { + const box = scroller.getBoundingClientRect() + const content = scroller.firstElementChild + // A hidden or not-yet-laid-out box reports no client height: nothing there is the reader's place. + if (scroller.clientHeight <= 0 || !content?.isConnected) { + return 'unusable' + } + const children = content.children + let found = -1 + for (let low = 0, high = children.length - 1; low <= high;) { + const middle = (low + high) >> 1 + if (children[middle]!.getBoundingClientRect().top - box.top <= 0.5) { + found = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + const read = (child: Element) => { + const key = child.getAttribute(ROW_KEY_ATTRIBUTE) + const rect = child.getBoundingClientRect() + const kind = + key === null ? 'spacer' : rowIndexByKey.has(key) && rect.height > 0 ? 'row' : 'blank' + return { kind, key, rect } + } + const atTop = found >= 0 ? read(children[found]!) : undefined + if (atTop && atTop.kind !== 'blank' && atTop.rect.bottom > box.top) { + return atTop.kind === 'row' && atTop.key !== null + ? { key: atTop.key, offset: box.top - atTop.rect.top } + : 'gap' + } + // A blank row or a margin between children: the place is the next child that draws, if it is a row. + for (let index = found + 1, blanks = 0; index < children.length; index++) { + const next = read(children[index]!) + if (next.kind === 'blank') { + if (++blanks > MAX_BLANK_ROWS_STEPPED) { + return 'gap' + } + continue + } + return next.kind === 'row' && next.key !== null + ? { key: next.key, offset: Math.max(0, box.top - next.rect.top) } + : 'gap' + } + return 'gap' +} + +type Landing = { + target: NativeChatRunPosition + frames: number + seen?: { totalSize: number; rowIndexByKey: ReadonlyMap } +} + +/** The run's one record of where its reader is, and the one landing that may move the box back there. */ +export function useNativeChatToolRunReaderPosition({ + scrollerRef, + virtualizer, + rows, + rowIndexByKey, + isVisible, + followsEndRef, + marks, + totalSize, + initial, + setHeldRowKey, + onRevealed +}: { + scrollerRef: React.RefObject + virtualizer: NativeChatToolRunVirtualizer + rows: readonly NativeChatToolRunRow[] + rowIndexByKey: ReadonlyMap + isVisible: boolean + followsEndRef: React.RefObject + marks: ProgrammaticScrollMarks + totalSize: number + /** A returning reader's place, landed once the run is visible. */ + initial: NativeChatRunPosition | undefined + /** Keeps the landing row drawn while a landing is pending. */ + setHeldRowKey: (key: string | undefined) => void + /** A reveal took the box: no follow may move it. */ + onRevealed: () => void +}) { + const positionRef = useRef(initial ?? null) + const landingRef = useRef(initial ? { target: initial, frames: 0 } : null) + const [landingWake, setLandingWake] = useState(0) + // Each row's first drawing row at or after it, so finding a place never walks the run. + const nextDrawing = useMemo(() => { + const next = new Int32Array(rows.length).fill(-1) + for (let index = rows.length - 1; index >= 0; index--) { + next[index] = drawsSomething(rows[index]!) ? index : (next[index + 1] ?? -1) + } + return next + }, [rows]) + const isVisibleRef = useRef(isVisible) + const latest = useRef({ virtualizer, rows, rowIndexByKey, nextDrawing, onRevealed }) + useLayoutEffect(() => { + isVisibleRef.current = isVisible + latest.current = { virtualizer, rows, rowIndexByKey, nextDrawing, onRevealed } + }) + + const retireLanding = useCallback(() => { + if (landingRef.current) { + landingRef.current = null + setHeldRowKey(undefined) + } + }, [setHeldRowKey]) + /** The reader's row: from the drawn rows when they cover the box top, else from the window's full model, + * which also covers rows not drawn. No usable geometry keeps the last place. */ + const capture = useCallback( + (scrollTop: number) => { + const { + virtualizer: model, + rows: current, + rowIndexByKey: keys, + nextDrawing: next + } = latest.current + const scroller = scrollerRef.current + const drawn = scroller ? placeFromDrawnRows(scroller, keys) : 'unusable' + if (drawn === 'unusable') { + return + } + if (drawn !== 'gap') { + positionRef.current = drawn + return + } + const found = model.getVirtualItemForOffset(scrollTop)?.index + const index = found === undefined ? -1 : (next[found] ?? -1) + const row = current[index] + const measured = model.measurementsCache[index] + // No drawing row there (or nothing measured yet) keeps the last place rather than inventing one. + if (row && measured) { + positionRef.current = { key: row.key, offset: Math.max(0, scrollTop - measured.start) } + } + }, + [scrollerRef] + ) + + /** Every scroll of the visible box: the reader's own retires any landing first, in every drawing mode. */ + const onScrolled = useCallback( + (scrollTop: number, byReader: boolean) => { + if (!isVisibleRef.current) { + return + } + if (byReader) { + retireLanding() + } else if (landingRef.current) { + return + } + capture(scrollTop) + }, + [capture, retireLanding] + ) + + // A reveal in this commit (from the live listener or a claim left before this run listened) owns the box. + const revealedRef = useRef(false) + // The revealed row's own place, read from the DOM as the card aligned; absent, the model decides below. + const revealedPlaceRef = useRef(null) + const takeReveal = useCallback(() => { + const scroller = scrollerRef.current + if (!scroller || !unclaimedReveals.has(scroller)) { + return + } + const place = unclaimedReveals.get(scroller) ?? null + unclaimedReveals.delete(scroller) + revealedRef.current = true + revealedPlaceRef.current = place + retireLanding() + latest.current.onRevealed() + if (place) { + positionRef.current = place + } else if (isVisibleRef.current) { + capture(scroller.scrollTop) + } + }, [capture, retireLanding, scrollerRef]) + useLayoutEffect(takeReveal) + + // A hidden box has no geometry: hiding keeps the place (a pending landing's target), showing lands on it + // again unless a reveal in the same commit took the box. + const wasVisibleRef = useRef(isVisible) + useLayoutEffect(() => { + if (wasVisibleRef.current === isVisible) { + return + } + wasVisibleRef.current = isVisible + const target = landingRef.current?.target ?? positionRef.current + if (!isVisible) { + positionRef.current = target + retireLanding() + } else if (target && !followsEndRef.current && !revealedRef.current) { + positionRef.current = target + landingRef.current = { target, frames: 0 } + setHeldRowKey(target.key) + setLandingWake((count) => count + 1) + } + }, [followsEndRef, isVisible, retireLanding, setHeldRowKey]) + + // The reveal's own place, read once visibility is current; the next commit may restore again. + useLayoutEffect(() => { + const scroller = scrollerRef.current + if (revealedRef.current && !revealedPlaceRef.current && scroller && isVisibleRef.current) { + capture(scroller.scrollTop) + } + revealedRef.current = false + revealedPlaceRef.current = null + }) + useLayoutEffect(() => { + const scroller = scrollerRef.current + scroller?.addEventListener(REVEAL_EVENT, takeReveal) + return () => scroller?.removeEventListener(REVEAL_EVENT, takeReveal) + }, [scrollerRef, takeReveal]) + + // Moves the box toward the landing row. A correction owns one confirmation frame; past the frame budget the + // landing keeps its row drawn and waits for a size, row-list, measurement or visibility change. + useLayoutEffect(() => { + const landing = landingRef.current + const scroller = scrollerRef.current + if (!landing || !scroller || !isVisible) { + return + } + if (landing.seen?.totalSize !== totalSize || landing.seen.rowIndexByKey !== rowIndexByKey) { + landing.seen = { totalSize, rowIndexByKey } + landing.frames = 0 + } + const found = rowIndexByKey.get(landing.target.key) + const index = found === undefined ? -1 : (nextDrawing[found] ?? -1) + const row = rows[index] + if (!row) { + retireLanding() + return + } + if (row.key !== landing.target.key) { + landing.target = { key: row.key, offset: 0 } + setHeldRowKey(row.key) + } + const element = scroller.querySelector(`[${ROW_KEY_ATTRIBUTE}="${CSS.escape(row.key)}"]`) + const rect = element?.getBoundingClientRect() + const laidOut = rect !== undefined && rect.height > 0 + const delta = laidOut + ? rect.top - scroller.getBoundingClientRect().top + landing.target.offset + : (virtualizer.measurementsCache[index]?.start ?? scroller.scrollTop) + + landing.target.offset - + scroller.scrollTop + const atEnd = scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight - 1 + if (laidOut && (Math.abs(delta) <= 1 || (delta > 0 && atEnd))) { + positionRef.current = landing.target + retireLanding() + return + } + if (Math.abs(delta) > 1) { + marks.mark(scroller.scrollTop + delta) + scroller.scrollTop += delta + } else if (laidOut) { + return + } + if (landing.frames >= MAX_LANDING_FRAMES) { + return + } + landing.frames++ + const frame = window.requestAnimationFrame(() => { + if (landingRef.current === landing && isVisibleRef.current) { + setLandingWake((count) => count + 1) + } + }) + return () => window.cancelAnimationFrame(frame) + }, [ + isVisible, + landingWake, + marks, + nextDrawing, + retireLanding, + rowIndexByKey, + rows, + scrollerRef, + setHeldRowKey, + totalSize, + virtualizer + ]) + + /** The held row reported a real size: a waiting landing tries again, with a fresh confirmation budget. */ + const onRowMeasured = useCallback((key: string) => { + const landing = landingRef.current + if (landing?.target.key === key) { + landing.frames = 0 + setLandingWake((count) => count + 1) + } + }, []) + /** A landing is still moving the box: its own writes are not the reader arriving at the end. */ + const landingPending = useCallback((): boolean => landingRef.current !== null, []) + /** What the run keeps when it leaves: a pending landing's target is still the reader's place. */ + const current = useCallback( + (): NativeChatRunPosition | null => landingRef.current?.target ?? positionRef.current, + [] + ) + return useMemo( + () => ({ onScrolled, onRowMeasured, current, landingPending }), + [current, landingPending, onRowMeasured, onScrolled] + ) +} diff --git a/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts new file mode 100644 index 00000000000..f54240e1488 --- /dev/null +++ b/src/renderer/src/components/native-chat/tools/runs/use-native-chat-tool-run-window.ts @@ -0,0 +1,106 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { + elementScroll, + measureElement, + useVirtualizer, + type Virtualizer +} from '@tanstack/react-virtual' +import { VIRTUALIZED_LIST_OVERSCAN } from '@/components/virtualized-list' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { useLatestHandler } from '@/lib/use-latest-handler' +import type { NativeChatToolRunRow } from './native-chat-tool-run-rows' + +/** A tool line's `min-h-6.5`; measured rows replace it. */ +const ROW_ESTIMATE_PX = 26 +const estimateRowSize = (): number => ROW_ESTIMATE_PX + +export type NativeChatToolRunVirtualizer = Virtualizer +export type NativeChatToolRunDrawnRow = { index: number; start: number; end: number } + +/** The run's virtualizer, kept in its own hook: it reads a mutable library instance every render, + * so every live read (window, sizes, positions) happens here and leaves as plain values. */ +export function useNativeChatToolRunWindow({ + rows, + scrollerRef, + isVisible, + drawsEveryRow, + rangeExtractor, + marks, + onMeasure +}: { + rows: readonly NativeChatToolRunRow[] + scrollerRef: React.RefObject + isVisible: boolean + drawsEveryRow: boolean + rangeExtractor: (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }) => number[] + marks: ProgrammaticScrollMarks + /** Every real measurement, attach and resize alike, so the cap and follow see them all. */ + onMeasure: (element: HTMLDivElement) => void +}) { + const onMeasured = useLatestHandler(onMeasure) + // Stable per row list: a new key function makes the virtualizer rebuild every row's measurement. + const getItemKey = useCallback((index: number) => rows[index]?.key ?? index, [rows]) + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollerRef.current, + estimateSize: estimateRowSize, + getItemKey, + overscan: VIRTUALIZED_LIST_OVERSCAN, + rangeExtractor, + // A hidden pane has no boxes to measure; keep the last visible sizes. + useCachedMeasurements: !isVisible, + measureElement: (element, entry, instance) => { + const size = measureElement(element, entry, instance) + onMeasured(element) + return size + }, + // Mark every write the virtualizer makes so the reader's own scrolling stays distinguishable. + scrollToFn: (offset, options, instance) => { + const element = instance.scrollElement + const previous = element?.scrollTop + elementScroll(offset, options, instance) + if (element && element.scrollTop !== previous) { + marks.mark(element.scrollTop) + } + } + }) + const totalSize = virtualizer.getTotalSize() + const virtualItems = virtualizer.getVirtualItems() + const measurements = virtualizer.measurementsCache + // Nothing measurable (a hidden pane): keep what was drawn rather than unmounting the window. + const lastDrawnRef = useRef([]) + // Every row drawn: the same array while only the scroll position moves, so the rows are not rebuilt. + const everyRow = useMemo( + () => + drawsEveryRow + ? Array.from({ length: rows.length }, (_, index) => ({ index, start: 0, end: 0 })) + : null, + [drawsEveryRow, rows.length] + ) + // The window's rows keep their identity while the virtualizer's items and sizes do, so an + // unchanged window (a large selection span, say) re-renders no rows. + const windowRows = useMemo( + () => + everyRow || virtualItems.length === 0 + ? null + : virtualItems.map((item) => ({ + index: item.index, + start: measurements[item.index]?.start ?? 0, + end: measurements[item.index]?.end ?? 0 + })), + [everyRow, measurements, virtualItems] + ) + useLayoutEffect(() => { + if (windowRows) { + lastDrawnRef.current = windowRows + } + }) + const drawn: readonly NativeChatToolRunDrawnRow[] = + everyRow ?? windowRows ?? lastDrawnRef.current.filter((row) => row.index < rows.length) + return { virtualizer, measureRow: virtualizer.measureElement, totalSize, drawn } +} diff --git a/src/renderer/src/components/native-chat/tools/tasks/NativeChatTaskList.tsx b/src/renderer/src/components/native-chat/tools/tasks/NativeChatTaskList.tsx index 7f2b127929b..9996106009a 100644 --- a/src/renderer/src/components/native-chat/tools/tasks/NativeChatTaskList.tsx +++ b/src/renderer/src/components/native-chat/tools/tasks/NativeChatTaskList.tsx @@ -3,6 +3,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { cn } from '@/lib/class-names' import { translate } from '@/i18n/i18n' import { useTranslate, type Translate } from '@/i18n/use-translate' +import { useNativeChatDisclosure } from '../../messages/disclosure/native-chat-disclosure-store' import { diffNativeChatTaskLists, nativeChatCompletedTaskCount, @@ -92,13 +93,17 @@ function taskProgressLabel(completed: number, total: number, t: Translate = tran export function NativeChatTaskList({ list, previous, - presentation = 'inline' + presentation = 'inline', + disclosureKey }: { list: TaskList previous?: TaskList presentation?: 'inline' | 'composer' + /** Identity the full list's open state is remembered under while its row is unmounted. */ + disclosureKey?: string }): React.JSX.Element { const t = useTranslate() + const fullList = useNativeChatDisclosure(disclosureKey, false) const completed = nativeChatCompletedTaskCount(list) if (presentation === 'composer') { return ( @@ -159,7 +164,7 @@ export function NativeChatTaskList({ {t('components.native-chat.taskList.unchanged', 'Tasks unchanged')}

)} - +