tenseleyflow/claudex / d2d4313

Browse files

perf: lru cache + skeleton loading state + header title on click

Authored by espadonne
SHA
d2d4313202248c200718f07a2270dfd0f211172b
Parents
b67d967
Tree
bcc8ae4

2 changed files

StatusFile+-
M src/components/ViewerPane.tsx 66 23
M src/lib/store/sessions.ts 50 1
src/components/ViewerPane.tsxmodified
@@ -8,17 +8,24 @@ import { TurnStatusBanner } from "@/components/TurnStatusBanner";
88
 import { PaneHeader } from "@/components/panes/PaneHeader";
99
 import { shortModel } from "@/lib/format";
1010
 import { useSessionStore } from "@/lib/store/sessions";
11
+import type { SessionSummary } from "@/lib/ipc/types";
1112
 
1213
 export function ViewerPane() {
1314
   const detail = useSessionStore((s) => s.detail);
1415
   const loading = useSessionStore((s) => s.loading.detail);
15
-  const selectedSessionId = useSessionStore((s) => s.selectedSessionId);
16
+  const pendingSummary = useSessionStore((s) => s.pendingSummary);
1617
   const inFlightTurns = useSessionStore((s) => s.inFlightTurns);
1718
   const viewerMode = useSessionStore((s) => s.viewerMode);
1819
   const ptyIds = useSessionStore((s) => s.ptyIds);
1920
   const toggleViewerMode = useSessionStore((s) => s.toggleViewerMode);
2021
   const closeSessionPty = useSessionStore((s) => s.closeSessionPty);
2122
 
23
+  // Prefer the pendingSummary when a new session is mid-load — the
24
+  // header should flip to the *clicked* title instantly, even if
25
+  // `detail` still holds the previous session's content or is null.
26
+  const headerSummary: SessionSummary | null =
27
+    pendingSummary ?? detail?.summary ?? null;
28
+
2229
   // Is an in-flight turn currently targeting the visible session?
2330
   // Used to flip auto-follow on the timeline so new tokens scroll
2431
   // into view without user intervention.
@@ -42,28 +49,17 @@ export function ViewerPane() {
4249
   // return followed by a later `useMemo` triggers the
4350
   // "Rendered more hooks than during the previous render" crash.
4451
   const claudeArgs = useMemo<string[]>(() => {
45
-    if (!detail) return [];
46
-    const { summary } = detail;
47
-    if (summary.source === "archive") return [];
48
-    if (summary.id.startsWith("pending-")) {
49
-      return ["--session-id", summary.id.replace(/^pending-/, "")];
52
+    if (!headerSummary) return [];
53
+    if (headerSummary.source === "archive") return [];
54
+    if (headerSummary.id.startsWith("pending-")) {
55
+      return ["--session-id", headerSummary.id.replace(/^pending-/, "")];
5056
     }
51
-    return ["--resume", summary.id];
52
-  }, [detail]);
57
+    return ["--resume", headerSummary.id];
58
+  }, [headerSummary]);
5359
 
54
-  if (loading) {
60
+  if (!headerSummary) {
5561
     return (
56
-      <Shell subtitle={selectedSessionId ?? ""}>
57
-        <div className="flex h-full items-center justify-center text-xs text-fg-3">
58
-          loading session…
59
-        </div>
60
-      </Shell>
61
-    );
62
-  }
63
-
64
-  if (!detail) {
65
-    return (
66
-      <Shell subtitle="">
62
+      <Shell subtitle="" loading={false}>
6763
         <div className="flex h-full flex-col items-center justify-center gap-2 text-center text-xs text-fg-3">
6864
           <div className="text-sm text-fg-2">select a session</div>
6965
           <div>browse projects on the left → sessions in the middle</div>
@@ -72,7 +68,14 @@ export function ViewerPane() {
7268
     );
7369
   }
7470
 
75
-  const { summary, messages } = detail;
71
+  // If the header is showing a pending session that hasn't loaded
72
+  // yet (cache miss), render the skeleton so the user sees an
73
+  // instant title-flip + shimmering body instead of a blank pane
74
+  // that looks frozen.
75
+  const showingSkeleton = pendingSummary !== null && detail === null;
76
+
77
+  const summary = headerSummary;
78
+  const messages = detail?.messages ?? [];
7679
   const isArchive = summary.source === "archive";
7780
   const mode = viewerMode.get(summary.id) ?? "cards";
7881
   const hasLivePty = ptyIds.has(summary.id);
@@ -80,6 +83,7 @@ export function ViewerPane() {
8083
   return (
8184
     <Shell
8285
       subtitle={summary.title}
86
+      loading={loading}
8387
       right={
8488
         <div className="flex items-center gap-2 text-[10px] text-fg-3">
8589
           {isArchive ? (
@@ -107,7 +111,7 @@ export function ViewerPane() {
107111
               {shortModel(summary.model)}
108112
             </span>
109113
           )}
110
-          <span>{messages.length} messages</span>
114
+          <span>{summary.messageCount} messages</span>
111115
           {summary.gitBranch && (
112116
             <>
113117
               <span>•</span>
@@ -127,7 +131,9 @@ export function ViewerPane() {
127131
           cwd={summary.cwd}
128132
           claudeArgs={claudeArgs}
129133
         />
130
-      ) : (
134
+      ) : showingSkeleton ? (
135
+        <CardsSkeleton />
136
+      ) : detail ? (
131137
         <div className="flex h-full flex-col">
132138
           <div className="min-h-0 flex-1">
133139
             <MessageTimeline messages={messages} autoFollow={hasActiveTurn} />
@@ -139,11 +145,36 @@ export function ViewerPane() {
139145
             <ChatInput detail={detail} />
140146
           )}
141147
         </div>
148
+      ) : (
149
+        <CardsSkeleton />
142150
       )}
143151
     </Shell>
144152
   );
145153
 }
146154
 
155
+/** Shimmer placeholder shown while a session detail is loading.
156
+ *  Six ghost message cards, sized similarly to real messages, with
157
+ *  a subtle animation. Keeps the viewer feeling alive instead of
158
+ *  showing a frozen "loading…" string. */
159
+function CardsSkeleton() {
160
+  return (
161
+    <div className="flex h-full flex-col gap-4 overflow-hidden p-4">
162
+      {Array.from({ length: 6 }).map((_, i) => (
163
+        <div
164
+          key={i}
165
+          className="flex animate-pulse flex-col gap-2"
166
+          style={{ animationDelay: `${i * 60}ms` }}
167
+        >
168
+          <div className="h-3 w-24 rounded bg-bg-2" />
169
+          <div className="h-4 w-full rounded bg-bg-2" />
170
+          <div className="h-4 w-5/6 rounded bg-bg-2" />
171
+          <div className="h-4 w-2/3 rounded bg-bg-2" />
172
+        </div>
173
+      ))}
174
+    </div>
175
+  );
176
+}
177
+
147178
 function ModeToggle({
148179
   mode,
149180
   onToggle,
@@ -183,15 +214,27 @@ function ModeToggle({
183214
 function Shell({
184215
   subtitle,
185216
   right,
217
+  loading,
186218
   children,
187219
 }: {
188220
   subtitle: string;
189221
   right?: React.ReactNode;
222
+  loading: boolean;
190223
   children: React.ReactNode;
191224
 }) {
192225
   return (
193226
     <div className="flex h-full flex-col overflow-hidden">
194227
       <PaneHeader title="Viewer" subtitle={subtitle} right={right} />
228
+      {loading && (
229
+        // Indeterminate progress stripe pinned under the header.
230
+        // Tells the user "something is in flight" without the
231
+        // jarring "loading session…" centered text that used to
232
+        // replace the whole body on every click.
233
+        <div
234
+          aria-hidden
235
+          className="h-0.5 w-full shrink-0 bg-gradient-to-r from-transparent via-accent/60 to-transparent"
236
+        />
237
+      )}
195238
       <div className="min-h-0 flex-1 overflow-hidden">{children}</div>
196239
     </div>
197240
   );
src/lib/store/sessions.tsmodified
@@ -72,6 +72,32 @@ export interface InFlightTurn {
7272
  *  in memory. */
7373
 const STDERR_LINE_CAP = 50;
7474
 
75
+/** How many recently-viewed SessionDetail objects to keep in the
76
+ *  LRU cache. Each detail can be 1-2 MB for a long session, so
77
+ *  keep this small. 5 is enough to make "flip between A, B, C,
78
+ *  back to A" feel instant without blowing the heap. */
79
+const DETAIL_CACHE_CAP = 5;
80
+
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). */
85
+function touchDetailCache(
86
+  cache: Map<string, SessionDetail>,
87
+  id: string,
88
+  detail: SessionDetail,
89
+): Map<string, SessionDetail> {
90
+  const next = new Map(cache);
91
+  next.delete(id);
92
+  next.set(id, detail);
93
+  while (next.size > DETAIL_CACHE_CAP) {
94
+    const oldest = next.keys().next().value;
95
+    if (oldest === undefined) break;
96
+    next.delete(oldest);
97
+  }
98
+  return next;
99
+}
100
+
75101
 interface SessionStore {
76102
   /** All projects with their sessions embedded, sorted by the backend. */
77103
   projects: Project[];
@@ -84,6 +110,19 @@ interface SessionStore {
84110
   selectedSessionId: string | null;
85111
   /** Full body for the selected session. */
86112
   detail: SessionDetail | null;
113
+  /** Summary of the session the user just clicked, set instantly on
114
+   *  selectSession so the viewer header can show the real title
115
+   *  while the backend is still streaming the body. Cleared once
116
+   *  `detail` arrives. `null` when no load is in flight. */
117
+  pendingSummary: SessionSummary | null;
118
+  /** LRU cache of the last-viewed SessionDetail objects, keyed by
119
+   *  session id. Hitting a cached entry on click lets us render
120
+   *  the previous content immediately (no skeleton) while we
121
+   *  kick off a background refresh for fresh data. Capped at
122
+   *  `DETAIL_CACHE_CAP` to bound memory — each SessionDetail can
123
+   *  be 1-2 MB for a long session, so keeping more than a handful
124
+   *  is a big ask. */
125
+  detailCache: Map<string, SessionDetail>;
87126
 
88127
   /** In-flight chat turns keyed by turnId. Multiple entries are
89128
    *  allowed — the UI currently only surfaces one at a time, but
@@ -186,6 +225,8 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
186225
   expandedProjectIds: new Set<string>(),
187226
   selectedSessionId: null,
188227
   detail: null,
228
+  pendingSummary: null,
229
+  detailCache: new Map(),
189230
   inFlightTurns: new Map(),
190231
   viewerMode: new Map(),
191232
   ptyIds: new Map(),
@@ -250,6 +291,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
250291
     // ptyIds for the *previous* session here — background terminals
251292
     // must keep running across session switches. See the
252293
     // codex-parallel-threads goal in the v1.1 plan.
294
+    const cached = get().detailCache.get(session.id) ?? null;
253295
     set((s) => {
254296
       const nextMode = new Map(s.viewerMode);
255297
       if (!nextMode.has(session.id)) {
@@ -257,7 +299,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
257299
       }
258300
       return {
259301
         selectedSessionId: session.id,
260
-        detail: null,
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.
305
+        detail: cached,
306
+        pendingSummary: cached ? null : session,
261307
         viewerMode: nextMode,
262308
         loading: { ...s.loading, detail: true },
263309
         error: null,
@@ -277,11 +323,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
277323
       if (get().selectedSessionId !== targetId) return;
278324
       set((s) => ({
279325
         detail,
326
+        pendingSummary: null,
327
+        detailCache: touchDetailCache(s.detailCache, session.id, detail),
280328
         loading: { ...s.loading, detail: false },
281329
       }));
282330
     } catch (err) {
283331
       if (get().selectedSessionId !== targetId) return;
284332
       set((s) => ({
333
+        pendingSummary: null,
285334
         loading: { ...s.loading, detail: false },
286335
         error: formatError(err),
287336
       }));