perf(app): virtualize session timeline rows (#26949)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user