@@ -78,22 +78,30 @@ const STDERR_LINE_CAP = 50; |
| 78 | 78 | * back to A" feel instant without blowing the heap. */ |
| 79 | 79 | const DETAIL_CACHE_CAP = 5; |
| 80 | 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). */ |
| 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. */ |
| 85 | 86 | function touchDetailCache( |
| 86 | 87 | cache: Map<string, SessionDetail>, |
| 87 | 88 | id: string, |
| 88 | 89 | detail: SessionDetail, |
| 90 | + pinnedId: string | null, |
| 89 | 91 | ): Map<string, SessionDetail> { |
| 90 | 92 | const next = new Map(cache); |
| 91 | 93 | next.delete(id); |
| 92 | 94 | next.set(id, detail); |
| 93 | 95 | 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); |
| 97 | 105 | } |
| 98 | 106 | return next; |
| 99 | 107 | } |
@@ -157,8 +165,15 @@ interface SessionStore { |
| 157 | 165 | |
| 158 | 166 | /** Called when the user clicks a session row. Takes the full |
| 159 | 167 | * 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. */ |
| 161 | 172 | 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>; |
| 162 | 177 | |
| 163 | 178 | rescan: () => Promise<void>; |
| 164 | 179 | subscribeToChanges: () => Promise<void>; |
@@ -283,15 +298,20 @@ export const useSessionStore = create<SessionStore>((set, get) => ({ |
| 283 | 298 | |
| 284 | 299 | async selectSession(session) { |
| 285 | 300 | 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). |
| 294 | 304 | 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 | + |
| 295 | 315 | set((s) => { |
| 296 | 316 | const nextMode = new Map(s.viewerMode); |
| 297 | 317 | if (!nextMode.has(session.id)) { |
@@ -299,20 +319,19 @@ export const useSessionStore = create<SessionStore>((set, get) => ({ |
| 299 | 319 | } |
| 300 | 320 | return { |
| 301 | 321 | 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. |
| 305 | 322 | detail: cached, |
| 306 | | - pendingSummary: cached ? null : session, |
| 323 | + pendingSummary: cached || !needsDetail ? null : session, |
| 307 | 324 | viewerMode: nextMode, |
| 308 | | - loading: { ...s.loading, detail: true }, |
| 325 | + loading: { ...s.loading, detail: needsDetail && !cached }, |
| 309 | 326 | error: null, |
| 310 | 327 | }; |
| 311 | 328 | }); |
| 329 | + |
| 330 | + if (!needsDetail) return; |
| 331 | + |
| 312 | 332 | // Race guard: if the user clicks session B while session A's |
| 313 | 333 | // 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. |
| 316 | 335 | const targetId = session.id; |
| 317 | 336 | try { |
| 318 | 337 | const detail = await readSession( |
@@ -324,7 +343,12 @@ export const useSessionStore = create<SessionStore>((set, get) => ({ |
| 324 | 343 | set((s) => ({ |
| 325 | 344 | detail, |
| 326 | 345 | 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 | + ), |
| 328 | 352 | loading: { ...s.loading, detail: false }, |
| 329 | 353 | })); |
| 330 | 354 | } catch (err) { |
@@ -337,6 +361,60 @@ export const useSessionStore = create<SessionStore>((set, get) => ({ |
| 337 | 361 | } |
| 338 | 362 | }, |
| 339 | 363 | |
| 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 | + |
| 340 | 418 | async rescan() { |
| 341 | 419 | set((s) => ({ loading: { ...s.loading, projects: true }, error: null })); |
| 342 | 420 | try { |