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')}
)}
-
+