Skip to content

Commit 8f6aa44

Browse files
authored
perf(ui): virtualize nested session list (NeuralNomadsAI#586)
## Summary - flatten expanded session trees into a stable pre-order row projection - render only the viewport and buffer with the existing Solid virtua integration - preserve nested connectors, dynamic heights, search expansion, focus, active-session scrolling, and loading sentinel behavior ## Why Large or deeply expanded session trees currently mount every visible row. Status updates then affect a large DOM tree even when most sessions are far outside the sidebar viewport. This PR bounds mounted rows while leaving selection, deletion, filtering, and store semantics unchanged. ## Validation - UI TypeScript typecheck - 47 focused session-tree, pagination, and virtual-scroll tests - 1,000-session projection regression test - production Vite build - git diff whitespace check - final focused regression review - manual test
1 parent b766b76 commit 8f6aa44

4 files changed

Lines changed: 208 additions & 97 deletions

File tree

packages/ui/src/components/session-list.tsx

Lines changed: 95 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
1+
import { Component, Show, createSignal, createMemo, createEffect, JSX, on, onCleanup } from "solid-js"
2+
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
23
import type { SessionStatus } from "../types/session"
34
import type { SessionThread } from "../stores/session-state"
45
import { getRetrySeconds, getSessionIdleFadeClass, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status"
@@ -32,7 +33,7 @@ import {
3233
isSessionSearchLoading,
3334
} from "../stores/sessions"
3435
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
35-
import { collectSessionThreadIds, findSessionThread, sortSessionIdsDeepestFirst } from "../stores/session-tree"
36+
import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree"
3637
import { getLogger } from "../lib/logger"
3738
import { copyToClipboard } from "../lib/clipboard"
3839
import { useConfig } from "../stores/preferences"
@@ -69,6 +70,9 @@ const SessionList: Component<SessionListProps> = (props) => {
6970
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
7071
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
7172
const [now, setNow] = createSignal(Date.now())
73+
const [listEl, setListEl] = createSignal<HTMLDivElement>()
74+
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle>()
75+
const [focusedSessionId, setFocusedSessionId] = createSignal<string>()
7276

7377
createEffect(() => {
7478
if (typeof window === "undefined") return
@@ -107,7 +111,7 @@ const SessionList: Component<SessionListProps> = (props) => {
107111
})
108112
}
109113
},
110-
{ root: el.parentElement ?? null, rootMargin: "0px 0px 200px 0px" }
114+
{ root: listEl() ?? null, rootMargin: "0px 0px 200px 0px" }
111115
)
112116

113117
observer.observe(el)
@@ -189,6 +193,29 @@ const SessionList: Component<SessionListProps> = (props) => {
189193
return result
190194
})
191195

