From 8dd40c176d87cf6704e18944117ee72449ca2da2 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 01:11:31 +0530 Subject: [PATCH 1/7] feat(react): add VirtualList windowing primitive Shared windowed-list primitive for the web UI virtualization effort: @tanstack/react-virtual with dynamic measurement, an onEndReached infinite-scroll trigger generalized from the runs shell's hand-rolled distance-from-bottom check, optional sticky section headers, and net-new scroll restoration keyed by id. Pure helpers (isNearBottom + the scroll-position store) are unit-tested; the component is auto-exported via the existing ./components/* glob. --- bun.lock | 5 + packages/react/package.json | 1 + .../react/src/components/virtual-list.test.ts | 50 ++++ .../react/src/components/virtual-list.tsx | 250 ++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 packages/react/src/components/virtual-list.test.ts create mode 100644 packages/react/src/components/virtual-list.tsx diff --git a/bun.lock b/bun.lock index 0509cc8701..7f6bb4006a 100644 --- a/bun.lock +++ b/bun.lock @@ -1197,6 +1197,7 @@ "@shikijs/langs": "^4.0.2", "@shikijs/themes": "^4.0.2", "@tanstack/react-router": "catalog:", + "@tanstack/react-virtual": "^3.14.3", "@tanstack/virtual-file-routes": "^1.162.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2888,6 +2889,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.3", "", { "dependencies": { "@tanstack/virtual-core": "3.17.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ=="], + "@tanstack/router-cli": ["@tanstack/router-cli@1.167.17", "", { "dependencies": { "@tanstack/router-generator": "1.167.17", "chokidar": "^5.0.0", "yargs": "^17.7.2" }, "bin": { "tsr": "bin/tsr.cjs" } }, "sha512-kws9PNdspkHbeZG6aLDjjpgH/hg7Q565BdSW9hHrjKmXPq/at7OKxQnA76aw9dJiJqy9OmST2RdOKlMEog43+g=="], "@tanstack/router-core": ["@tanstack/router-core@1.168.15", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA=="], @@ -2910,6 +2913,8 @@ "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], diff --git a/packages/react/package.json b/packages/react/package.json index ed7e05dd56..b776860d7c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -39,6 +39,7 @@ "@shikijs/langs": "^4.0.2", "@shikijs/themes": "^4.0.2", "@tanstack/react-router": "catalog:", + "@tanstack/react-virtual": "^3.14.3", "@tanstack/virtual-file-routes": "^1.162.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/react/src/components/virtual-list.test.ts b/packages/react/src/components/virtual-list.test.ts new file mode 100644 index 0000000000..0131a01ff1 --- /dev/null +++ b/packages/react/src/components/virtual-list.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + clearScrollPosition, + DEFAULT_END_REACHED_THRESHOLD, + isNearBottom, + readScrollPosition, + saveScrollPosition, +} from "./virtual-list"; + +describe("isNearBottom", () => { + it("is true exactly at the threshold and false just above it", () => { + // distanceFromBottom = scrollHeight - scrollTop - clientHeight + const metrics = { scrollHeight: 1000, clientHeight: 400, scrollTop: 600 - 320 }; + // distance = 1000 - 280 - 400 = 320 === threshold → near + expect(isNearBottom(metrics, 320)).toBe(true); + expect(isNearBottom({ ...metrics, scrollTop: metrics.scrollTop - 1 }, 320)).toBe(false); + }); + + it("is true once scrolled to the very bottom", () => { + expect(isNearBottom({ scrollHeight: 5000, clientHeight: 800, scrollTop: 4200 }, 320)).toBe( + true, + ); + }); + + it("is false near the top of a long list", () => { + expect(isNearBottom({ scrollHeight: 5000, clientHeight: 800, scrollTop: 0 }, 320)).toBe(false); + }); + + it("exposes the runs-shell default threshold", () => { + expect(DEFAULT_END_REACHED_THRESHOLD).toBe(320); + }); +}); + +describe("scroll position store", () => { + it("round-trips a saved offset by id", () => { + clearScrollPosition("runs"); + expect(readScrollPosition("runs")).toBeUndefined(); + saveScrollPosition("runs", 742); + expect(readScrollPosition("runs")).toBe(742); + }); + + it("keeps ids independent and clears one without touching the other", () => { + saveScrollPosition("a", 10); + saveScrollPosition("b", 20); + clearScrollPosition("a"); + expect(readScrollPosition("a")).toBeUndefined(); + expect(readScrollPosition("b")).toBe(20); + }); +}); diff --git a/packages/react/src/components/virtual-list.tsx b/packages/react/src/components/virtual-list.tsx new file mode 100644 index 0000000000..15337021fc --- /dev/null +++ b/packages/react/src/components/virtual-list.tsx @@ -0,0 +1,250 @@ +import * as React from "react"; +import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual"; + +import { cn } from "../lib/utils"; + +// --------------------------------------------------------------------------- +// VirtualList — the shared windowed-list primitive. +// +// Renders only the rows near the viewport for an arbitrary item array, so a +// list of thousands (the worst case today is a single source's ~3k tools) keeps +// a bounded DOM instead of mounting every row at once. Three concerns the web +// UI re-implements ad hoc are folded in here so every surface behaves the same: +// +// 1. Windowing — @tanstack/react-virtual with dynamic measurement, so rows of +// uneven height (collapsible log/tool-call blocks) work without a fixed +// itemHeight. +// 2. End-reached — the infinite-scroll trigger generalized from the runs +// shell's hand-rolled `scrollHeight - scrollTop - clientHeight < 320`. +// 3. Scroll restoration — net-new: a list's scroll offset survives unmount / +// remount (route change, drawer close→open) keyed by `scrollRestoreId`. +// +// Optional sticky headers (e.g. the tool tree's per-account section headers) +// are supported by marking those indices via `isSticky`: the active header is +// always kept in the render window and pinned to the top of the scroller. +// +// The runs table can't use this component directly (a real needs the +// scroller to wrap +); it composes the same building blocks +// exported below — `isNearBottom`, the scroll-position store, and the shared +// constants — against its own spacer rows. +// --------------------------------------------------------------------------- + +export const DEFAULT_END_REACHED_THRESHOLD = 320; +export const DEFAULT_ESTIMATE_SIZE = 36; +export const DEFAULT_OVERSCAN = 10; + +// --- scroll restoration ---------------------------------------------------- +// Module-level map of last-seen scrollTop keyed by a caller-provided id. Lives +// for the lifetime of the page so a list re-mounted on the same id (back nav, +// reopened drawer) lands where the user left it. Pure data — unit-testable. +const scrollPositions = new Map(); + +export function saveScrollPosition(id: string, top: number): void { + scrollPositions.set(id, top); +} + +export function readScrollPosition(id: string): number | undefined { + return scrollPositions.get(id); +} + +export function clearScrollPosition(id: string): void { + scrollPositions.delete(id); +} + +// --- end-reached math (pure, unit-testable) -------------------------------- +export interface ScrollMetrics { + readonly scrollHeight: number; + readonly scrollTop: number; + readonly clientHeight: number; +} + +/** True when the scroller is within `threshold` px of the bottom. */ +export function isNearBottom(metrics: ScrollMetrics, threshold: number): boolean { + return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold; +} + +// --- imperative handle ----------------------------------------------------- +export interface VirtualListHandle { + scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void; + scrollToOffset: (offset: number) => void; +} + +export interface VirtualListProps { + readonly items: readonly T[]; + readonly renderItem: (item: T, index: number) => React.ReactNode; + readonly getKey: (item: T, index: number) => React.Key; + /** Estimated row height in px, used before a row is measured. Default 36. */ + readonly estimateSize?: (index: number) => number; + readonly overscan?: number; + /** Classes for the scroll container (must establish the scroll viewport). */ + readonly className?: string; + /** Mark an index as a sticky section header — pinned to the top while its + * section scrolls beneath it, and always kept in the render window. */ + readonly isSticky?: (index: number) => boolean; + /** Fired when the viewport reaches within `endReachedThreshold` px of the + * bottom. Re-entrancy is the caller's responsibility (guard on + * hasMore/isLoading), matching the existing runs-shell contract. */ + readonly onEndReached?: () => void; + readonly endReachedThreshold?: number; + /** Preserve/restore scroll offset across unmount under this id. */ + readonly scrollRestoreId?: string; + readonly isLoading?: boolean; + readonly renderLoading?: () => React.ReactNode; + readonly renderEmpty?: () => React.ReactNode; + /** Rendered in normal flow below the windowed rows — e.g. a "Loading more…" + * / "End of history" sentinel. */ + readonly footer?: React.ReactNode; + readonly apiRef?: React.Ref; + readonly "aria-label"?: string; +} + +export function VirtualList(props: VirtualListProps) { + const { + items, + renderItem, + getKey, + estimateSize, + overscan = DEFAULT_OVERSCAN, + className, + isSticky, + onEndReached, + endReachedThreshold = DEFAULT_END_REACHED_THRESHOLD, + scrollRestoreId, + isLoading, + renderLoading, + renderEmpty, + footer, + apiRef, + } = props; + + const scrollRef = React.useRef(null); + + // Indices that pin to the top. Recomputed when the row set changes. + const stickyIndices = React.useMemo(() => { + if (!isSticky) return [] as number[]; + const acc: number[] = []; + for (let i = 0; i < items.length; i++) { + if (isSticky(i)) acc.push(i); + } + return acc; + }, [items, isSticky]); + + // The header currently pinned — the last sticky index at or above the top of + // the window. Tracked in a ref so the range extractor can keep it mounted. + const activeStickyRef = React.useRef(null); + + const rangeExtractor = React.useCallback( + (range: Range): number[] => { + if (stickyIndices.length === 0) { + activeStickyRef.current = null; + return defaultRangeExtractor(range); + } + let active: number | null = null; + for (const idx of stickyIndices) { + if (idx <= range.startIndex) active = idx; + else break; + } + activeStickyRef.current = active; + const indices = new Set(defaultRangeExtractor(range)); + if (active !== null) indices.add(active); + return [...indices].sort((a, b) => a - b); + }, + [stickyIndices], + ); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: estimateSize ?? (() => DEFAULT_ESTIMATE_SIZE), + overscan, + rangeExtractor, + }); + + React.useImperativeHandle( + apiRef, + () => ({ + scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options), + scrollToOffset: (offset) => virtualizer.scrollToOffset(offset), + }), + [virtualizer], + ); + + // Restore on mount; persist on unmount. Restore runs in a layout effect so the + // jump happens before paint. The estimate-based total size is in place by then, + // which is close enough for keyset lists; exact dynamic-measure drift is minor. + React.useLayoutEffect(() => { + const el = scrollRef.current; + if (!scrollRestoreId || !el) return; + const saved = readScrollPosition(scrollRestoreId); + if (saved !== undefined && saved > 0) { + el.scrollTop = saved; + } + // `el` is captured here so the cleanup saves the same node it restored — the + // scroll container is stable for the component's life (single container, + // inner content swaps for loading/empty states). + return () => saveScrollPosition(scrollRestoreId, el.scrollTop); + }, [scrollRestoreId]); + + const handleScroll = React.useCallback( + (event: React.UIEvent) => { + const el = event.currentTarget; + if (scrollRestoreId) saveScrollPosition(scrollRestoreId, el.scrollTop); + if (onEndReached && isNearBottom(el, endReachedThreshold)) onEndReached(); + }, + [onEndReached, endReachedThreshold, scrollRestoreId], + ); + + // One stable scroll container across loading / empty / populated states so the + // virtualizer's scroll element and the restore ref never swap nodes underfoot. + const showLoading = isLoading && items.length === 0; + const showEmpty = !showLoading && items.length === 0; + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+ {showLoading ? ( + renderLoading?.() + ) : showEmpty ? ( + <> + {renderEmpty?.()} + {footer} + + ) : ( + <> +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index; + const pinned = activeStickyRef.current === index && isSticky?.(index) === true; + return ( +
+ {renderItem(items[index]!, index)} +
+ ); + })} +
+ {footer} + + )} +
+ ); +} From b562e03a01e03c79d79e3456406471e301435cb0 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 01:22:23 +0530 Subject: [PATCH 2/7] perf(react): virtualize the tool source-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool tree mounted every row at once — opening a broad source (~3,150 tools for Cloudflare) put thousands of rows, each with a Radix dropdown portal, in the DOM. Flatten the per-account sections and their dotted-name trees into a single item list and render it through VirtualList, so only near-viewport rows mount. Section headers become sticky virtual items; expand/collapse state is lifted to ToolTree (keyed by section+path) so sections stay independent; selection scroll uses the virtualizer's scrollToIndex instead of scrollIntoView on an unmounted row. --- packages/react/src/components/tool-tree.tsx | 371 ++++++++++++-------- 1 file changed, 221 insertions(+), 150 deletions(-) diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 689c68bebb..cf22270094 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide-react"; import type { EffectivePolicy, Owner, ToolPolicyAction } from "@executor-js/sdk/shared"; import { ownerLabel, useOwnerDisplay } from "../api/owner-display"; @@ -22,6 +22,7 @@ import { POLICY_INDICATOR_COLOR, POLICY_STATE_LABEL, } from "../lib/policy-display"; +import { VirtualList, type VirtualListHandle } from "./virtual-list"; // --------------------------------------------------------------------------- // Types @@ -272,8 +273,93 @@ const highlightMatch = (text: string, search: string) => { // --------------------------------------------------------------------------- // ToolTree — main export +// +// One windowed scroller over a single flattened item list. In grouped mode the +// per-account sections and their dotted-name trees are flattened together — +// section headers become sticky items — so a source with thousands of tools +// keeps a bounded DOM (only near-viewport rows mount). Expand/collapse state is +// lifted here and keyed by (section, path) so sections stay independent. // --------------------------------------------------------------------------- +// Flat (ungrouped) mode has no account sections; its rows share one empty key. +const FLAT_SECTION_KEY = ""; +// NUL separates section key from path - it can't appear in a `${owner}:${connection}` +// key or a dotted tool path, so prefix matching never bleeds across sections. +const COMPOSITE_SEP = "\u0000"; +const compositeKey = (sectionKey: string, path: string) => `${sectionKey}${COMPOSITE_SEP}${path}`; + +type ToolTreeItem = + | { readonly kind: "section"; readonly sectionKey: string; readonly group: AccountGroup } + | { + readonly kind: "leaf"; + readonly sectionKey: string; + readonly depth: number; + readonly path: string; + readonly tool: ToolSummary; + } + | { + readonly kind: "group"; + readonly sectionKey: string; + readonly depth: number; + readonly path: string; + readonly segment: string; + readonly count: number; + readonly open: boolean; + }; + +// The open group paths for one section: every group when searching (so matches +// are visible), otherwise the user's manual toggles plus the selected tool's +// ancestors (best-effort, based on its dotted name). +const resolveSectionOpenSet = ( + tree: TreeNode, + manualOpen: ReadonlySet, + sectionKey: string, + terms: readonly string[], + selectedToolId: string | null, +): Set => { + if (terms.length > 0) { + const all = new Set(); + collectGroupPaths(tree, all); + return all; + } + const set = new Set(); + const prefix = `${sectionKey}${COMPOSITE_SEP}`; + for (const composite of manualOpen) { + if (composite.startsWith(prefix)) set.add(composite.slice(prefix.length)); + } + if (selectedToolId) { + const parts = selectedToolId.split("."); + let acc = ""; + for (let i = 0; i < parts.length - 1; i++) { + acc = acc ? `${acc}.${parts[i]}` : parts[i]!; + set.add(acc); + } + } + return set; +}; + +function SectionHeader(props: { + group: AccountGroup; + ownerDisplay: ReturnType; +}) { + const { group, ownerDisplay } = props; + return ( +
+ {ownerDisplay.showOwnerLabels ? ( + + {ownerLabel(group.owner)} + + ) : null} + + {group.connection || ownerDisplay.label(group.owner)} + + + {group.tools.length} + +
+ ); +} + export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; @@ -311,10 +397,9 @@ export function ToolTree(props: { }, [policies]); const [search, setSearch] = useState(""); const searchRef = useRef(null); - const selectedRowRef = useRef(null); const ownerDisplay = useOwnerDisplay(); - const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); + const terms = useMemo(() => search.trim().toLowerCase().split(/\s+/).filter(Boolean), [search]); const filteredTools = useMemo(() => { if (terms.length === 0) return tools; @@ -331,6 +416,46 @@ export function ToolTree(props: { [groupByConnection, filteredTools], ); + // Expand/collapse state, lifted out of the per-section bodies so account + // sections stay independent. Keys are `${sectionKey}\u0000${path}`. + const [manualOpen, setManualOpen] = useState>(() => new Set()); + + // The single flat item list fed to the virtualizer: section headers plus the + // flattened dotted-name rows of each section (or one flat tree when ungrouped). + const items = useMemo(() => { + const acc: ToolTreeItem[] = []; + const pushRows = (sectionTools: readonly ToolSummary[], sectionKey: string) => { + const tree = buildTree(sectionTools); + const openSet = resolveSectionOpenSet(tree, manualOpen, sectionKey, terms, selectedToolId); + const rows: Row[] = []; + flattenTree(tree, 0, openSet, rows); + for (const row of rows) { + if (row.kind === "leaf") { + acc.push({ kind: "leaf", sectionKey, depth: row.depth, path: row.path, tool: row.tool }); + } else { + acc.push({ + kind: "group", + sectionKey, + depth: row.depth, + path: row.path, + segment: row.segment, + count: row.count, + open: row.open, + }); + } + } + }; + if (groupByConnection) { + for (const group of accountGroups) { + acc.push({ kind: "section", sectionKey: group.key, group }); + pushRows(group.tools, group.key); + } + } else { + pushRows(filteredTools, FLAT_SECTION_KEY); + } + return acc; + }, [groupByConnection, accountGroups, filteredTools, manualOpen, terms, selectedToolId]); + // Keyboard shortcuts — `/` focuses search, Escape clears useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -347,22 +472,92 @@ export function ToolTree(props: { return () => document.removeEventListener("keydown", handler); }, [search]); - // Scroll the selected row into view when it changes + // Scroll the selected row into view when it changes. Reads the latest items + // through a ref so re-flattening (e.g. expanding a group) doesn't re-trigger. + const listRef = useRef(null); + const itemsRef = useRef(items); + itemsRef.current = items; useEffect(() => { if (!selectedToolId) return; - selectedRowRef.current?.scrollIntoView({ block: "nearest" }); + const index = itemsRef.current.findIndex( + (it) => it.kind === "leaf" && it.tool.id === selectedToolId, + ); + if (index >= 0) listRef.current?.scrollToIndex(index, { align: "center" }); }, [selectedToolId]); - const bodyProps = { - selectedToolId, - onSelect, - onSetPolicy, - onClearPolicy, - exactPatterns, - search, - terms, - selectedRowRef, - }; + const toggleGroup = useCallback((sectionKey: string, path: string) => { + setManualOpen((prev) => { + const next = new Set(prev); + const key = compositeKey(sectionKey, path); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const isSticky = useCallback((index: number) => items[index]?.kind === "section", [items]); + const estimateSize = useCallback( + (index: number) => (items[index]?.kind === "section" ? 30 : 34), + [items], + ); + const getKey = useCallback((item: ToolTreeItem) => { + if (item.kind === "section") return `section:${item.sectionKey}`; + if (item.kind === "leaf") return `${item.sectionKey}:leaf:${item.tool.id}`; + return `${item.sectionKey}:group:${item.path}`; + }, []); + + const renderItem = useCallback( + (item: ToolTreeItem) => { + if (item.kind === "section") { + return ; + } + if (item.kind === "leaf") { + return ( + { + const nameParts = item.tool.name.split("."); + trackEvent("tool_selected", { + integration_slug: nameParts[0] ?? item.tool.name, + tool_name: nameParts.slice(1).join(".") || item.tool.name, + }); + onSelect(item.tool.id); + }} + search={search} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + exactRule={exactPatterns.get(toPolicyPattern(item.tool.name))} + /> + ); + } + return ( + toggleGroup(item.sectionKey, item.path)} + search={search} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + exactRule={exactPatterns.get(toPolicyPattern(`${item.path}.*`))} + /> + ); + }, + [ + ownerDisplay, + selectedToolId, + search, + onSelect, + onSetPolicy, + onClearPolicy, + exactPatterns, + toggleGroup, + ], + ); return (
@@ -389,149 +584,25 @@ export function ToolTree(props: { )}
-
- {filteredTools.length === 0 ? ( + (
{terms.length > 0 ? "No tools match your filter" : "No tools available"}
- ) : groupByConnection ? ( - accountGroups.map((group) => ( -
-
- {ownerDisplay.showOwnerLabels ? ( - - {ownerLabel(group.owner)} - - ) : null} - - {group.connection || ownerDisplay.label(group.owner)} - - - {group.tools.length} - -
- -
- )) - ) : ( - )} -
+ />
); } -// --------------------------------------------------------------------------- -// ToolTreeBody — one dotted-name tree for a tools subset. Owns its own -// expand/collapse state so account sections (grouped mode) expand independently -// and never share path keys. Already-filtered tools are passed in. -// --------------------------------------------------------------------------- - -function ToolTreeBody(props: { - tools: readonly ToolSummary[]; - selectedToolId: string | null; - onSelect: (toolId: string) => void; - onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; - onClearPolicy?: (pattern: string) => void; - exactPatterns: ReadonlyMap; - search: string; - terms: readonly string[]; - selectedRowRef: React.Ref; -}) { - const { - tools, - selectedToolId, - onSelect, - onSetPolicy, - onClearPolicy, - exactPatterns, - search, - terms, - selectedRowRef, - } = props; - const [manualOpen, setManualOpen] = useState>(() => new Set()); - - const tree = useMemo(() => buildTree(tools), [tools]); - - // When searching, expand everything so matches are visible. - // Also auto-expand groups that contain the selected tool. - const openSet = useMemo(() => { - if (terms.length > 0) { - const all = new Set(); - collectGroupPaths(tree, all); - return all; - } - const set = new Set(manualOpen); - if (selectedToolId) { - const parts = selectedToolId.split("."); - // Progressively add ancestor paths (best-effort, based on dotted name). - let acc = ""; - for (let i = 0; i < parts.length - 1; i++) { - acc = acc ? `${acc}.${parts[i]}` : parts[i]!; - set.add(acc); - } - } - return set; - }, [tree, manualOpen, selectedToolId, terms.length]); - - const rows = useMemo(() => { - const acc: Row[] = []; - flattenTree(tree, 0, openSet, acc); - return acc; - }, [tree, openSet]); - - const toggleGroup = (path: string) => { - setManualOpen((prev) => { - const next = new Set(prev); - if (next.has(path)) next.delete(path); - else next.add(path); - return next; - }); - }; - - return ( - <> - {rows.map((row) => - row.kind === "leaf" ? ( - { - const nameParts = row.tool.name.split("."); - trackEvent("tool_selected", { - integration_slug: nameParts[0] ?? row.tool.name, - tool_name: nameParts.slice(1).join(".") || row.tool.name, - }); - onSelect(row.tool.id); - }} - search={search} - onSetPolicy={onSetPolicy} - onClearPolicy={onClearPolicy} - exactRule={exactPatterns.get(toPolicyPattern(row.tool.name))} - /> - ) : ( - toggleGroup(row.path)} - search={search} - onSetPolicy={onSetPolicy} - onClearPolicy={onClearPolicy} - exactRule={exactPatterns.get(toPolicyPattern(`${row.path}.*`))} - /> - ), - )} - - ); -} - // --------------------------------------------------------------------------- // Row renderers // --------------------------------------------------------------------------- From 9c8a4cc410f83a422ad58dc703bed0fab049f5cb Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 01:33:49 +0530 Subject: [PATCH 3/7] perf(execution-history): virtualize the runs table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runs table mounted every accumulated row; deep keyset scrolling grew the DOM unbounded (each row carries a per-row actor-label atom subscription). Add a useWindowedRows hook to the shared primitive — windowing for surfaces that can't use
rows — and adopt it in RunsShell: the real
and sticky stay, but only near-viewport rows mount, bracketed by top/ bottom spacer s. The actor and code columns are already width-capped, so windowing a subset doesn't make table-layout:auto columns jitter. RunsPage now hands the shell keyed row descriptors (off-screen runs are cheap element descriptors that never run their per-row hooks) plus the loading/error status row and the load-more retry row. Scroll position is preserved across drawer open/close via scrollRestoreId. --- .../execution-history/src/react/RunsPage.tsx | 71 ++++++------ .../execution-history/src/react/shell.tsx | 104 ++++++++++++------ .../react/src/components/virtual-list.tsx | 79 ++++++++++++- 3 files changed, 187 insertions(+), 67 deletions(-) diff --git a/packages/plugins/execution-history/src/react/RunsPage.tsx b/packages/plugins/execution-history/src/react/RunsPage.tsx index 6c4173e0f7..be95ed4d1e 100644 --- a/packages/plugins/execution-history/src/react/RunsPage.tsx +++ b/packages/plugins/execution-history/src/react/RunsPage.tsx @@ -10,7 +10,7 @@ import { LiveButton } from "./live-button"; import { LiveDivider } from "./live-row"; import { RefreshButton } from "./refresh-button"; import { RunListRow } from "./run-row"; -import { RunsShell, RUNS_TABLE_COLSPAN } from "./shell"; +import { RunsShell, RUNS_TABLE_COLSPAN, type RunsShellRow } from "./shell"; import { RunsTimelineChart } from "./timeline-chart"; import { emptyRunsFilters, @@ -146,37 +146,40 @@ export function RunsPage() { [isDescending, view.rows, view.liveCutoffId], ); - const rowItems: React.ReactNode[] = []; + // Run rows + the interleaved live divider, as keyed entries the shell windows. + // Elements for off-screen runs are cheap descriptors — only the rows the + // windowing mounts actually run their per-row hooks / portals. + const rows: RunsShellRow[] = []; view.rows.forEach((run, index) => { if (index === cutoffIndex && cutoffIndex > 0) { - rowItems.push(); + rows.push({ key: "live-divider", node: }); } - rowItems.push( - 0 && index >= cutoffIndex} - columns={columns} - onSelect={() => setSelected(run.executionId)} - />, - ); + rows.push({ + key: run.executionId, + node: ( + 0 && index >= cutoffIndex} + columns={columns} + onSelect={() => setSelected(run.executionId)} + /> + ), + }); }); - if (view.isLoadMoreError) { - rowItems.push( - - - , - ); - } + const loadMoreError = view.isLoadMoreError ? ( + + + + ) : undefined; const toolbar = (
@@ -226,7 +229,7 @@ export function RunsPage() { /> ) : undefined; - const body = view.isLoading ? ( + const statusRow = view.isLoading ? (
- ) : ( - rowItems - ); + ) : undefined; // Prev/next drawer navigation mirrors the ArrowUp/ArrowDown keyboard handler: // index-1 is the row above (newer in descending sort), index+1 the row below. @@ -286,9 +287,11 @@ export function RunsPage() { } railCollapsed={railCollapsed} - > - {body} - + rows={rows} + statusRow={statusRow} + loadMoreError={loadMoreError} + scrollRestoreId="execution-history-runs" + /> renders the filter rail at ~15rem wide and can be collapsed // via railCollapsed. The right main pane stacks the full-width toolbar + chart -// above a single table scroller that owns both axes. The table is a real -// semantic
- - Failed to load more - - -
+ + Failed to load more + + +
Loading runs… @@ -238,9 +241,7 @@ export function RunsPage() { Unable to load runs.
with content-fit columns (table-layout: auto): the sticky -// and the rows share one column model, so columns size to their -// widest cell and stay aligned for free — no fixed widths. The scroller fires -// onLoadMore when the user scrolls within 320px of the bottom, guarded against -// re-firing while loading. +// above a single table scroller that owns both axes. The table stays a real +// semantic
with a sticky ; only the rows near the viewport are +// mounted, with top/bottom spacer s standing in for the rest (useWindowedRows +// from the shared virtualization primitive). The two columns that can run long — +// actor and code — are width-capped in run-row.tsx, so windowing a subset doesn't +// make `table-layout: auto` columns jitter as rows scroll in. The scroller fires +// onLoadMore within 320px of the bottom, guarded against re-firing while loading. // --------------------------------------------------------------------------- // Upper bound on the visible column count (dot + started + status + up to six @@ -20,48 +23,63 @@ import { cn } from "@executor-js/react/lib/utils"; // with this; an over-count colSpan is harmless (the browser clamps it). export const RUNS_TABLE_COLSPAN = 12; +// Approximate keyset row height (run-row.tsx: px-3 py-2 + border ≈ 33px). Rows +// are near-uniform, so a fixed estimate keeps the spacer math stable. +const RUNS_ROW_ESTIMATE = 33; + +export interface RunsShellRow { + readonly key: React.Key; + readonly node: React.ReactNode; +} + export interface RunsShellProps { readonly filterRail: React.ReactNode; readonly toolbar: React.ReactNode; readonly chart?: React.ReactNode; readonly columnHeader: React.ReactNode; - readonly children: React.ReactNode; + /** Windowed body rows — run rows plus the interleaved live divider. */ + readonly rows: readonly RunsShellRow[]; + readonly estimateRowSize?: number; + readonly scrollRestoreId?: string; readonly onLoadMore: () => void; readonly hasMore: boolean; readonly isLoadingMore: boolean; readonly isEmpty: boolean; readonly emptyState?: React.ReactNode; + /** A full-width shown instead of the windowed rows (loading / error). */ + readonly statusRow?: React.ReactNode; + /** A appended after the rows — e.g. the load-more retry prompt. */ + readonly loadMoreError?: React.ReactNode; readonly railCollapsed?: boolean; } -const SCROLL_THRESHOLD = 320; - export function RunsShell(props: RunsShellProps) { const { filterRail, toolbar, chart, columnHeader, - children, + rows, + estimateRowSize, + scrollRestoreId, onLoadMore, hasMore, isLoadingMore, isEmpty, emptyState, + statusRow, + loadMoreError, railCollapsed, } = props; - const onScroll = React.useCallback( - (event: React.UIEvent) => { - if (!hasMore || isLoadingMore || isEmpty) return; - const target = event.currentTarget; - const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; - if (distanceFromBottom < SCROLL_THRESHOLD) { - onLoadMore(); - } - }, - [hasMore, isLoadingMore, isEmpty, onLoadMore], - ); + const canLoadMore = hasMore && !isLoadingMore && !isEmpty; + const { scrollRef, virtualItems, paddingTop, paddingBottom, onScroll } = useWindowedRows({ + count: rows.length, + estimateSize: estimateRowSize ?? RUNS_ROW_ESTIMATE, + overscan: 16, + onEndReached: canLoadMore ? onLoadMore : undefined, + scrollRestoreId, + }); return (
@@ -84,16 +102,13 @@ export function RunsShell(props: RunsShellProps) { {chart != null &&
{chart}
}
- {/* Table scroller — owns both axes. The sticky
sticks to the top - of this scroller (below the toolbar/chart) and rides the same - horizontal scroll as the rows. `border-separate border-spacing-0` - keeps cell borders rendering on the sticky header (a known - border-collapse + sticky browser bug). No table width is forced, so - columns content-fit: narrower than the pane → left-aligned with slack - on the right; wider → horizontal scroll. */} -
+ {/* Table scroller — owns both axes and is the virtualizer's scroll + element. The sticky
rides the same scroll; only near-viewport + rows mount, bracketed by spacer s. `border-separate + border-spacing-0` keeps cell borders on the sticky header. */} +
{/* oxlint-disable-next-line react/forbid-elements -- the runs table needs - this custom scroll container (the onScroll infinite-scroll handler) + + this custom scroll container (the windowed infinite-scroll handler) + a sticky thead inside it; the shared
wraps its own overflow-x-auto div, which would conflict. Same suppression the shared Table component itself uses on its raw
. */} @@ -110,9 +125,34 @@ export function RunsShell(props: RunsShellProps) { )} + ) : statusRow != null ? ( + statusRow ) : ( <> - {children} + {paddingTop > 0 && ( + + + )} + {virtualItems.map((item) => ( + + {rows[item.index]!.node} + + ))} + {paddingBottom > 0 && ( + + + )} + {loadMoreError} {isLoadingMore && (
+
+
(props: VirtualListProps) { ); } + +// --------------------------------------------------------------------------- +// useWindowedRows — windowing for surfaces that can't use
rows. +// +// A real needs its scroll container to wrap +, so the runs +// table can't drop in . This hook exposes the same engine: attach +// `scrollRef` to the scroll container, render a top spacer row of height +// `paddingTop`, the `virtualItems`, then a bottom spacer of `paddingBottom`. +// `onScroll` fires end-reached and persists scroll position, mirroring the +// component. Rows are assumed near-uniform height (fixed `estimateSize`); the +// sticky-header offset is absorbed by `overscan`. +// --------------------------------------------------------------------------- +export interface WindowedRows { + readonly scrollRef: React.RefObject; + readonly virtualItems: readonly VirtualItem[]; + readonly paddingTop: number; + readonly paddingBottom: number; + readonly onScroll: (event: React.UIEvent) => void; +} + +export function useWindowedRows(options: { + readonly count: number; + readonly estimateSize?: number; + readonly overscan?: number; + readonly onEndReached?: () => void; + readonly endReachedThreshold?: number; + readonly scrollRestoreId?: string; +}): WindowedRows { + const { + count, + estimateSize = DEFAULT_ESTIMATE_SIZE, + overscan = DEFAULT_OVERSCAN, + onEndReached, + endReachedThreshold = DEFAULT_END_REACHED_THRESHOLD, + scrollRestoreId, + } = options; + + const scrollRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateSize, + overscan, + }); + + React.useLayoutEffect(() => { + const el = scrollRef.current; + if (!scrollRestoreId || !el) return; + const saved = readScrollPosition(scrollRestoreId); + if (saved !== undefined && saved > 0) el.scrollTop = saved; + return () => saveScrollPosition(scrollRestoreId, el.scrollTop); + }, [scrollRestoreId]); + + const onScroll = React.useCallback( + (event: React.UIEvent) => { + const el = event.currentTarget; + if (scrollRestoreId) saveScrollPosition(scrollRestoreId, el.scrollTop); + if (onEndReached && isNearBottom(el, endReachedThreshold)) onEndReached(); + }, + [onEndReached, endReachedThreshold, scrollRestoreId], + ); + + const virtualItems = virtualizer.getVirtualItems(); + const paddingTop = virtualItems.length > 0 ? virtualItems[0]!.start : 0; + const paddingBottom = + virtualItems.length > 0 + ? virtualizer.getTotalSize() - virtualItems[virtualItems.length - 1]!.end + : 0; + + return { scrollRef, virtualItems, paddingTop, paddingBottom, onScroll }; +} From 922149fdcd888cab382d3b39f8ed282ee63575f6 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 01:40:37 +0530 Subject: [PATCH 4/7] perf(execution-history): virtualize run-detail logs and tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verbose or agentic run could mount hundreds of log lines or tool-call rows in the detail drawer at once. The single shared ScrollArea wrapped all three tabs; since Radix unmounts inactive tabs, give the two pure-list tabs their own VirtualList scroll containers and keep Properties (mixed content) in a ScrollArea. ToolCallItem's expanded state is lifted into ToolCallsTab so a row scrolled out of the window and back keeps its expansion. The interactions list stays inline on Properties — it's small in practice and embedded in mixed content, so windowing it isn't worth the complexity. --- .../src/react/detail-drawer.tsx | 218 ++++++++++-------- 1 file changed, 123 insertions(+), 95 deletions(-) diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx index ff321ed56e..a462e35d2e 100644 --- a/packages/plugins/execution-history/src/react/detail-drawer.tsx +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -8,6 +8,7 @@ import { useResolveActorLabel } from "@executor-js/react/lib/actor-labels"; import { CodeBlock } from "@executor-js/react/components/code-block"; import { CopyButton } from "@executor-js/react/components/copy-button"; import { ScrollArea } from "@executor-js/react/components/scroll-area"; +import { VirtualList } from "@executor-js/react/components/virtual-list"; import { Sheet, SheetContent, @@ -65,20 +66,25 @@ function LogsBlock(props: { readonly logsJson: string | null }) { ); } return ( -
- {lines.map((line, index) => ( + `${index}-${line.slice(0, 24)}`} + estimateSize={() => 28} + overscan={16} + className="min-h-0 flex-1 px-5 py-4" + aria-label="Run logs" + renderItem={(line) => (
           {line}
         
- ))} -
+ )} + /> ); } @@ -117,14 +123,15 @@ function ToolCallItem(props: { readonly call: ToolCallRow; readonly windowStart: number; readonly windowEnd: number; + readonly expanded: boolean; + readonly onToggle: () => void; }) { - const [expanded, setExpanded] = useState(false); return (
- {expanded && ( + {props.expanded && (
{props.call.status === "failed" && props.call.errorText ? ( @@ -157,9 +164,19 @@ function ToolCallItem(props: { } function ToolCallsTab(props: { readonly run: RunRow; readonly toolCalls: readonly ToolCallRow[] }) { + // Expanded state is lifted out of the row so it survives windowing (a row + // scrolled out and back keeps its expansion). + const [expanded, setExpanded] = useState>(() => new Set()); const windowStart = props.run.startedAt; + const toggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); if (props.toolCalls.length === 0) { - return

No tool calls recorded.

; + return

No tool calls recorded.

; } // Derive the window end from the tool-call data as well as `run.completedAt` // so a fresher tool call can't outrun a stale run end and clamp its bar to a @@ -175,16 +192,25 @@ function ToolCallsTab(props: { readonly run: RunRow; readonly toolCalls: readonl ); const windowEnd = Math.max(props.run.completedAt ?? Date.now(), callsEnd); return ( -
- {props.toolCalls.map((call) => ( - - ))} -
+ call.toolCallId} + estimateSize={() => 52} + overscan={12} + className="min-h-0 flex-1 px-5 py-4" + aria-label="Tool calls" + renderItem={(call) => ( +
+ toggle(call.toolCallId)} + /> +
+ )} + /> ); } @@ -313,85 +339,87 @@ function DetailContent(props: { Logs - - -
- - - - {statusLabel(run.status)} - - - - {run.durationMs != null ? formatDuration(run.durationMs) : "—"} - - - {run.actorId !== null ? ( - - {actorLabel} - {run.actorKind !== null ? ( - · {run.actorKind} - ) : null} + + +
+
+ + + + {statusLabel(run.status)} - ) : ( - — - )} - - - - - - {run.completedAt != null ? ( - - ) : ( - formatDateTime(run.completedAt) - )} - -
- -
- - - via {trigger.label} - - · tools {run.toolCallCount} -
- - - - {props.errorText && ( -
-

Error

-
-                  {props.errorText}
-                
+ + + {run.durationMs != null ? formatDuration(run.durationMs) : "—"} + + + {run.actorId !== null ? ( + + {actorLabel} + {run.actorKind !== null ? ( + · {run.actorKind} + ) : null} + + ) : ( + — + )} + + + + + + {run.completedAt != null ? ( + + ) : ( + formatDateTime(run.completedAt) + )} +
- )} - {props.interactions.length > 0 && ( -
-

- {interactionsHeading} -

- {props.interactions.map((interaction) => ( - - ))} +
+ + + via {trigger.label} + + · tools {run.toolCallCount}
- )} - - - - - - - - + + + + {props.errorText && ( +
+

Error

+
+                    {props.errorText}
+                  
+
+ )} + + {props.interactions.length > 0 && ( +
+

+ {interactionsHeading} +

+ {props.interactions.map((interaction) => ( + + ))} +
+ )} +
+ + + + + + + +
); From 33c0f2888f79a0b12f4360a157d54197da504177 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 01:46:18 +0530 Subject: [PATCH 5/7] test(react): extract and cover the sticky-header range logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the "which sticky header is pinned" computation out of VirtualList's range extractor into a pure resolveActiveStickyIndex(stickyIndices, startIndex) and unit-test it — the trickiest part of the windowing (it decides which section header stays mounted and pinned as the user scrolls between sections) now has direct coverage independent of the DOM-bound virtualizer. --- .../react/src/components/virtual-list.test.ts | 25 +++++++++++++++++++ .../react/src/components/virtual-list.tsx | 21 ++++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/virtual-list.test.ts b/packages/react/src/components/virtual-list.test.ts index 0131a01ff1..8d250e0de4 100644 --- a/packages/react/src/components/virtual-list.test.ts +++ b/packages/react/src/components/virtual-list.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_END_REACHED_THRESHOLD, isNearBottom, readScrollPosition, + resolveActiveStickyIndex, saveScrollPosition, } from "./virtual-list"; @@ -48,3 +49,27 @@ describe("scroll position store", () => { expect(readScrollPosition("b")).toBe(20); }); }); + +describe("resolveActiveStickyIndex", () => { + // Sections at rows 0, 4 and 9 (e.g. the tool tree's per-account headers). + const sticky = [0, 4, 9]; + + it("pins the first header while scrolled within the first section", () => { + expect(resolveActiveStickyIndex(sticky, 0)).toBe(0); + expect(resolveActiveStickyIndex(sticky, 3)).toBe(0); + }); + + it("switches to the header at the section boundary", () => { + expect(resolveActiveStickyIndex(sticky, 4)).toBe(4); + expect(resolveActiveStickyIndex(sticky, 8)).toBe(4); + expect(resolveActiveStickyIndex(sticky, 9)).toBe(9); + expect(resolveActiveStickyIndex(sticky, 50)).toBe(9); + }); + + it("returns null when no header is at or above the first visible row", () => { + // A list whose first sticky header isn't at the very top. + expect(resolveActiveStickyIndex([2, 5], 0)).toBeNull(); + expect(resolveActiveStickyIndex([2, 5], 1)).toBeNull(); + expect(resolveActiveStickyIndex([], 7)).toBeNull(); + }); +}); diff --git a/packages/react/src/components/virtual-list.tsx b/packages/react/src/components/virtual-list.tsx index 196a7cfb0c..926989834e 100644 --- a/packages/react/src/components/virtual-list.tsx +++ b/packages/react/src/components/virtual-list.tsx @@ -68,6 +68,21 @@ export function isNearBottom(metrics: ScrollMetrics, threshold: number): boolean return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold; } +/** The sticky header that should be pinned at the top of the window: the last + * sticky index at or above the first visible row, or null if none precedes it. + * `stickyIndices` must be ascending. Pure — unit-testable. */ +export function resolveActiveStickyIndex( + stickyIndices: readonly number[], + startIndex: number, +): number | null { + let active: number | null = null; + for (const idx of stickyIndices) { + if (idx <= startIndex) active = idx; + else break; + } + return active; +} + // --- imperative handle ----------------------------------------------------- export interface VirtualListHandle { scrollToIndex: (index: number, options?: { align?: "start" | "center" | "end" }) => void; @@ -144,11 +159,7 @@ export function VirtualList(props: VirtualListProps) { activeStickyRef.current = null; return defaultRangeExtractor(range); } - let active: number | null = null; - for (const idx of stickyIndices) { - if (idx <= range.startIndex) active = idx; - else break; - } + const active = resolveActiveStickyIndex(stickyIndices, range.startIndex); activeStickyRef.current = active; const indices = new Set(defaultRangeExtractor(range)); if (active !== null) indices.add(active); From fcf39cdd419b97539fba73da9ced472ad46f6350 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 02:09:37 +0530 Subject: [PATCH 6/7] fix(execution-history): memoize the tool-call toggle (greptile) Wrap ToolCallsTab's toggle in useCallback (empty deps; it only calls the stable setExpanded setter), matching toggleGroup in ToolTree and keeping the windowed renderItem closure stable if ToolCallItem is ever memoized. --- .../plugins/execution-history/src/react/detail-drawer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx index a462e35d2e..dc8ab875b0 100644 --- a/packages/plugins/execution-history/src/react/detail-drawer.tsx +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Badge } from "@executor-js/react/components/badge"; import { Button } from "@executor-js/react/components/button"; @@ -168,13 +168,14 @@ function ToolCallsTab(props: { readonly run: RunRow; readonly toolCalls: readonl // scrolled out and back keeps its expansion). const [expanded, setExpanded] = useState>(() => new Set()); const windowStart = props.run.startedAt; - const toggle = (id: string) => + const toggle = useCallback((id: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + }, []); if (props.toolCalls.length === 0) { return

No tool calls recorded.

; } From 9a629206e11fabcb33f0bf7a4a306a7fe61ac7c9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 22 Jun 2026 03:28:36 +0530 Subject: [PATCH 7/7] fix(react): key VirtualList's measurement cache by item identity Pass getItemKey to the virtualizer so dynamically-measured row heights are cached by stable item key, not by index. VirtualList re-flattens its items on tool-tree expand/collapse/filter; with the default index keying, a reorder left the prior row's measured height cached at that index until the ResizeObserver re-measured. Matches TanStack Virtual's guidance for reordering dynamic lists. --- packages/react/src/components/virtual-list.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react/src/components/virtual-list.tsx b/packages/react/src/components/virtual-list.tsx index 926989834e..b225ade187 100644 --- a/packages/react/src/components/virtual-list.tsx +++ b/packages/react/src/components/virtual-list.tsx @@ -174,6 +174,11 @@ export function VirtualList(props: VirtualListProps) { estimateSize: estimateSize ?? (() => DEFAULT_ESTIMATE_SIZE), overscan, rangeExtractor, + // Key the measurement cache by stable item identity, not index, so measured + // heights follow rows when the list reorders (the tool tree re-flattens on + // expand/collapse/filter). Without this the virtualizer caches sizes by + // index and a reorder leaves stale heights until the next re-measure. + getItemKey: (index) => getKey(items[index]!, index), }); React.useImperativeHandle(