perf(native-chat): draw only the visible rows of an opened tool run (#41552)

* perf(native-chat): draw only the visible rows of an opened tool run

An opened tool run rendered every call it held, so a long autonomous stretch
kept thousands of rows, their DOM and their per-row state alive while only a
dozen were visible in the run's capped box.

The run now owns a keyed window over its rows (the shared TanStack
virtualizer plus the existing row+offset scroll anchor):
- rows keep today's keys and order; small runs and Find-with-a-query draw
  every row through the same keyed tree, so nothing remounts at the switch
- the reader is remembered as a row and offset (pixel top while unmeasured),
  following the end only for new work, and forgetting on close as before
- the box's cap keeps the measured height of opened details, including ones
  scrolled out of view; the full task list now remembers its open state
- the first/last rows, a diff reveal target, recently interacted rows, the
  focused row's Tab neighbours and a text selection's span stay drawn

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): finish tool-run follow, keep reveals, bound per-move work

Review repairs for the windowed tool run:
- a follow started by new work always gets its completion frame, so a
  result that adds no row cannot leave it armed to chase later detail growth
- the row anchor restores only when rows arrive or the pane shows again,
  so a diff reveal or the reader's own scrolling is not pulled back
- opened-detail room is kept incrementally and travels with the run's
  remembered position across outer windowing
- focus neighbours, item keys and window rows no longer cost work per move
  proportional to the run's length; selection is tracked only while visible
- thoughts with no words are not keyboard stops; the restored row stays
  drawn until the reader moves

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): finish hidden follows, let reveals win, keep draw-all scrolling free

Second review repair for the windowed tool run:
- a follow is certified only by a real, visible tail measurement, and that
  measurement wakes it even when no size changed; a hidden pane resumes it
- a diff reveal cancels any pending restore and follow on every request
- a hidden pane returns to its row (not its reset pixel offset) on show
- draw-every-row mode (Find, short runs) rebuilds nothing as the reader
  scrolls; selection spans re-derive after the pane was hidden
- remembered detail heights are copied only when one changes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): certify a tool-run follow only by a laid-out tail

A follow's landing is confirmed only when the last row that draws something
has a real, non-zero size; a measurement callback carrying a cached or zero
size no longer ends it early, and a run whose rows draw nothing has no
landing to wait for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): keep draw-all tool runs cheap per scroll and prove follow wake paths

Draw-all (Find) no longer records the scroll anchor or rebuilds the selection span
on every scroll, and its trailing spacer skips the end scan. Tests now cover a task
list crossing the window threshold and Find, and pin both follow-completion paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): skip anchor scans under a wide selection and recover the reader's row after

A selection wider than a window keeps its rows drawn, so recording the anchor on
each scroll would scan them all; recording now waits, as it does under Find. The
reader's row is then taken from the virtualizer's offsets when recording resumes
or the pane hides, so showing it again does not return to an older row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): type the tool-run selection-span gate and settle the anchor on hide

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): merge duplicate tool-run window import

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(native-chat): prune the structured session's compiler bailout after the main sync

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): keep the tool-run measurement callback current without a render-time ref write

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): keep a reader who jumps past an open run's drawn rows where they jumped

The window redraws inside the scroll that moves it, and a restore still pending
from earlier work could run first and send the reader back to the row they left.
The reader's own scroll now points the anchor at their row from the window's
offsets before that redraw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): re-point the anchor on reader scrolls only while the run is windowed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): keep a hidden tool run's saved row from its box's reset offset

A hidden box resets its offset and may report that as a scroll or as the offset
at unmount. Scroll recording (the run's and the anchor hook's) and the hook's
unmount record now wait while the pane is hidden, so the row kept at hide time is
the one a returning reader lands on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): give an opened tool run sole ownership of its reader's place

The run and the shared scroll-anchor hook each decided when a saved row still owned
the box, and each local exclusion left another path for a stale restore or capture.
The run now keeps one row-and-offset place from the window's full measurement model,
admits a landing only on mount or show, lets every reader scroll retire pending
work first, records nothing while hidden, and takes a reveal before any landing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): confirm every tool-run landing correction and model the browser's scroll report in tests

