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

)} - +