tenseleyflow/claudex / c45b847

Browse files

perf: virtualize sidebar with react-virtuoso flat-row model

Authored by espadonne
SHA
c45b847edf531566974c8bd8c46649143a02e35b
Parents
19ab69c
Tree
66f084b

1 changed file

StatusFile+-
M src/components/ProjectsPane.tsx 286 141
src/components/ProjectsPane.tsxmodified
@@ -1,14 +1,151 @@
1
-import { useMemo, useState } from "react";
1
+import { memo, useEffect, useMemo, useRef, useState } from "react";
2
+import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
23
 
34
 import { PaneHeader } from "@/components/panes/PaneHeader";
45
 import { relativeTime, shortEntrypoint, shortModel, tildeify } from "@/lib/format";
56
 import { useSessionStore } from "@/lib/store/sessions";
67
 import type { Project, SessionSummary } from "@/lib/ipc/types";
78
 
9
+// ---------------------------------------------------------------
10
+// Virtualized flat-row model
11
+// ---------------------------------------------------------------
12
+//
13
+// The previous implementation was a straight `projects.map(...)`
14
+// nested inside section wrappers. That worked for small session
15
+// sets but beachballed once the user had hundreds of sessions —
16
+// every store update re-reconciled every row. We now flatten the
17
+// tree into a single list of typed rows and feed it to react-
18
+// virtuoso, which only mounts rows currently in the viewport (plus
19
+// a small overscan buffer). Rescans that return an equal-by-key
20
+// row skip re-rendering thanks to the `React.memo` row renderer.
21
+
22
+type ProjectsRow =
23
+  | {
24
+      kind: "project-header";
25
+      key: string;
26
+      project: Project;
27
+      expanded: boolean;
28
+      muted: boolean;
29
+      allowNewSession: boolean;
30
+    }
31
+  | {
32
+      kind: "session-row";
33
+      key: string;
34
+      session: SessionSummary;
35
+      muted: boolean;
36
+    }
37
+  | {
38
+      kind: "section-header";
39
+      key: string;
40
+      section: "observer" | "archive";
41
+      label: string;
42
+      tooltip: string;
43
+      totalSessions: number;
44
+      expanded: boolean;
45
+    }
46
+  | { kind: "empty-state"; key: string; label: string };
47
+
48
+function buildRows(
49
+  projects: Project[],
50
+  expandedProjectIds: Set<string>,
51
+  observerExpanded: boolean,
52
+  archiveExpanded: boolean,
53
+  loading: boolean,
54
+): ProjectsRow[] {
55
+  if (projects.length === 0) {
56
+    return [
57
+      {
58
+        kind: "empty-state",
59
+        key: "empty",
60
+        label: loading
61
+          ? "loading projects…"
62
+          : "no projects found under ~/.claude/projects/",
63
+      },
64
+    ];
65
+  }
66
+
67
+  const regular: Project[] = [];
68
+  const observer: Project[] = [];
69
+  const archive: Project[] = [];
70
+  for (const p of projects) {
71
+    if (p.category === "observer") observer.push(p);
72
+    else if (p.category === "archive") archive.push(p);
73
+    else regular.push(p);
74
+  }
75
+
76
+  const rows: ProjectsRow[] = [];
77
+
78
+  const pushProjectAndSessions = (
79
+    project: Project,
80
+    muted: boolean,
81
+    allowNewSession: boolean,
82
+  ) => {
83
+    const expanded = expandedProjectIds.has(project.id);
84
+    rows.push({
85
+      kind: "project-header",
86
+      key: `proj:${project.id}`,
87
+      project,
88
+      expanded,
89
+      muted,
90
+      allowNewSession,
91
+    });
92
+    if (expanded) {
93
+      for (const session of project.sessions) {
94
+        rows.push({
95
+          kind: "session-row",
96
+          key: `sess:${session.id}`,
97
+          session,
98
+          muted,
99
+        });
100
+      }
101
+    }
102
+  };
103
+
104
+  for (const p of regular) pushProjectAndSessions(p, false, true);
105
+
106
+  if (observer.length > 0) {
107
+    const total = observer.reduce((acc, p) => acc + p.sessionCount, 0);
108
+    rows.push({
109
+      kind: "section-header",
110
+      key: "sec:observer",
111
+      section: "observer",
112
+      label: "claude-mem observer",
113
+      tooltip: "claude-mem observer agent sessions",
114
+      totalSessions: total,
115
+      expanded: observerExpanded,
116
+    });
117
+    if (observerExpanded) {
118
+      for (const p of observer) pushProjectAndSessions(p, true, false);
119
+    }
120
+  }
121
+
122
+  if (archive.length > 0) {
123
+    rows.push({
124
+      kind: "section-header",
125
+      key: "sec:archive",
126
+      section: "archive",
127
+      label: "archive",
128
+      tooltip:
129
+        "projects with prompt history but no on-disk transcripts. Rebuilt from ~/.claude/history.jsonl",
130
+      totalSessions: archive.length,
131
+      expanded: archiveExpanded,
132
+    });
133
+    if (archiveExpanded) {
134
+      for (const p of archive) pushProjectAndSessions(p, true, false);
135
+    }
136
+  }
137
+
138
+  return rows;
139
+}
140
+
141
+// ---------------------------------------------------------------
142
+// Top-level pane
143
+// ---------------------------------------------------------------
144
+
8145
 export function ProjectsPane() {
9146
   const projects = useSessionStore((s) => s.projects);
10147
   const loading = useSessionStore((s) => s.loading.projects);
11
-  const expanded = useSessionStore((s) => s.expandedProjectIds);
148
+  const expandedProjectIds = useSessionStore((s) => s.expandedProjectIds);
12149
   const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
13150
   const toggleProject = useSessionStore((s) => s.toggleProject);
14151
   const selectSession = useSessionStore((s) => s.selectSession);
@@ -17,23 +154,51 @@ export function ProjectsPane() {
17154
   const [observerExpanded, setObserverExpanded] = useState(false);
18155
   const [archiveExpanded, setArchiveExpanded] = useState(false);
19156
 
20
-  const { regular, observer, archive } = useMemo(() => {
21
-    const regular: Project[] = [];
22
-    const observer: Project[] = [];
23
-    const archive: Project[] = [];
24
-    for (const p of projects) {
25
-      if (p.category === "observer") observer.push(p);
26
-      else if (p.category === "archive") archive.push(p);
27
-      else regular.push(p);
28
-    }
29
-    return { regular, observer, archive };
30
-  }, [projects]);
157
+  const virtuosoRef = useRef<VirtuosoHandle | null>(null);
158
+
159
+  const rows = useMemo(
160
+    () =>
161
+      buildRows(
162
+        projects,
163
+        expandedProjectIds,
164
+        observerExpanded,
165
+        archiveExpanded,
166
+        loading,
167
+      ),
168
+    [projects, expandedProjectIds, observerExpanded, archiveExpanded, loading],
169
+  );
31170
 
171
+  const regularCount = useMemo(
172
+    () => projects.filter((p) => p.category === "regular").length,
173
+    [projects],
174
+  );
32175
   const totalSessionCount = useMemo(
33
-    () => regular.reduce((acc, p) => acc + p.sessionCount, 0),
34
-    [regular],
176
+    () =>
177
+      projects
178
+        .filter((p) => p.category === "regular")
179
+        .reduce((acc, p) => acc + p.sessionCount, 0),
180
+    [projects],
35181
   );
36182
 
183
+  // Scroll the selected session into view when it changes out-of-
184
+  // band (e.g. from the titlebar "N terminals" popover or from a
185
+  // deep link). If the row isn't currently in the flat list
186
+  // because its parent project is collapsed, the scroll is a no-op
187
+  // — still correct, just silent.
188
+  useEffect(() => {
189
+    if (!selectedSessionId) return;
190
+    const idx = rows.findIndex(
191
+      (r) => r.kind === "session-row" && r.session.id === selectedSessionId,
192
+    );
193
+    if (idx >= 0) {
194
+      virtuosoRef.current?.scrollToIndex({
195
+        index: idx,
196
+        align: "center",
197
+        behavior: "auto",
198
+      });
199
+    }
200
+  }, [selectedSessionId, rows]);
201
+
37202
   return (
38203
     <div className="flex h-full flex-col overflow-hidden">
39204
       <PaneHeader
@@ -41,7 +206,7 @@ export function ProjectsPane() {
41206
         subtitle={
42207
           loading
43208
             ? "loading…"
44
-            : `${regular.length} project${regular.length === 1 ? "" : "s"} · ${totalSessionCount} thread${totalSessionCount === 1 ? "" : "s"}`
209
+            : `${regularCount} project${regularCount === 1 ? "" : "s"} · ${totalSessionCount} thread${totalSessionCount === 1 ? "" : "s"}`
45210
         }
46211
         right={
47212
           <button
@@ -55,92 +220,114 @@ export function ProjectsPane() {
55220
           </button>
56221
         }
57222
       />
58
-      <div className="flex-1 overflow-y-auto">
59
-        {projects.length === 0 ? (
60
-          <EmptyState
61
-            label={
62
-              loading
63
-                ? "loading projects…"
64
-                : "no projects found under ~/.claude/projects/"
65
-            }
66
-          />
67
-        ) : (
68
-          <>
69
-            {regular.map((project) => (
70
-              <ProjectNode
71
-                key={project.id}
72
-                project={project}
73
-                expanded={expanded.has(project.id)}
74
-                onToggle={() => toggleProject(project.id)}
75
-                selectedSessionId={selectedSessionId}
76
-                onSelectSession={(session) => void selectSession(session)}
77
-              />
78
-            ))}
79
-            {observer.length > 0 && (
80
-              <CollapsibleSection
81
-                label="claude-mem observer"
82
-                tooltip="claude-mem observer agent sessions"
83
-                totalSessions={observer.reduce(
84
-                  (acc, p) => acc + p.sessionCount,
85
-                  0,
86
-                )}
87
-                projects={observer}
88
-                expanded={observerExpanded}
89
-                onToggle={() => setObserverExpanded((x) => !x)}
90
-                openProjects={expanded}
91
-                onToggleProject={toggleProject}
92
-                selectedSessionId={selectedSessionId}
93
-                onSelectSession={(s) => void selectSession(s)}
94
-              />
95
-            )}
96
-            {archive.length > 0 && (
97
-              <CollapsibleSection
98
-                label="archive"
99
-                tooltip="projects with prompt history but no on-disk transcripts. Rebuilt from ~/.claude/history.jsonl"
100
-                totalSessions={archive.length}
101
-                projects={archive}
102
-                expanded={archiveExpanded}
103
-                onToggle={() => setArchiveExpanded((x) => !x)}
104
-                openProjects={expanded}
105
-                onToggleProject={toggleProject}
106
-                selectedSessionId={selectedSessionId}
107
-                onSelectSession={(s) => void selectSession(s)}
108
-              />
109
-            )}
110
-          </>
111
-        )}
223
+      <div className="min-h-0 flex-1">
224
+        <Virtuoso
225
+          ref={virtuosoRef}
226
+          data={rows}
227
+          computeItemKey={(_index, row) => row.key}
228
+          increaseViewportBy={{ top: 400, bottom: 400 }}
229
+          itemContent={(_index, row) => (
230
+            <RowRenderer
231
+              row={row}
232
+              selectedSessionId={selectedSessionId}
233
+              onToggleProject={toggleProject}
234
+              onSelectSession={selectSession}
235
+              onToggleObserver={() => setObserverExpanded((x) => !x)}
236
+              onToggleArchive={() => setArchiveExpanded((x) => !x)}
237
+            />
238
+          )}
239
+        />
112240
       </div>
113241
     </div>
114242
   );
115243
 }
116244
 
117
-function ProjectNode({
245
+// ---------------------------------------------------------------
246
+// Row renderer (memoized)
247
+// ---------------------------------------------------------------
248
+
249
+interface RowRendererProps {
250
+  row: ProjectsRow;
251
+  selectedSessionId: string | null;
252
+  onToggleProject: (id: string) => void;
253
+  onSelectSession: (session: SessionSummary) => Promise<void>;
254
+  onToggleObserver: () => void;
255
+  onToggleArchive: () => void;
256
+}
257
+
258
+const RowRenderer = memo(function RowRenderer({
259
+  row,
260
+  selectedSessionId,
261
+  onToggleProject,
262
+  onSelectSession,
263
+  onToggleObserver,
264
+  onToggleArchive,
265
+}: RowRendererProps) {
266
+  switch (row.kind) {
267
+    case "project-header":
268
+      return (
269
+        <ProjectHeaderRow
270
+          project={row.project}
271
+          expanded={row.expanded}
272
+          muted={row.muted}
273
+          allowNewSession={row.allowNewSession}
274
+          onToggle={() => onToggleProject(row.project.id)}
275
+        />
276
+      );
277
+    case "session-row":
278
+      return (
279
+        <SessionRowInner
280
+          session={row.session}
281
+          selected={row.session.id === selectedSessionId}
282
+          muted={row.muted}
283
+          onSelect={() => void onSelectSession(row.session)}
284
+        />
285
+      );
286
+    case "section-header":
287
+      return (
288
+        <SectionHeaderRow
289
+          label={row.label}
290
+          tooltip={row.tooltip}
291
+          totalSessions={row.totalSessions}
292
+          expanded={row.expanded}
293
+          onToggle={
294
+            row.section === "observer" ? onToggleObserver : onToggleArchive
295
+          }
296
+        />
297
+      );
298
+    case "empty-state":
299
+      return <EmptyState label={row.label} />;
300
+  }
301
+});
302
+
303
+// ---------------------------------------------------------------
304
+// Row components (all memoized — re-render only when their props
305
+// actually change, which for rescans of unchanged rows is never).
306
+// ---------------------------------------------------------------
307
+
308
+const ProjectHeaderRow = memo(function ProjectHeaderRow({
118309
   project,
119310
   expanded,
311
+  muted,
312
+  allowNewSession,
120313
   onToggle,
121
-  selectedSessionId,
122
-  onSelectSession,
123
-  muted = false,
124
-  allowNewSession = true,
125314
 }: {
126315
   project: Project;
127316
   expanded: boolean;
317
+  muted: boolean;
318
+  allowNewSession: boolean;
128319
   onToggle: () => void;
129
-  selectedSessionId: string | null;
130
-  onSelectSession: (session: SessionSummary) => void;
131
-  muted?: boolean;
132
-  allowNewSession?: boolean;
133320
 }) {
134321
   const hasSessions = project.sessions.length > 0;
135322
   const beginNewSession = useSessionStore((s) => s.beginNewSession);
136
-  const ptyIds = useSessionStore((s) => s.ptyIds);
137
-  // Does any session under this project have a live PTY? Drives
138
-  // the muted rollup dot next to the project row so the user can
139
-  // see background activity even with the tree collapsed.
140
-  const hasChildPty = useMemo(
141
-    () => project.sessions.some((s) => ptyIds.has(s.id)),
142
-    [project.sessions, ptyIds],
143
-  );
323
+  // Narrow selector — only re-render when THIS project's live-pty
324
+  // rollup flips, not on every unrelated ptyIds mutation.
325
+  const hasChildPty = useSessionStore((s) => {
326
+    for (const session of project.sessions) {
327
+      if (s.ptyIds.has(session.id)) return true;
328
+    }
329
+    return false;
330
+  });
144331
   return (
145332
     <div className={`group ${muted ? "opacity-70" : ""}`}>
146333
       <div className="relative flex items-stretch">
@@ -195,46 +382,30 @@ function ProjectNode({
195382
           </button>
196383
         )}
197384
       </div>
198
-      {expanded && hasSessions && (
199
-        <div className="border-l border-border/60 ml-[14px] pb-1">
200
-          {project.sessions.map((session) => (
201
-            <SessionRow
202
-              key={session.id}
203
-              session={session}
204
-              selected={session.id === selectedSessionId}
205
-              onSelect={() => onSelectSession(session)}
206
-            />
207
-          ))}
208
-        </div>
209
-      )}
210385
     </div>
211386
   );
212
-}
387
+});
213388
 
214
-function SessionRow({
389
+const SessionRowInner = memo(function SessionRowInner({
215390
   session,
216391
   selected,
392
+  muted,
217393
   onSelect,
218394
 }: {
219395
   session: SessionSummary;
220396
   selected: boolean;
397
+  muted: boolean;
221398
   onSelect: () => void;
222399
 }) {
400
+  // Narrow selector — zustand diffs the returned boolean, so this
401
+  // only re-renders when *this* row's live-PTY state flips.
223402
   const hasLivePty = useSessionStore((s) => s.ptyIds.has(session.id));
224403
   const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
225404
   return (
226
-    // `content-visibility: auto` tells the browser it can skip
227
-    // layout + paint entirely for rows outside the viewport. Huge
228
-    // help for projects with hundreds of sessions where scrolling
229
-    // otherwise forces the whole ancestor chain to recalc on each
230
-    // frame. `contain-intrinsic-size` reserves a stable placeholder
231
-    // so the scrollbar doesn't jitter as rows virtualize in/out.
232405
     <div
233
-      className="group/session relative"
234
-      style={{
235
-        contentVisibility: "auto",
236
-        containIntrinsicSize: "0 56px",
237
-      }}
406
+      className={`group/session relative ml-[14px] border-l border-border/60 ${
407
+        muted ? "opacity-70" : ""
408
+      }`}
238409
     >
239410
       <button
240411
         type="button"
@@ -295,30 +466,20 @@ function SessionRow({
295466
       )}
296467
     </div>
297468
   );
298
-}
469
+});
299470
 
300
-function CollapsibleSection({
471
+const SectionHeaderRow = memo(function SectionHeaderRow({
301472
   label,
302473
   tooltip,
303474
   totalSessions,
304
-  projects,
305475
   expanded,
306476
   onToggle,
307
-  openProjects,
308
-  onToggleProject,
309
-  selectedSessionId,
310
-  onSelectSession,
311477
 }: {
312478
   label: string;
313479
   tooltip: string;
314480
   totalSessions: number;
315
-  projects: Project[];
316481
   expanded: boolean;
317482
   onToggle: () => void;
318
-  openProjects: Set<string>;
319
-  onToggleProject: (id: string) => void;
320
-  selectedSessionId: string | null;
321
-  onSelectSession: (session: SessionSummary) => void;
322483
 }) {
323484
   return (
324485
     <div className="mt-2 border-t border-border">
@@ -332,25 +493,9 @@ function CollapsibleSection({
332493
         <span className="uppercase tracking-wide">{label}</span>
333494
         <span className="ml-auto font-mono text-[10px]">{totalSessions}</span>
334495
       </button>
335
-      {expanded && (
336
-        <div>
337
-          {projects.map((project) => (
338
-            <ProjectNode
339
-              key={project.id}
340
-              project={project}
341
-              expanded={openProjects.has(project.id)}
342
-              onToggle={() => onToggleProject(project.id)}
343
-              selectedSessionId={selectedSessionId}
344
-              onSelectSession={onSelectSession}
345
-              muted
346
-              allowNewSession={false}
347
-            />
348
-          ))}
349
-        </div>
350
-      )}
351496
     </div>
352497
   );
353
-}
498
+});
354499
 
355500
 function EmptyState({ label }: { label: string }) {
356501
   return (