196+
const visibleProjection = createMemo(() => {
197+
const expandAll = Boolean(normalizedQuery())
198+
const rows = flattenVisibleSessionThreads(
199+
filteredThreads(),
200+
(sessionId) => expandAll || isSessionExpanded(props.instanceId, sessionId),
201+
)
202+
const ids: string[] = []
203+
const rowsById = new Map<string, (typeof rows)[number]>()
204+
const indexById = new Map<string, number>()
205+
rows.forEach((row, index) => {
206+
ids.push(row.sessionId)
207+
rowsById.set(row.sessionId, row)
208+
indexById.set(row.sessionId, index)
209+
})
210+
return { ids, rowsById, indexById }
211+
})
212+
const keptMountedIndexes = createMemo(() => {
213+
const sessionId = focusedSessionId()
214+
if (!sessionId) return undefined
215+
const index = visibleProjection().indexById.get(sessionId)
216+
return index === undefined ? undefined : [index]
217+
})
218+
192219
const allMatchingSessionIds = createMemo<string[]>(() => {
193220
const ids: string[] = []
194221
const collectIds = (threads: SessionThread[]) => {
@@ -472,6 +499,7 @@ const SessionList: Component<SessionListProps> = (props) => {
472499
isLastChild: boolean
473500
hasChildren: boolean
474501
expanded?: boolean
502+
isLastRow: boolean
475503
onToggleExpand?: () => void
476504
}> = (rowProps) => {
477505
const sessionId = () => rowProps.session.id
@@ -574,7 +602,7 @@ const SessionList: Component<SessionListProps> = (props) => {
574602
}
575603

576604
return (
577-
<div class="session-list-item group">
605+
<div class={`session-list-item group ${rowProps.isLastRow ? "session-list-item-last" : ""}`}>
578606
<button
579607
class={`session-item-base ${isChild() ? "session-item-nested" : ""} ${isChild() && rowProps.isLastChild ? "session-item-child-last" : ""} ${isChild() ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
580608
style={nestedStyle()}
@@ -718,40 +746,6 @@ const SessionList: Component<SessionListProps> = (props) => {
718746
)
719747
}
720748

721-
// Recursive component for rendering a thread and its children
722-
const SessionThreadRow: Component<{
723-
thread: SessionThread
724-
depth?: number
725-
isLastChild?: boolean
726-
}> = (rowProps) => {
727-
const depth = () => rowProps.depth ?? 0
728-
const expanded = () => normalizedQuery() ? true : isSessionExpanded(props.instanceId, rowProps.thread.session.id)
729-
730-
return (
731-
<>
732-
<SessionRow
733-
session={rowProps.thread.session}
734-
depth={depth()}
735-
hasChildren={rowProps.thread.hasChildren}
736-
expanded={expanded()}
737-
onToggleExpand={() => toggleSessionExpanded(props.instanceId, rowProps.thread.session.id)}
738-
isLastChild={Boolean(rowProps.isLastChild)}
739-
/>
740-
<Show when={expanded() && rowProps.thread.children.length > 0}>
741-
<For each={rowProps.thread.children}>
742-
{(childThread, index) => (
743-
<SessionThreadRow
744-
thread={childThread}
745-
depth={depth() + 1}
746-
isLastChild={index() === rowProps.thread.children.length - 1}
747-
/>
748-
)}
749-
</For>
750-
</Show>
751-
</>
752-
)
753-
}
754-
755749
createEffect(() => {
756750
const activeId = props.activeSessionId
757751
if (!activeId || activeId === "info") return
@@ -760,52 +754,24 @@ const SessionList: Component<SessionListProps> = (props) => {
760754
ensureSessionAncestorsExpanded(props.instanceId, activeId)
761755
})
762756

763-
const listEl = createSignal<HTMLElement | null>(null)
764-
765-
const escapeCss = (value: string) => {
766-
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
767-
return (CSS as any).escape(value)
768-
}
769-
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
770-
}
771-
772-
const scrollActiveIntoView = (sessionId: string) => {
773-
const root = listEl[0]()
774-
if (!root) return
775-
776-
const selector = `[data-session-id="${escapeCss(sessionId)}"]`
777-
778-
const scrollNow = () => {
779-
const target = root.querySelector(selector) as HTMLElement | null
780-
if (!target) return
781-
target.scrollIntoView({ block: "nearest", inline: "nearest" })
782-
}
783-
784-
if (typeof requestAnimationFrame === "undefined") {
785-
scrollNow()
786-
return
787-
}
788-
789-
// Wait a couple frames so expand/collapse DOM settles.
790-
let raf1 = 0
791-
let raf2 = 0
792-
raf1 = requestAnimationFrame(() => {
793-
raf2 = requestAnimationFrame(() => {
794-
scrollNow()
795-
})
796-
})
797-
798-
onCleanup(() => {
799-
if (raf1) cancelAnimationFrame(raf1)
800-
if (raf2) cancelAnimationFrame(raf2)
801-
})
802-
}
757+
createEffect(on(
758+
() => [props.activeSessionId, virtualizerHandle()] as const,
759+
([activeId, handle]) => {
760+
if (!activeId || activeId === "info" || !handle) return
761+
762+
const scroll = () => {
763+
const index = visibleProjection().indexById.get(activeId)
764+
if (index !== undefined) handle.scrollToIndex(index, { align: "nearest" })
765+
}
766+
if (typeof requestAnimationFrame === "undefined") {
767+
scroll()
768+
return
769+
}
803770

804-
createEffect(() => {
805-
const activeId = props.activeSessionId
806-
if (!activeId || activeId === "info") return
807-
scrollActiveIntoView(activeId)
808-
})
771+
const frame = requestAnimationFrame(scroll)
772+
onCleanup(() => cancelAnimationFrame(frame))
773+
},
774+
))
809775

810776
return (
811777
<div
@@ -883,7 +849,21 @@ const SessionList: Component<SessionListProps> = (props) => {
883849
</div>
884850
</Show>
885851

886-
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
852+
<div
853+
class="session-list flex-1 overflow-y-auto"
854+
ref={setListEl}
855+
onFocusIn={(event) => {
856+
const target = event.target
857+
if (!(target instanceof Element)) return
858+
const row = target.closest<HTMLElement>("[data-session-id]")
859+
setFocusedSessionId(row?.dataset.sessionId)
860+
}}
861+
onFocusOut={(event) => {
862+
const nextTarget = event.relatedTarget
863+
if (nextTarget instanceof Node && listEl()?.contains(nextTarget)) return
864+
setFocusedSessionId(undefined)
865+
}}
866+
>
887867

888868
<Show when={sessionListError()}>
889869
{(error) => (
@@ -897,23 +877,42 @@ const SessionList: Component<SessionListProps> = (props) => {
897877
)}
898878
</Show>
899879

900-
<Show when={!sessionListError() && isFetchingSessions() && filteredThreads().length === 0}>
880+
<Show when={!sessionListError() && isFetchingSessions() && visibleProjection().ids.length === 0}>
901881
<div class="flex items-center justify-center p-4 text-xs text-muted" role="status">
902882
<span class="animate-pulse">{t("sessionList.loading.initial")}</span>
903883
</div>
904884
</Show>
905885

906-
<Show when={filteredThreads().length > 0}>
907-
<div class="session-section">
908-
<For each={filteredThreads()}>
909-
{(thread, index) => (
910-
<SessionThreadRow
911-
thread={thread}
912-
depth={0}
913-
isLastChild={index() === filteredThreads().length - 1}
914-
/>
915-
)}
916-
</For>
886+
<Show when={visibleProjection().ids.length > 0 || hasMore() || isFetchingSessions()}>
887+
<div class="session-section">
888+
<Show when={visibleProjection().ids.length > 0}>
889+
<Virtualizer
890+
ref={setVirtualizerHandle}
891+
data={visibleProjection().ids}
892+
scrollRef={listEl()}
893+
bufferSize={400}
894+
keepMounted={keptMountedIndexes()}
895+
>
896+
{(sessionId, index) => {
897+
const row = createMemo(() => visibleProjection().rowsById.get(sessionId))
898+
return (
899+
<Show when={row()}>
900+
{(current) => (
901+
<SessionRow
902+
session={current().thread.session}
903+
depth={current().depth}
904+
hasChildren={current().hasChildren}
905+
expanded={current().expanded}
906+
onToggleExpand={() => toggleSessionExpanded(props.instanceId, sessionId)}
907+
isLastChild={current().isLastChild}
908+
isLastRow={index() === visibleProjection().ids.length - 1 && !hasMore() && !isFetchingSessions()}
909+
/>
910+
)}
911+
</Show>
912+
)
913+
}}
914+
</Virtualizer>
915+
</Show>
917916
<Show when={hasMore() || isFetchingSessions()}>
918917
<div
919918
ref={(el) => setSentinelEl(el)}

packages/ui/src/stores/session-tree.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
buildSessionThreadsFromMap,
77
collectSessionThreadIds,
88
collectVisibleSessionIds,
9+
flattenVisibleSessionThreads,
910
findSessionThread,
1011
getDescendantSessionsFromMap,
1112
getSessionAncestorIdsFromMap,
@@ -64,6 +65,76 @@ describe("session tree", () => {
6465
assert.deepEqual(collectVisibleSessionIds(threads, new Set(["root", "child"])), ["root", "child", "grandchild"])
6566
})
6667

68+
it("flattens visible nested rows in pre-order with sibling metadata", () => {
69+
const sessions = sessionMap([
70+
["root", null, 100],
71+
["child", "root", 400],
72+
["grandchild", "child", 300],
73+
["sibling", "root", 200],
74+
["other-root", null, 50],
75+
])
76+
const threads = buildSessionThreadsFromMap(sessions, ["root", "other-root"])
77+
const expanded = new Set(["root", "child"])
78+
const rows = flattenVisibleSessionThreads(threads, (id) => expanded.has(id))
79+
80+
assert.deepEqual(rows.map((row) => row.sessionId), ["root", "child", "grandchild", "sibling", "other-root"])
81+
assert.deepEqual(rows.map((row) => row.depth), [0, 1, 2, 1, 0])
82+
assert.deepEqual(rows.map((row) => row.isLastChild), [false, false, true, true, true])
83+
assert.deepEqual(rows.map((row) => row.expanded), [true, true, false, false, false])
84+
})
85+
86+
it("does not expose descendants through a collapsed ancestor", () => {
87+
const sessions = sessionMap([
88+
["root", null, 100],
89+
["child", "root", 200],
90+
["grandchild", "child", 300],
91+
])
92+
const threads = buildSessionThreadsFromMap(sessions, ["root"])
93+
const rows = flattenVisibleSessionThreads(threads, (id) => id === "child")
94+
95+
assert.deepEqual(rows.map((row) => row.sessionId), ["root"])
96+
})
97+
98+
it("matches the existing visible-id traversal for expansion sets", () => {
99+
const sessions = sessionMap([
100+
["root", null, 100],
101+
["child", "root", 200],
102+
["grandchild", "child", 300],
103+
["sibling", "root", 400],
104+
])
105+
const threads = buildSessionThreadsFromMap(sessions, ["root"])
106+
107+
for (const expanded of [new Set<string>(), new Set(["root"]), new Set(["root", "child"])]) {
108+
assert.deepEqual(
109+
flattenVisibleSessionThreads(threads, (id) => expanded.has(id)).map((row) => row.sessionId),
110+
collectVisibleSessionIds(threads, expanded),
111+
)
112+
}
113+
})
114+
115+
it("derives children from the projected filtered tree", () => {
116+
const sessions = sessionMap([
117+
["root", null, 100],
118+
["child", "root", 200],
119+
])
120+
const [filteredRoot] = buildSessionThreadsFromMap(sessions, ["root"], new Set())
121+
const [row] = flattenVisibleSessionThreads([filteredRoot], () => true)
122+
123+
assert.equal(row.hasChildren, false)
124+
assert.equal(row.expanded, false)
125+
})
126+
127+
it("projects large root lists without dropping or duplicating sessions", () => {
128+
const definitions: Array<[string, string | null, number]> = []
129+
for (let index = 0; index < 1_000; index += 1) definitions.push([`session-${index}`, null, index])
130+
const sessions = sessionMap(definitions)
131+
const threads = buildSessionThreadsFromMap(sessions, definitions.map(([id]) => id))
132+
const rows = flattenVisibleSessionThreads(threads, () => false)
133+
134+
assert.equal(rows.length, 1_000)
135+
assert.equal(new Set(rows.map((row) => row.sessionId)).size, 1_000)
136+
})
137+
67138
it("resolves roots and ancestors across arbitrary depth", () => {
68139
const sessions = sessionMap([
69140
["root", null, 100],

0 commit comments

Comments
 (0)