A real change (size, row list, the held row's measurement) now grants a landing a
fresh, bounded confirmation budget, so a correction made after an earlier budget ran
out is still confirmed. A pending landing's own writes no longer re-arm following.
The reader harness delivers the scroll event a browser sends after an offset change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(native-chat): keep the reveal-return case on one memory record and before the reveal's scroll report

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(native-chat): return the reader harness's scroll writes to their earlier behaviour (rig-stop ruling)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): keep a revealed file's own row as the tool run's saved place

A reveal now records the card's row and offset from the page as the card aligns,
rather than reading the window's size model, which may still hold that row's
collapsed height. Remount tests report the landing's own scroll the way a browser
would; the pinned reveal case needs no report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(native-chat): read an opened tool run's reader place from the rows actually drawn

The window's size model can hold a row's old height for a frame after it grows, so a
scroll report read through it could record a later row than the reader sees. Capture
now takes the row at the box top from the drawn rows' own geometry, found by a binary
search over the run's in-flow children, and uses the model only where the top lies in
an undrawn gap; a box with no geometry keeps the last place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(native-chat): type the reader-position owner test's rows without assertions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-11-12 23:06:24 -08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a5f68e9d4f
commit 842e4e0077
19 changed files with 2366 additions and 168 deletions
@@ -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
@@ -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
@@ -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' ? (
<NativeChatEmptyState kind="empty" agent={agent} />
) : (
<NativeChatMessageList
ref={messageListRef}
session={sessionWithPending}
isVisible={isVisible}
isWorking={turnActive}
expandSignal={false}
{...turnTiming}
awaitingInput={awaitingInput}
deliveryNotices={deliveryNotices}
runtimeContext={imageRuntimeContext}
/>
<NativeChatToolRunWindowProvider isVisible={isVisible} find={find}>
<NativeChatMessageList
ref={messageListRef}
session={sessionWithPending}
isVisible={isVisible}
isWorking={turnActive}
expandSignal={false}
{...turnTiming}
awaitingInput={awaitingInput}
deliveryNotices={deliveryNotices}
runtimeContext={imageRuntimeContext}
/>
</NativeChatToolRunWindowProvider>
)}
</NativeChatCrewFrame>
{/* A collapsed card stays mounted but hidden, so a partly answered question survives. */}
@@ -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<NativeChatStructuredView
view={continuation.view}
>
<NativeChatHostSetupContext.Provider value={setup}>
<NativeChatWorkerApprovalTimeline party={workerApprovalParty}>
{(timedRows) => (
<NativeChatMessageList
// A rewind or a /clear replaces the conversation; nothing the old one held carries over.
key={`${controller.epoch}:${controller.contextClear.clears}`}
ref={submits.messageListRef}
session={session}
journalItems={controller.journalItems}
journalSubmissions={controller.submissions}
journalLatestTurn={controller.latestTurn}
journalHostWork={controller.hostWork}
subagentRoster={controller.subagentRoster}
railOutline={controller.railOutline}
isVisible={props.isVisible}
isWorking={controller.isWorking}
expandSignal={false}
workingStartedAt={controller.workingStartedAt}
settledTurns={controller.settledTurns}
awaitingInput={prompt === null ? null : 'shown'}
turnActivity={controller.turnActivity}
stopping={stopControls.stopping}
structuredAttachments
runtimeContext={imageRuntimeContext}
deliveryNotices={deliveryNotices}
timedRows={timedRows}
/>
)}
</NativeChatWorkerApprovalTimeline>
<NativeChatToolRunWindowProvider isVisible={props.isVisible} find={find}>
<NativeChatWorkerApprovalTimeline party={workerApprovalParty}>
{(timedRows) => (
<NativeChatMessageList
// A rewind or a /clear replaces the conversation; nothing the old one held carries over.
key={`${controller.epoch}:${controller.contextClear.clears}`}
ref={submits.messageListRef}
session={session}
journalItems={controller.journalItems}
journalSubmissions={controller.submissions}
journalLatestTurn={controller.latestTurn}
journalHostWork={controller.hostWork}
subagentRoster={controller.subagentRoster}
railOutline={controller.railOutline}
isVisible={props.isVisible}
isWorking={controller.isWorking}
expandSignal={false}
workingStartedAt={controller.workingStartedAt}
settledTurns={controller.settledTurns}
awaitingInput={prompt === null ? null : 'shown'}
turnActivity={controller.turnActivity}
stopping={stopControls.stopping}
structuredAttachments
runtimeContext={imageRuntimeContext}
deliveryNotices={deliveryNotices}
timedRows={timedRows}
/>
)}
</NativeChatWorkerApprovalTimeline>
</NativeChatToolRunWindowProvider>
</NativeChatHostSetupContext.Provider>
</NativeChatUsageLimitActionsProvider>
</NativeChatRowActionsProvider>
@@ -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<string, number>
}
export type NativeChatDisclosureStore = {
read: (key: string) => boolean | undefined
@@ -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"
/>
<NativeChatExpandable open={open && showsHeader}>
<NativeChatToolRunMembers
<NativeChatToolRunMemberList
blocks={blocks}
headerBlocks={headerBlocks}
asides={asides}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
revealedDiff={revealedDiff}
onRevealDiff={revealDiff}
disclosureId={disclosureId}
followKey={blocks.length + asideCount}
startsAtEnd={live && !revealedDiff}
memoryKey={runKey}
>
<NativeChatToolRunMemberList
blocks={blocks}
headerBlocks={headerBlocks}
asides={asides}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
revealedDiff={revealedDiff}
onRevealDiff={revealDiff}
disclosureId={disclosureId}
/>
</NativeChatToolRunMembers>
/>
</NativeChatExpandable>
</div>
)
@@ -43,6 +43,8 @@ function MemberDisclosureHarness({
blocks={blocks}
headerBlocks={blocks}
disclosureId="message-1"
followKey={blocks.length}
startsAtEnd={false}
/>
) : null}
</NativeChatDisclosureContext.Provider>
@@ -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<string, number>()
const member = (block: NativeChatBlock, blockIndex: number): React.ReactNode => {
const taskList = taskLists.rows.get(block)
if (taskList) {
return <NativeChatTaskList key={`tasks:${blockIndex}`} {...taskList} />
}
if (taskLists.consumedResults.has(block)) {
return null
}
const edit = editCards.get(block)
if (edit) {
return (
<div key={`edit:${edit.key}`}>
{edit.files.map((file, fileIndex) => (
const renderRow = useCallback(
(row: NativeChatToolRunRow): React.ReactNode => {
switch (row.kind) {
case 'aside':
return row.element
case 'tasks':
return (
<NativeChatTaskList
{...row.taskList}
disclosureKey={
disclosureId === undefined ? undefined : `tasks:${disclosureId}:${row.key}`
}
/>
)
case 'edit':
return row.files.map((file, fileIndex) => (
<NativeChatDiffCard
key={`${edit.key}:${fileIndex}`}
key={`${row.editKey}:${fileIndex}`}
file={file}
revealSignal={
revealedDiff?.editKey === edit.key && revealedDiff.fileIndex === fileIndex
revealedDiff?.editKey === row.editKey && revealedDiff.fileIndex === fileIndex
? revealedDiff.requestId
: undefined
}
@@ -75,38 +80,33 @@ export function NativeChatToolRunMemberList({
disclosureKey={
disclosureId === undefined
? undefined
: `diff:${disclosureId}:${edit.key}:${fileIndex}`
: `diff:${disclosureId}:${row.editKey}:${fileIndex}`
}
/>
))}
</div>
)
}
// 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 (
<NativeChatToolLine
key={lineIdentity}
block={block}
result={block.type === 'tool-call' ? resultByCall.get(block) : undefined}
disclosureKey={
disclosureId === undefined ? undefined : `line:${disclosureId}:${lineIdentity}`
}
/>
)
}
))
case 'line':
return (
<NativeChatToolLine
block={row.block}
result={row.result}
disclosureKey={
disclosureId === undefined ? undefined : `line:${disclosureId}:${row.lineIdentity}`
}
/>
)
}
},
[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}
</>
<NativeChatToolRunMembers
rows={rows}
renderRow={renderRow}
followKey={followKey}
startsAtEnd={startsAtEnd}
memoryKey={memoryKey}
revealRowKey={revealedDiff ? `edit:${revealedDiff.editKey}` : undefined}
/>
)
}
@@ -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<string, number>()
/** A hidden pane is display:none: nothing in it has a size. */
const pane = { hidden: false }
const resizeObservers = new Set<StubResizeObserver>()
class StubResizeObserver implements ResizeObserver {
readonly targets = new Set<Element>()
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<HTMLElement>('[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<Element, number>()
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 (
<NativeChatDisclosureContext.Provider value={disclosures}>
<NativeChatToolRunWindowContext.Provider value={windowFacts}>
{mounted ? (
<NativeChatToolRun
blocks={blocks}
expandSignal
activeTurnIsWorking={live}
disclosureId="message-1"
revealedDiff={revealedDiff}
onRevealDiff={ignoreRevealTarget}
/>
) : null}
</NativeChatToolRunWindowContext.Provider>
</NativeChatDisclosureContext.Provider>
)
}
function membersBox(container: HTMLElement): HTMLElement {
const element = container.querySelector<HTMLElement>('[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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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(<Transcript blocks={calls(1_000)} />)
await settle()
await scrollBox(container, 9_013)
const before = topRow(container)
expect(before.key).not.toBeNull()
expect(before.offset).toBeGreaterThan(0)
rerender(<Transcript blocks={calls(1_000)} mounted={false} />)
rerender(<Transcript blocks={calls(1_010)} />)
// Evicted again before that return lands: the place it was landing on is what it keeps.
rerender(<Transcript blocks={calls(1_010)} mounted={false} />)
rerender(<Transcript blocks={calls(1_020)} />)
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(<Transcript blocks={blocks} />)
await settle()
await scrollBox(container, 2_000)
const reveal = { messageId: 'message-1', editKey: 'apply_patch:400', fileIndex: 1 }
rerender(<Transcript blocks={blocks} revealedDiff={{ ...reveal, requestId: 1 }} />)
await settle()
expect(topRow(container).key).toBe('edit:apply_patch:400')
await scrollBox(container, 2_000)
rerender(<Transcript blocks={blocks} revealedDiff={{ ...reveal, requestId: 2 }} />)
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(<Transcript blocks={calls(1_000)} />)
await settle()
await scrollBox(container, 9_013)
const header = container.querySelector<HTMLElement>('[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(<Transcript blocks={calls(200)} live />)
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(<Transcript blocks={calls(260)} live />)
await settle()
expect(atEnd()).toBe(true)
const withResult = [
...calls(260),
{ type: 'tool-result' as const, callId: 'call-259', output: 'done' }
]
rerender(<Transcript blocks={withResult} live />)
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(<Transcript blocks={[...withResult, ...calls(270).slice(260)]} live />)
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(<Transcript blocks={blocks} />)
await settle()
await scrollBox(container, 2_000)
rerender(<Transcript blocks={blocks} mounted={false} />)
rerender(<Transcript blocks={blocks} />)
// No settling: the returning restore is still pending when the reveal arrives.
const reveal = { messageId: 'message-1', editKey: 'apply_patch:400', fileIndex: 1 }
rerender(<Transcript blocks={blocks} revealedDiff={{ ...reveal, requestId: 1 }} />)
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(
<Transcript blocks={blocks} visible={false} revealedDiff={{ ...reveal, requestId: 1 }} />
)
pane.hidden = false
rerender(<Transcript blocks={blocks} revealedDiff={{ ...reveal, requestId: 2 }} />)
pane.hidden = true
rerender(
<Transcript blocks={blocks} visible={false} revealedDiff={{ ...reveal, requestId: 2 }} />
)
pane.hidden = false
rerender(<Transcript blocks={blocks} revealedDiff={{ ...reveal, requestId: 2 }} />)
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(<Transcript blocks={calls(200)} live />)
await settle()
const box = membersBox(container)
rerender(<Transcript blocks={calls(260)} live />)
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(<Transcript blocks={calls(200)} live />)
await settle()
pane.hidden = true
rerender(<Transcript blocks={calls(200)} live visible={false} />)
rerender(<Transcript blocks={calls(230)} live visible={false} />)
await settle()
pane.hidden = false
rerender(<Transcript blocks={calls(230)} live />)
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(<Transcript blocks={calls(200)} live />)
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(<Transcript blocks={calls(200)} live />)
await settle()
grownRows.set('call:call-259', 0)
rerender(<Transcript blocks={calls(260)} live />)
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(<Transcript blocks={calls(1_000)} />)
await settle()
rerender(<Transcript blocks={calls(1_000)} find />)
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(<Transcript blocks={calls(1_000)} find visible={false} />)
membersBox(container).scrollTop = 0
pane.hidden = false
rerender(<Transcript blocks={calls(1_000)} find />)
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(<Transcript blocks={calls(29)} live />)
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(<Transcript blocks={calls(count)} live />)
await settle()
}
// A scrollbar drag lands far from every drawn row; then work arrives.
await scrollBox(container, 683)
rerender(<Transcript blocks={calls(63)} live />)
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(<Transcript blocks={calls(first)} live find={find} />)
await settle()
if (!find) {
pane.hidden = true
rerender(<Transcript blocks={calls(first)} live visible={false} />)
pane.hidden = false
rerender(<Transcript blocks={calls(first)} live />)
await settle()
}
for (let count = first + 1; count <= first + 15; count++) {
rerender(<Transcript blocks={calls(count)} live find={find} />)
await settle()
}
const box = membersBox(container)
const away = box.scrollHeight - BOX_HEIGHT_PX - 120
await scrollBox(container, away)
expect(box.scrollTop).toBe(away)
rerender(<Transcript blocks={calls(first + 16)} live find={find} />)
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(<Transcript blocks={calls(1_000)} />)
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(<Transcript blocks={calls(1_000)} visible={false} />)
// A hidden box loses its offset and may report that as a scroll; the transcript then unmounts the run.
await scrollBox(container, 0)
rerender(<Transcript blocks={calls(1_000)} visible={false} mounted={false} />)
pane.hidden = false
rerender(<Transcript blocks={calls(1_000)} />)
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(<Transcript blocks={calls(49)} />)
await settle()
await scrollBox(container, 613)
const before = topRow(container)
rerender(<Transcript blocks={calls(50)} />)
await settle()
expect(topRow(container)).toEqual(before)
rerender(<Transcript blocks={calls(50)} find />)
await settle()
expect(topRow(container)).toEqual(before)
rerender(<Transcript blocks={calls(50)} />)
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(<Transcript blocks={calls(1_000)} find />)
await settle()
await scrollBox(container, 9_013)
const before = topRow(container)
pane.hidden = true
rerender(<Transcript blocks={calls(1_000)} find visible={false} />)
membersBox(container).scrollTop = 0
// Shown, but its box has no geometry for longer than any landing waits on frames alone.
rerender(<Transcript blocks={calls(1_000)} find />)
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(<Transcript blocks={calls(1_000)} />)
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(<Transcript blocks={calls(1_000)} visible={false} />)
await act(async () => {
document.getSelection()?.removeAllRanges()
document.dispatchEvent(new Event('selectionchange'))
})
pane.hidden = false
rerender(<Transcript blocks={calls(1_000)} />)
await settle()
expect(container.querySelectorAll(ROW).length).toBeLessThan(100)
})
})
@@ -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<HTMLElement>('[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(
<Fragment key={row.key}>
{start > previousEnd + 0.5 ? (
<div aria-hidden style={{ height: start - previousEnd }} />
) : null}
<div ref={measureRow} data-index={index} data-native-chat-tool-run-row={row.key}>
<WindowRow row={row} renderRow={renderRow} />
</div>
</Fragment>
)
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<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(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<number | null>(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<HTMLDivElement>(`[${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<HTMLDivElement>) => {
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 (
<div
ref={scrollerRef}
onScroll={readPosition}
onScroll={onScroll}
className={cn(
'scrollbar-sleek ml-1.75 mt-0.5 overflow-y-auto border-l border-chat-code-border pl-3.25',
'scrollbar-sleek ml-1.75 mt-0.5 overflow-y-auto border-l border-chat-code-border pl-3.25 [overflow-anchor:none]',
// Contains the members' absolute sr-only labels, which otherwise lengthen the page.
'relative',
// Room for the list plus what the reader has opened in it, up to half the window.
@@ -125,7 +370,15 @@ export function NativeChatToolRunMembers({
)}
data-native-chat-tool-run-members
>
<div ref={contentRef}>{children}</div>
<div
onClickCapture={onReaderAct}
onKeyDownCapture={onReaderAct}
onFocusCapture={pins.onFocusCapture}
onBlurCapture={pins.onBlurCapture}
>
<WindowRows drawn={drawn} rows={rows} renderRow={renderRow} measureRow={measureRow} />
{trailing > 0.5 ? <div aria-hidden style={{ height: trailing }} /> : null}
</div>
</div>
)
}
@@ -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 (
<NativeChatDisclosureContext.Provider value={disclosures}>
<NativeChatToolRunWindowContext.Provider value={windowFacts}>
<NativeChatToolRun
blocks={blocks}
expandSignal
activeTurnIsWorking={false}
disclosureId="message-1"
/>
</NativeChatToolRunWindowContext.Provider>
</NativeChatDisclosureContext.Provider>
)
}
function drawnRows(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('[data-native-chat-tool-run-row]'))
}
function rowButton(container: HTMLElement, index: number): HTMLElement {
const button = container.querySelector<HTMLElement>(
`[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(<Run count={count} />)
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(<Run count={200} />)
const first = rowButton(container, 0)
expect(drawnRows(container).length).toBeLessThanOrEqual(MAX_DRAWN_ROWS)
rerender(<Run count={200} findActive />)
expect(drawnRows(container)).toHaveLength(200)
expect(rowButton(container, 0)).toBe(first)
rerender(<Run count={200} />)
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(<Run count={49} blocks={withTaskList(49)} />)
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(<Run count={50} blocks={withTaskList(50)} />)
expect(focused).toBeInTheDocument()
expect(document.activeElement).toBe(focused)
expect(screen.getByRole('button', { name: /Full task list/ })).toHaveAttribute(
'aria-expanded',
'true'
)
rerender(<Run count={50} blocks={withTaskList(50)} findActive />)
rerender(<Run count={50} blocks={withTaskList(50)} />)
expect(screen.getByRole('button', { name: /Full task list/ })).toHaveAttribute(
'aria-expanded',
'true'
)
})
})
@@ -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<string, number>()
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
}
@@ -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<NativeChatToolRunWindowFacts>({
isVisible: true,
findActive: false
})
export function NativeChatToolRunWindowProvider({
isVisible,
find,
children
}: {
isVisible: boolean
find: Pick<NativeChatFind, 'isOpen' | 'query'>
children: React.ReactNode
}): React.JSX.Element {
const findActive = find.isOpen && find.query !== ''
const facts = useMemo(() => ({ isVisible, findActive }), [findActive, isVisible])
return (
<NativeChatToolRunWindowContext.Provider value={facts}>
{children}
</NativeChatToolRunWindowContext.Provider>
)
}
@@ -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<HTMLElement>('[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<HTMLDivElement | null>
rowIndexByKey: ReadonlyMap<string, number>
initial: ReadonlyMap<string, number> | 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<string, number> = 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<string, number>): 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<string, number> => {
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])
}
@@ -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<string, number>
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<HTMLDivElement | null>
}) {
const [interactionKeys, setInteractionKeys] = useState<readonly string[]>([])
const [focusedKey, setFocusedKey] = useState<string | null>(null)
const [selectionSpan, setSelectionSpan] = useState<readonly [number, number] | null>(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<number>()
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 }
}
@@ -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: <span />
}))
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<string, HTMLDivElement>
} {
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<string, HTMLDivElement>()
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<string, HTMLDivElement>,
positions: Record<string, [number, number]>
): 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 })
})
})
@@ -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<Element, NativeChatRunPosition | null>()
// 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<string, number>
): 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<string, number> }
}
/** 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<HTMLDivElement | null>
virtualizer: NativeChatToolRunVirtualizer
rows: readonly NativeChatToolRunRow[]
rowIndexByKey: ReadonlyMap<string, number>
isVisible: boolean
followsEndRef: React.RefObject<boolean>
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<NativeChatRunPosition | null>(initial ?? null)
const landingRef = useRef<Landing | null>(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<NativeChatRunPosition | null>(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]
)
}
@@ -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<HTMLDivElement, HTMLDivElement>
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<HTMLDivElement | null>
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<HTMLDivElement, HTMLDivElement>({
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<readonly NativeChatToolRunDrawnRow[]>([])
// Every row drawn: the same array while only the scroll position moves, so the rows are not rebuilt.
const everyRow = useMemo<readonly NativeChatToolRunDrawnRow[] | null>(
() =>
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<readonly NativeChatToolRunDrawnRow[] | null>(
() =>
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 }
}
@@ -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')}
</p>
)}
<Collapsible>
<Collapsible open={fullList.open} onOpenChange={fullList.setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"