tenseleyflow/claudex / d47e25d

Browse files

perf: skip read_session when target view mode is terminal

Authored by espadonne
SHA
d47e25dd0eadf616227796945cd3e94eea6ecddf
Parents
71832a5
Tree
fbfe8c1

2 changed files

StatusFile+-
M src/components/ViewerPane.tsx 16 5
M src/lib/store/sessions.ts 102 24
src/components/ViewerPane.tsxmodified
@@ -1,4 +1,4 @@
1
-import { lazy, Suspense, useMemo } from "react";
1
+import { lazy, Suspense, useEffect, useMemo } from "react";
22
 
33
 import { ArchiveChatBanner } from "@/components/ArchiveChatBanner";
44
 import { ChatInput } from "@/components/ChatInput";
@@ -27,6 +27,7 @@ export function ViewerPane() {
2727
   const ptyIds = useSessionStore((s) => s.ptyIds);
2828
   const toggleViewerMode = useSessionStore((s) => s.toggleViewerMode);
2929
   const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
30
+  const ensureDetailFor = useSessionStore((s) => s.ensureDetailFor);
3031
 
3132
   // Prefer the pendingSummary when a new session is mid-load — the
3233
   // header should flip to the *clicked* title instantly, even if
@@ -76,10 +77,6 @@ export function ViewerPane() {
7677
     );
7778
   }
7879
 
79
-  // If the header is showing a pending session that hasn't loaded
80
-  // yet (cache miss), render the skeleton so the user sees an
81
-  // instant title-flip + shimmering body instead of a blank pane
82
-  // that looks frozen.
8380
   const showingSkeleton = pendingSummary !== null && detail === null;
8481
 
8582
   const summary = headerSummary;
@@ -88,6 +85,20 @@ export function ViewerPane() {
8885
   const mode = viewerMode.get(summary.id) ?? "cards";
8986
   const hasLivePty = ptyIds.has(summary.id);
9087
 
88
+  // Lazy-load detail if the user is in cards mode for a session
89
+  // whose body we skipped fetching on click (because it defaulted
90
+  // to terminal mode). Only fires when the detail we need genuinely
91
+  // isn't already loaded or in flight.
92
+  const needsLazyCardsLoad =
93
+    mode === "cards" &&
94
+    !isArchive &&
95
+    !loading &&
96
+    (detail === null || detail.summary.id !== summary.id);
97
+  useEffect(() => {
98
+    if (!needsLazyCardsLoad) return;
99
+    void ensureDetailFor(summary);
100
+  }, [needsLazyCardsLoad, summary, ensureDetailFor]);
101
+
91102
   return (
92103
     <Shell
93104
       subtitle={summary.title}
src/lib/store/sessions.tsmodified
@@ -78,22 +78,30 @@ const STDERR_LINE_CAP = 50;
7878
  *  back to A" feel instant without blowing the heap. */
7979
 const DETAIL_CACHE_CAP = 5;
8080
 
81
-/** Insertion-order-based LRU update. JavaScript `Map` iterates in
82
- *  insertion order, so to mark a key as "most recently used" we
83
- *  delete-then-set. Evicts the oldest entry when the cap is
84
- *  exceeded. Returns a new Map (does not mutate the input). */
81
+/** Returns a new Map with `id → detail` marked most-recently-used.
82
+ *  `pinnedId` (the currently-selected session) is never evicted
83
+ *  even when it's the oldest entry — otherwise opening 5 other
84
+ *  sessions would silently drop the one the user is looking at,
85
+ *  and clicking back to it would be a cache miss. */
8586
 function touchDetailCache(
8687
   cache: Map<string, SessionDetail>,
8788
   id: string,
8889
   detail: SessionDetail,
90
+  pinnedId: string | null,
8991
 ): Map<string, SessionDetail> {
9092
   const next = new Map(cache);
9193
   next.delete(id);
9294
   next.set(id, detail);
9395
   while (next.size > DETAIL_CACHE_CAP) {
94
-    const oldest = next.keys().next().value;
95
-    if (oldest === undefined) break;
96
-    next.delete(oldest);
96
+    let evict: string | undefined;
97
+    for (const key of next.keys()) {
98
+      if (key !== pinnedId) {
99
+        evict = key;
100
+        break;
101
+      }
102
+    }
103
+    if (evict === undefined) break;
104
+    next.delete(evict);
97105
   }
98106
   return next;
99107
 }
@@ -157,8 +165,15 @@ interface SessionStore {
157165
 
158166
   /** Called when the user clicks a session row. Takes the full
159167
    *  SessionSummary so we can thread `source` through to the
160
-   *  `read_session` dispatcher. */
168
+   *  `read_session` dispatcher. Only fetches the full session
169
+   *  detail when the resolved view mode is `cards` — terminal
170
+   *  mode skips the fetch entirely to avoid reparsing giant
171
+   *  JSONL files on session switch. */
161172
   selectSession: (session: SessionSummary) => Promise<void>;
173
+  /** Trigger a lazy detail load for a session that the user is
174
+   *  about to view in cards mode (typically after toggling from
175
+   *  terminal mode). No-op when the detail is already loaded. */
176
+  ensureDetailFor: (summary: SessionSummary) => Promise<void>;
162177
 
163178
   rescan: () => Promise<void>;
164179
   subscribeToChanges: () => Promise<void>;
@@ -283,15 +298,20 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
283298
 
284299
   async selectSession(session) {
285300
     if (get().selectedSessionId === session.id) return;
286
-    // Default the viewer mode for this session if it hasn't been set
287
-    // yet. Archive sessions are locked to cards. Disk sessions open
288
-    // in cards mode by default — terminal mode is opt-in per session
289
-    // via the toggle (new-thread entries default to terminal inside
290
-    // `beginNewSession`). CRITICAL: we do NOT touch viewerMode or
291
-    // ptyIds for the *previous* session here — background terminals
292
-    // must keep running across session switches. See the
293
-    // codex-parallel-threads goal in the v1.1 plan.
301
+    // CRITICAL: we do NOT touch viewerMode or ptyIds for the
302
+    // *previous* session — background terminals must keep running
303
+    // across session switches (codex-parallel-threads).
294304
     const cached = get().detailCache.get(session.id) ?? null;
305
+    // Check the resolved view mode for this session. If the user is
306
+    // going to see it as a PTY-backed terminal we skip `readSession`
307
+    // entirely — parsing 171 MB of JSONL for a session that will be
308
+    // rendered as an xterm is pure waste and the largest source of
309
+    // session-switch jank we've measured.
310
+    const resolvedMode =
311
+      get().viewerMode.get(session.id) ??
312
+      (session.source === "archive" ? "cards" : "cards");
313
+    const needsDetail = resolvedMode === "cards";
314
+
295315
     set((s) => {
296316
       const nextMode = new Map(s.viewerMode);
297317
       if (!nextMode.has(session.id)) {
@@ -299,20 +319,19 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
299319
       }
300320
       return {
301321
         selectedSessionId: session.id,
302
-        // Cache hit → swap content instantly (main-thread-free).
303
-        // Cache miss → clear stale detail, let the viewer render
304
-        // its skeleton using `pendingSummary` for the header.
305322
         detail: cached,
306
-        pendingSummary: cached ? null : session,
323
+        pendingSummary: cached || !needsDetail ? null : session,
307324
         viewerMode: nextMode,
308
-        loading: { ...s.loading, detail: true },
325
+        loading: { ...s.loading, detail: needsDetail && !cached },
309326
         error: null,
310327
       };
311328
     });
329
+
330
+    if (!needsDetail) return;
331
+
312332
     // Race guard: if the user clicks session B while session A's
313333
     // readSession is still in flight, A's `set` would otherwise
314
-    // overwrite B's state a moment later. Check the still-selected
315
-    // id after each await and bail if the user has moved on.
334
+    // overwrite B's state a moment later.
316335
     const targetId = session.id;
317336
     try {
318337
       const detail = await readSession(
@@ -324,7 +343,12 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
324343
       set((s) => ({
325344
         detail,
326345
         pendingSummary: null,
327
-        detailCache: touchDetailCache(s.detailCache, session.id, detail),
346
+        detailCache: touchDetailCache(
347
+          s.detailCache,
348
+          session.id,
349
+          detail,
350
+          s.selectedSessionId,
351
+        ),
328352
         loading: { ...s.loading, detail: false },
329353
       }));
330354
     } catch (err) {
@@ -337,6 +361,60 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
337361
     }
338362
   },
339363
 
364
+  /** Lazy-load the full session detail on demand. Called by the
365
+   *  ViewerPane when the user toggles to cards mode and we don't
366
+   *  have the detail yet (because selectSession skipped it for a
367
+   *  terminal-default session). Idempotent — bails if the detail
368
+   *  is already loaded or a load is in flight. */
369
+  async ensureDetailFor(summary) {
370
+    const s = get();
371
+    if (s.detail?.summary.id === summary.id) return;
372
+    if (s.loading.detail && s.pendingSummary?.id === summary.id) return;
373
+    const cached = s.detailCache.get(summary.id);
374
+    if (cached) {
375
+      set((state) => ({
376
+        detail: cached,
377
+        detailCache: touchDetailCache(
378
+          state.detailCache,
379
+          summary.id,
380
+          cached,
381
+          state.selectedSessionId,
382
+        ),
383
+      }));
384
+      return;
385
+    }
386
+    set((state) => ({
387
+      pendingSummary: summary,
388
+      loading: { ...state.loading, detail: true },
389
+    }));
390
+    try {
391
+      const detail = await readSession(
392
+        summary.projectId,
393
+        summary.id,
394
+        summary.source,
395
+      );
396
+      if (get().selectedSessionId !== summary.id) return;
397
+      set((state) => ({
398
+        detail,
399
+        pendingSummary: null,
400
+        detailCache: touchDetailCache(
401
+          state.detailCache,
402
+          summary.id,
403
+          detail,
404
+          state.selectedSessionId,
405
+        ),
406
+        loading: { ...state.loading, detail: false },
407
+      }));
408
+    } catch (err) {
409
+      if (get().selectedSessionId !== summary.id) return;
410
+      set((state) => ({
411
+        pendingSummary: null,
412
+        loading: { ...state.loading, detail: false },
413
+        error: formatError(err),
414
+      }));
415
+    }
416
+  },
417
+
340418
   async rescan() {
341419
     set((s) => ({ loading: { ...s.loading, projects: true }, error: null }));
342420
     try {