Skip to content

claude-code-chat-browser: Session list slow on large installs — disk summary cache, display-name cache, and count alignment #109

Description

@clean6378-max-it

Chen (@clean6378-max-it)

Calendar Day

Friday, July 4, 2026 (PR 1 of 2)

Planned Effort

5 story points (Medium–High)

Companion PR: Mon PR 2 (FTS index + search UX + API errors) — independent files; PR 1 should land first.

Problem

Three related performance and correctness gaps remain after issue
#82 (in-memory LRU cache):


Gap 1 — Session list cold on every restart (cppa-cursor-browser #95 analog).

GET /api/projects/<name>/sessions calls get_cached_session() on every .jsonl file —
a full JSONL parse — to build the title, models, tokens, tool_calls, and timestamps
shown on the session list page. The in-memory LRU (200 entries, mtime-keyed) eliminates
re-parses within a server run, but is volatile: after any restart all entries are cold. On a
large install (300+ sessions, 20+ projects), the first request to each project page after
startup triggers N full parses. There is no disk-backed summary that survives restart.


Gap 2 — Per-request re-computation in GET /api/projects (cppa-cursor-browser #116 analog).

The landing page endpoint runs two expensive operations on every call with no caching:

  • _get_display_name() in utils/session_path.py → list_projects(): opens the first
    .jsonl file in each project directory to read the cwd field and derive a human-readable
    display name. On 20 projects that is 20 file opens per landing page load. The cwd field is
    set at session creation and never changes; the display name is deterministic for a given
    project directory and stable until a new .jsonl file arrives.

  • quick_session_info() loop in api/projects.py → get_projects(): peeks at every session
    file to compute titled_count and latest_ts for each project card. These calls happen on
    every page load even though the results are stable until a file's mtime changes.


Gap 3 — Count mismatch between project cards and session list (cppa-cursor-browser #95 analog).

GET /api/projects computes session_count via quick_session_info() peek without applying
exclusion rules. GET /api/projects/<name>/sessions applies exclusion rules through
is_session_excluded(), which scans all session["messages"] for content matching. When
exclusion rules are active, the count on the project card does not match the number of rows
returned on the session list page for the same project — the same count-alignment regression
that affected cppa before cppa-cursor-browser #95 / PR #113.


Root cause summary

Path Per-call cost Cached today
GET /api/projects_get_display_name() 1 file open per project Never
GET /api/projectsquick_session_info() 1 head+tail read per session Never
GET /api/projects/<name>/sessionsget_cached_session() Full JSONL parse per file In-memory LRU only (volatile)
Exclusion check Reads all session["messages"] Never (always re-computed)

is_session_excluded() requires full parse on first access, but exclusion rules are loaded once
at startup and never change within a run — the result is deterministic for a given
(path, mtime, rules_fingerprint) triple and safely cacheable on disk.

Goal

One merged PR that adds:

  1. A disk-backed session summary cache for session list rows, so session listing survives
    server restart without re-parsing unchanged files.
  2. A display-name cache per project directory, so list_projects() does not reopen .jsonl
    files on every call.
  3. Routes GET /api/projects through the same summary cache for per-session peek data, so the
    landing page does not re-read session files when mtimes have not changed.
  4. Aligned session counts between project cards and the session list page for the
    no-exclusion-rules case.

Scope

Touch points

utils/session_summary_cache.py (new) — disk SQLite summary cache

  • Stores session list rows keyed to (abs_path, mtime) for no-exclusion fast path, and to
    (abs_path, mtime, rules_fingerprint) for exclusion-aware entries.
  • Row payload: title, models, tokens, tool_calls, first_timestamp, last_timestamp,
    is_excluded (bool), is_untitled (bool derived from title).
  • Cache file: ~/.claude-code-chat-browser/session_summary_cache.sqlite (same directory as
    exclusion rules file).
  • threading.Lock for writer safety; read-only mode for concurrent readers.
  • Public API: get_summary(path, mtime, rules_fingerprint) -> SummaryRowDict | None,
    put_summary(path, mtime, rules_fingerprint, row), clear_cache().
  • Eviction: keep at most 2 000 rows; evict oldest-accessed on overflow.
  • Invalidation: mtime change is a cache miss; old entry overwritten on write.

utils/session_path.py → list_projects() / _get_display_name() — display-name cache

  • Add module-level _display_name_cache: dict[str, tuple[float, str]] — key: project_dir;
    value: (max_mtime_of_jsonl_files, display_name).
  • Before opening any .jsonl file, compute max_mtime = max(mtime of *.jsonl files).
  • If _display_name_cache.get(project_dir) returns a hit with the same max_mtime, return the
    cached display name without any file I/O.
  • On miss: open file as today, update cache entry. Reset entry when max_mtime changes.
  • threading.Lock for cache dict access.
  • This is the claude analog of cppa-cursor-browser #116: _get_display_name() re-reads the same stable field
    on every request; caching it per directory fingerprint eliminates per-request file opens.

api/projects.py → get_projects() — use summary cache for peek data

  • Replace the quick_session_info() loop with a pass that consults session_summary_cache for
    is_untitled and last_timestamp when a cache entry exists for (path, mtime).
  • On cache miss (first access or mtime changed): call quick_session_info() as today for
    title/timestamps; write a partial summary row (without token counts) to the disk cache so
    the next get_project_sessions() call can upgrade it to a full row.
  • session_count on project cards uses the same titled-session filter (skip is_untitled) as
    get_project_sessions(), fixing the count for the no-exclusion case.

api/projects.py → get_project_sessions() — use summary cache for session rows

  • Compute rules_fingerprint = stable hash of serialised rules list (consistent within a run).
  • For each session: check get_summary(path, mtime, rules_fingerprint).
  • Cache hit: return the cached row, skip get_cached_session().
  • Cache miss: full parse via get_cached_session(), apply exclusion, call put_summary(),
    proceed as today.
  • Response shape unchanged; no new fields.

tests/test_session_summary_cache.py (new)

  • Cache hit, cache miss, mtime invalidation, exclusion-key separation (different
    rules_fingerprint → separate rows), LRU eviction, clear_cache().

tests/test_api_integration.py — extend

  • Spy on utils.session_cache.get_cached_session: verify it is not called on a warm summary
    cache hit in GET /api/projects/<name>/sessions.
  • Verify project card session_count matches session list row count when no exclusion rules.

Out of scope

  • FTS search index for GET /api/search — tracked in PR 2
    (chen-july-week2-monday-search-fts-ux-and-errors-github-issue.md; cppa:
    PR #113).
  • ThreadPoolExecutor parallel session parsing — deferred until benchmarks justify it.
  • Changing exclusion rule semantics or load timing.
  • Caching full SessionDict (messages) to disk — only summary rows; in-memory LRU from
    claude-code-chat-browser #82
    remains the full-session cache for deep-link and export paths.

Follow-up (post-merge)

  • Wire summary cache into GET /api/search so search skips full re-parses for files already
    in the disk cache (title and timestamp pre-filter from summary).
  • Add tests/benchmarks/test_session_list_bench.py measuring cold-start session list latency
    with and without disk cache.

Acceptance Criteria

  • utils/session_summary_cache.py exists: SQLite-backed, (path, mtime, rules_fingerprint)
    composite key, get_summary / put_summary / clear_cache public API.
  • GET /api/projects/<name>/sessions does not call get_cached_session() for files whose
    (path, mtime, rules_fingerprint) entry is in the disk summary cache.
  • list_projects() does not reopen .jsonl files on repeat calls when the project
    directory's max file mtime has not changed (display-name cache hit).
  • GET /api/projects does not call quick_session_info() for session files already in the
    summary cache.
  • After a server restart and one warm-up request, a second request to
    GET /api/projects/<name>/sessions triggers zero parse_session calls for unchanged files
    (verified by spy in integration test).
  • Project card session_count equals GET /api/projects/<name>/sessions row count when
    no exclusion rules are active.
  • Cache entries invalidate on mtime change — an edited file is re-parsed on next request.
  • tests/test_session_summary_cache.py passes: hit, miss, invalidation, exclusion-key
    separation, eviction, clear_cache().
  • JSON response shapes for both endpoints are unchanged.
  • mypy --strict, full pytest, and ruff pass.
  • PR approved by at least 1 reviewer.

Verification

cd C:\Users\Jasen\CppAliance\claude-code-chat-browser
.\.venv\Scripts\Activate.ps1
pytest tests/test_session_summary_cache.py -q
pytest tests/test_api_integration.py tests/test_api_routes.py -q
pytest -q
mypy .
ruff check .

Manual smoke test:

  1. Start python app.py with 50+ sessions in one project.
  2. Open the project's session list page — note cold response time.
  3. Stop and restart the server.
  4. Open the same project page — response should be comparable to warm (disk cache hit, no
    re-parses in server log).
  5. touch one .jsonl file — that file re-parses on next request; all others remain cached.
  6. Reload the landing page repeatedly — server log shows no .jsonl file opens for projects
    whose directories have not changed.
  7. Confirm project card count matches session list row count (no exclusion rules active).

References

  • cppa-cursor-browser #95 /
    PR #113: workspace listing slow
    on large installs — summary_cache.py disk cache and count alignment.
    team-brain/2026-06/2026-06-23/brad/cppa-cursor-browser PR113 review 2026-06-23.md
  • cppa-cursor-browser #116 /
    PR #125:
    infer_invalid_workspace_aliases() re-computed per request — cache per storage fingerprint.
    Direct analog: _get_display_name() and quick_session_info() loop.
  • In-memory session cache:
    claude-code-chat-browser #82
    (this PR adds the disk tier on top).
  • Exclusion constraint: is_session_excluded()session_text_for_exclusion() iterates all
    session["messages"]; result is cacheable per (path, mtime, rules_fingerprint) because
    rules are stable within a process run.
  • Files: api/projects.py, utils/session_path.py, utils/session_cache.py,
    utils/session_peek.py, utils/exclusion_rules.py, models/project.py.
  • Companion PR: chen-july-week2-monday-search-fts-ux-and-errors-github-issue.md PR 2.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions