perf(app): virtualize session timeline rows (#26949)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker
2026-05-18 13:40:52 +10:00
committed by GitHub
parent e85119aa64
commit 5452ab6db7
9 changed files with 1618 additions and 883 deletions

View File

@@ -6,6 +6,7 @@
"exports": {
"./package.json": "./package.json",
"./*": "./src/components/*.tsx",
"./session-diff": "./src/components/session-diff.ts",
"./i18n/*": "./src/i18n/*.ts",
"./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts",

View File

@@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
@@ -40,26 +40,76 @@ export interface BasicToolProps {
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
let deferredFrame: number | undefined
function flushDeferredMounts() {
while (deferredMounts.length > 0) {
// Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
// Pop from the end so heavy default-open bodies near the bottom become interactive first.
const item = deferredMounts.pop()!
if (item.active) {
deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
item.fn()
return
}
}
deferredFrame = undefined
}
function scheduleDeferredFlush() {
if (deferredFrame !== undefined) return
deferredFrame = requestAnimationFrame(() => {
deferredFrame = requestAnimationFrame(flushDeferredMounts)
})
}
function scheduleDeferredMount(fn: () => void) {
const item = { active: true, fn }
deferredMounts.push(item)
scheduleDeferredFlush()
return () => {
item.active = false
}
}
function scheduleFrameMount(fn: () => void) {
const frame = requestAnimationFrame(fn)
return () => cancelAnimationFrame(frame)
}
export function BasicTool(props: BasicToolProps) {
const [state, setState] = createStore({
open: props.defaultOpen ?? false,
ready: props.defaultOpen ?? false,
ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
let frame: number | undefined
let cancelReady: (() => void) | undefined
const cancel = () => {
if (frame === undefined) return
cancelAnimationFrame(frame)
frame = undefined
cancelReady?.()
cancelReady = undefined
}
const scheduleReady = (initial = false) => {
cancel()
cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
cancelReady = undefined
if (!open()) return
setState("ready", true)
})
}
onCleanup(cancel)
onMount(() => {
if (props.defer && open()) scheduleReady(true)
})
createEffect(() => {
if (props.forceOpen) setState("open", true)
})
@@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) {
return
}
cancel()
frame = requestAnimationFrame(() => {
frame = undefined
if (!open()) return
setState("ready", true)
})
scheduleReady()
},
{ defer: true },
),
@@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
@@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
</Collapsible.Trigger>
)}
</Show>
<Show when={props.animated && props.children && !props.hideDetails}>
<Show when={props.animated && hasChildren() && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
@@ -229,10 +274,10 @@ export function BasicTool(props: BasicToolProps) {
overflow: initialOpen ? "visible" : "hidden",
}}
>
{props.children}
<Show when={!props.defer || ready()}>{props.children}</Show>
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>

View File

@@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
deferToolContent?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -486,12 +487,12 @@ function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
return a.every((x, i) => x === b[i])
}
type PartRef = {
export type PartRef = {
messageID: string
partID: string
}
type PartGroup =
export type PartGroup =
| {
key: string
type: "part"
@@ -520,14 +521,14 @@ function sameGroup(a: PartGroup, b: PartGroup) {
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
}
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!))
}
function groupParts(parts: { messageID: string; part: PartType }[]) {
export function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
let start = -1
@@ -575,7 +576,7 @@ function index<T extends { id: string }>(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const))
}
function renderable(part: PartType, showReasoningSummaries = true) {
export function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
@@ -591,7 +592,7 @@ function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
}
function partDefaultOpen(part: PartType, shell = false, edit = false) {
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit)
}
@@ -904,7 +905,7 @@ export function AssistantMessageDisplay(props: {
)
}
function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(
@@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost" class="tool-collapsible">
<Collapsible
open={open()}
onOpenChange={setOpen}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
>
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<span
@@ -1077,7 +1084,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
<div data-component="user-message">
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1244,6 +1252,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
deferContent?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return (
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper">
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
<Switch>
<Match when={part().state.status === "error" && (part().state as any).error}>
{(error) => {
@@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
deferContent={props.deferToolContent}
/>
</Match>
</Switch>
@@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="text-part">
<div data-component="text-part" data-timeline-part-id={part().id}>
<div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
@@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="reasoning-part">
<div data-component="reasoning-part" data-timeline-part-id={part().id}>
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show>
@@ -1913,7 +1923,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
@@ -1974,7 +1984,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
@@ -2056,7 +2066,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -2128,7 +2138,7 @@ ToolRegistry.register({
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<Show when={props.deferContent === false || visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
@@ -2153,7 +2163,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
defer
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">