tenseleyflow/claudex / b612367

Browse files

perf: stable row callbacks + collapsed passes + scroll effect gating

Authored by espadonne
SHA
b612367e928a9a1b2022fd91a8e4dd0712ed2ebb
Parents
1ed01e3
Tree
924bdab

1 changed file

StatusFile+-
M src/components/ProjectsPane.tsx 52 82
src/components/ProjectsPane.tsxmodified
@@ -1,4 +1,4 @@
1
-import { memo, useEffect, useMemo, useRef, useState } from "react";
1
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
22
 import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
33
 
44
 import { PaneHeader } from "@/components/panes/PaneHeader";
@@ -6,19 +6,11 @@ import { relativeTime, shortEntrypoint, shortModel, tildeify } from "@/lib/forma
66
 import { useSessionStore } from "@/lib/store/sessions";
77
 import type { Project, SessionSummary } from "@/lib/ipc/types";
88
 
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
-
9
+// Flat-row virtualization model for the sidebar. The previous tree
10
+// walk reconciled every row on every store tick; virtualizing
11
+// bounds the live row count to the viewport and a small overscan
12
+// buffer, and memoized row components mean rescans that return
13
+// equal-by-key rows don't re-render.
2214
 type ProjectsRow =
2315
   | {
2416
       kind: "project-header";
@@ -138,23 +130,18 @@ function buildRows(
138130
   return rows;
139131
 }
140132
 
141
-// ---------------------------------------------------------------
142
-// Top-level pane
143
-// ---------------------------------------------------------------
144
-
145133
 export function ProjectsPane() {
146134
   const projects = useSessionStore((s) => s.projects);
147135
   const loading = useSessionStore((s) => s.loading.projects);
148136
   const expandedProjectIds = useSessionStore((s) => s.expandedProjectIds);
149137
   const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
150
-  const toggleProject = useSessionStore((s) => s.toggleProject);
151
-  const selectSession = useSessionStore((s) => s.selectSession);
152138
   const rescan = useSessionStore((s) => s.rescan);
153139
 
154140
   const [observerExpanded, setObserverExpanded] = useState(false);
155141
   const [archiveExpanded, setArchiveExpanded] = useState(false);
156142
 
157143
   const virtuosoRef = useRef<VirtuosoHandle | null>(null);
144
+  const rowsRef = useRef<ProjectsRow[]>([]);
158145
 
159146
   const rows = useMemo(
160147
     () =>
@@ -167,27 +154,28 @@ export function ProjectsPane() {
167154
       ),
168155
     [projects, expandedProjectIds, observerExpanded, archiveExpanded, loading],
169156
   );
157
+  rowsRef.current = rows;
170158
 
171
-  const regularCount = useMemo(
172
-    () => projects.filter((p) => p.category === "regular").length,
173
-    [projects],
174
-  );
175
-  const totalSessionCount = useMemo(
176
-    () =>
177
-      projects
178
-        .filter((p) => p.category === "regular")
179
-        .reduce((acc, p) => acc + p.sessionCount, 0),
180
-    [projects],
181
-  );
159
+  // Single pass over projects for the header subtitle.
160
+  const { regularCount, totalSessionCount } = useMemo(() => {
161
+    let count = 0;
162
+    let total = 0;
163
+    for (const p of projects) {
164
+      if (p.category === "regular") {
165
+        count += 1;
166
+        total += p.sessionCount;
167
+      }
168
+    }
169
+    return { regularCount: count, totalSessionCount: total };
170
+  }, [projects]);
182171
 
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.
172
+  // Scroll the selected session into view only when the selection
173
+  // itself changes — the previous version fired on every rescan
174
+  // because `rows` was in its dep array, which yanked the viewport
175
+  // back to the selected row every 2s as the user scrolled away.
188176
   useEffect(() => {
189177
     if (!selectedSessionId) return;
190
-    const idx = rows.findIndex(
178
+    const idx = rowsRef.current.findIndex(
191179
       (r) => r.kind === "session-row" && r.session.id === selectedSessionId,
192180
     );
193181
     if (idx >= 0) {
@@ -197,7 +185,24 @@ export function ProjectsPane() {
197185
         behavior: "auto",
198186
       });
199187
     }
200
-  }, [selectedSessionId, rows]);
188
+  }, [selectedSessionId]);
189
+
190
+  const toggleObserver = useCallback(
191
+    () => setObserverExpanded((x) => !x),
192
+    [],
193
+  );
194
+  const toggleArchive = useCallback(() => setArchiveExpanded((x) => !x), []);
195
+
196
+  const renderItem = useCallback(
197
+    (_index: number, row: ProjectsRow) => (
198
+      <RowRenderer
199
+        row={row}
200
+        onToggleObserver={toggleObserver}
201
+        onToggleArchive={toggleArchive}
202
+      />
203
+    ),
204
+    [toggleObserver, toggleArchive],
205
+  );
201206
 
202207
   return (
203208
     <div className="flex h-full flex-col overflow-hidden">
@@ -226,40 +231,21 @@ export function ProjectsPane() {
226231
           data={rows}
227232
           computeItemKey={(_index, row) => row.key}
228233
           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
-          )}
234
+          itemContent={renderItem}
239235
         />
240236
       </div>
241237
     </div>
242238
   );
243239
 }
244240
 
245
-// ---------------------------------------------------------------
246
-// Row renderer (memoized)
247
-// ---------------------------------------------------------------
248
-
249241
 interface RowRendererProps {
250242
   row: ProjectsRow;
251
-  selectedSessionId: string | null;
252
-  onToggleProject: (id: string) => void;
253
-  onSelectSession: (session: SessionSummary) => Promise<void>;
254243
   onToggleObserver: () => void;
255244
   onToggleArchive: () => void;
256245
 }
257246
 
258247
 const RowRenderer = memo(function RowRenderer({
259248
   row,
260
-  selectedSessionId,
261
-  onToggleProject,
262
-  onSelectSession,
263249
   onToggleObserver,
264250
   onToggleArchive,
265251
 }: RowRendererProps) {
@@ -271,18 +257,10 @@ const RowRenderer = memo(function RowRenderer({
271257
           expanded={row.expanded}
272258
           muted={row.muted}
273259
           allowNewSession={row.allowNewSession}
274
-          onToggle={() => onToggleProject(row.project.id)}
275260
         />
276261
       );
277262
     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
-      );
263
+      return <SessionRowInner session={row.session} muted={row.muted} />;
286264
     case "section-header":
287265
       return (
288266
         <SectionHeaderRow
@@ -300,26 +278,20 @@ const RowRenderer = memo(function RowRenderer({
300278
   }
301279
 });
302280
 
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
-
308281
 const ProjectHeaderRow = memo(function ProjectHeaderRow({
309282
   project,
310283
   expanded,
311284
   muted,
312285
   allowNewSession,
313
-  onToggle,
314286
 }: {
315287
   project: Project;
316288
   expanded: boolean;
317289
   muted: boolean;
318290
   allowNewSession: boolean;
319
-  onToggle: () => void;
320291
 }) {
321292
   const hasSessions = project.sessions.length > 0;
322293
   const beginNewSession = useSessionStore((s) => s.beginNewSession);
294
+  const toggleProject = useSessionStore((s) => s.toggleProject);
323295
   // Narrow selector — only re-render when THIS project's live-pty
324296
   // rollup flips, not on every unrelated ptyIds mutation.
325297
   const hasChildPty = useSessionStore((s) => {
@@ -333,7 +305,7 @@ const ProjectHeaderRow = memo(function ProjectHeaderRow({
333305
       <div className="relative flex items-stretch">
334306
         <button
335307
           type="button"
336
-          onClick={onToggle}
308
+          onClick={() => toggleProject(project.id)}
337309
           className="flex w-full items-start gap-1.5 px-2 py-1.5 pr-8 text-left transition hover:bg-bg-1"
338310
         >
339311
           <span className="mt-0.5 w-3 shrink-0 text-[10px] text-fg-3">
@@ -388,19 +360,17 @@ const ProjectHeaderRow = memo(function ProjectHeaderRow({
388360
 
389361
 const SessionRowInner = memo(function SessionRowInner({
390362
   session,
391
-  selected,
392363
   muted,
393
-  onSelect,
394364
 }: {
395365
   session: SessionSummary;
396
-  selected: boolean;
397366
   muted: boolean;
398
-  onSelect: () => void;
399367
 }) {
400
-  // Narrow selector — zustand diffs the returned boolean, so this
401
-  // only re-renders when *this* row's live-PTY state flips.
368
+  // Narrow selectors — only re-render when the row's own pty/selected
369
+  // state actually flips, not on every unrelated store mutation.
402370
   const hasLivePty = useSessionStore((s) => s.ptyIds.has(session.id));
371
+  const selected = useSessionStore((s) => s.selectedSessionId === session.id);
403372
   const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
373
+  const selectSession = useSessionStore((s) => s.selectSession);
404374
   return (
405375
     <div
406376
       className={`group/session relative ml-[14px] border-l border-border/60 ${
@@ -409,7 +379,7 @@ const SessionRowInner = memo(function SessionRowInner({
409379
     >
410380
       <button
411381
         type="button"
412
-        onClick={onSelect}
382
+        onClick={() => void selectSession(session)}
413383
         className={`flex w-full flex-col gap-0.5 border-l-2 py-1.5 pl-3 pr-8 text-left transition ${
414384
           selected
415385
             ? "border-accent bg-bg-2"