Skip to content

fix(claude-sessions): unify session listing across NBI surfaces, drop history.jsonl gate#310

Merged
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:fix/claude-session-consistency
May 19, 2026
Merged

fix(claude-sessions): unify session listing across NBI surfaces, drop history.jsonl gate#310
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:fix/claude-session-consistency

Conversation

@pjdoland
Copy link
Copy Markdown
Collaborator

Summary

A user reported that the Launcher tile's Claude Code session picker showed "No previous sessions found" while /resume inside the same Claude session listed previous sessions. Root cause: the backend used ~/.claude/history.jsonl as the gating source for session listing. Recent Claude Code releases don't reliably populate that file for SDK-driven invocations, and the cwd-only fallback path only scanned the Jupyter root's transcript dir, so any session launched from a different cwd disappeared from both NBI pickers while Claude Code's own /resume (which walks the project dirs directly) continued to find them.

Solution

Rebuild list_all_sessions on a direct walk of ~/.claude/projects/*/, which is the durable on-disk source /resume consumes. The cwd for each session comes from the transcript's own "cwd" field (Claude Code writes it on most message envelopes) so paths with literal dashes round-trip correctly; a dash-decode of the directory name is only used as a fallback for older transcripts that lack the field.

The handler's scope=cwd filter switches from comparing transcript-path dirnames to a realpath compare on session.cwd, fixing symlink-alias mismatches common on JupyterHub user dirs (NFS / EFS), and memoizes the per-session realpath inside the request so a 1k-session filter on NFS doesn't fan out to 1k network round trips.

A mtime-keyed cache memoizes per-transcript parse results so repeated list calls don't re-open every .jsonl on each click (addresses D207 from the audit).

The Launcher tile's row now renders the cwd basename as the inline label with the full path duplicated on both title and aria-label so screen-reader users get the disambiguating path too.

Testing

  • pytest tests/ --ignore=tests/test_claude_client.py -q 882 passed (6 new TestCrossSurfaceConsistency cases, 1 new symlink-alias handler case, 1 new empty-cwd silent-drop pin, autouse fixture clears _SESSION_INFO_CACHE between tests).
  • jlpm tsc --noEmit clean. jlpm lint:check clean. jlpm jest 183 passed (2 new tests/ts/launcher-picker.test.tsx cases).
  • Playwright end-to-end from a fresh notebook-dir=/tmp/nbitour: scope=all returned 107 cross-project sessions (every project on the machine), each row labeled with its project basename and the full path on title + aria-label. scope=cwd returned 0 (no transcripts for /tmp/nbitour), confirming the realpath filter works.
  • Six-agent review surfaced: realpath memoization in handler (applied), autouse cache fixture (applied), aria-label fallback on the span (applied), empty-cwd silent-drop test pin (applied), tightened class docstring (applied), corrected misleading code comment (applied).

Risks / follow-ups

  • The PR replaces the chat-sidebar Resume button's underlying data source but keeps it scoped to the current cwd. Cross-cwd resume from the sidebar (open a terminal + cd + claude --resume) is deferred to a follow-up; the Launcher tile remains the path for cross-project resumes.
  • A shell-out integration test that runs claude --resume non-interactively and diffs session ids against ours is the right cross-binary check but is deferred (it needs the CLI on the Galata runner's PATH).
  • mtime resolution on some filesystems (HFS+ 1s) could theoretically serve stale cache entries; the cache is a perf hint, not authoritative for correctness, and transcripts are append-only so collision odds are essentially zero in practice. Worth a note but not worth complicating the cache key.

pjdoland added 2 commits May 18, 2026 13:30
…gate

The Launcher tile's session picker silently disagreed with /resume:
inside Claude, /resume found sessions; in the launcher picker the same
user saw "No previous sessions found." Root cause was the backend's
reliance on ~/.claude/history.jsonl as the gating source. Recent Claude
Code versions don't reliably populate history for SDK-driven flows, and
the existing fallback only scanned the Jupyter cwd's transcript dir, so
sessions launched from a different cwd disappeared from both NBI
pickers while /resume continued to find them.

Rebuild list_all_sessions on a direct walk of ~/.claude/projects/*/
which is the durable, authoritative source. The cwd for each session
is taken from the transcript's own "cwd" field (Claude Code writes it
on most message envelopes) so paths with literal dashes round-trip
correctly. Falls back to a dash-decoded directory name only when the
transcript carries no cwd at all (older flows + synthetic fixtures).

A small mtime-keyed cache memoizes per-transcript parses so repeated
list calls don't re-open every .jsonl on each click (D207).

Handler scope=cwd switches from comparing transcript-path dirnames to a
realpath comparison on the session's cwd field. This fixes symlink-
alias mismatches common on JupyterHub user dirs (NFS, EFS) where the
mounted path differs from the path Claude Code originally recorded.

Launcher picker rows now render the cwd basename as the inline label
with the full path on hover, so a user with several similarly-named
projects can disambiguate without losing the path entirely.

Tests:
- Existing 63 session tests adapted to the new transcript-first
  preview contract; one ("history display wins") rewritten to assert
  the inverse ("transcript wins over stale history display").
- Six new TestCrossSurfaceConsistency cases pin the structural
  invariants: launcher returns every on-disk session; launcher works
  with no history.jsonl; cwd-scope is a subset of all-scope; every
  session carries a usable cwd; dash-decode fallback for older
  transcripts; mtime cache skips reparse on unchanged files.
- Handler test adds a symlink-alias case for scope=cwd.
- New launcher-picker.test.tsx pins the basename label rendering and
  the empty state.
Six-reviewer pass surfaced several convergent improvements:

- Memoize realpath() per cwd inside the scope=cwd filter at
  extension.py so a 1k-session request on NFS doesn't fan out into 1k
  network round trips. The target cwd is already resolved once at
  comprehension entry; this caches each unique session.cwd too
  (performance).

- Add an autouse fixture that clears the module-level
  _SESSION_INFO_CACHE between tests. The cache key includes the
  tmp_path-anchored absolute path, so cross-test collisions were
  already unlikely, but explicit clearing makes ordering and re-run
  behavior deterministic (test architecture).

- Add a handler test pinning the documented behavior that scope=cwd
  drops sessions with empty cwd (their cwd is unmatched, so they
  cannot belong to the current workspace; scope=all still surfaces
  them). Documents the silent-drop so a future refactor doesn't
  reintroduce them by accident (test architecture).

- Sharpen the TestCrossSurfaceConsistency docstring to say what these
  tests actually pin (NBI's own surfaces agree on the on-disk
  transcript set) versus what they don't (claude --resume is not
  shelled out; a Galata or shell-out integration test is the right
  cross-binary check) (test architecture).

- Fix the misleading comment in _read_session_info about how the
  per-line loop terminates (waits for both preview AND cwd, capped by
  _MAX_LINES_SCANNED) (code reuse).

- Add aria-label fallback on the launcher-picker project span: screen
  readers do not reliably expose title on <span>, so duplicate the
  full path into aria-label whenever it differs from the visible
  basename. Also guard title against undefined cwd (frontend / UX).
@pjdoland pjdoland added the bug Something isn't working label May 18, 2026
@pjdoland pjdoland added this to the 5.0.x milestone May 19, 2026
@mbektas mbektas merged commit d0cf946 into plmbr:main May 19, 2026
4 of 5 checks passed
pjdoland added a commit to pjdoland/notebook-intelligence that referenced this pull request May 22, 2026
Promotes the [Unreleased] CHANGELOG snapshot to [5.0.0] - 2026-05-22
and expands it to cover everything merged into upstream/main after
PR plmbr#287's docs refresh. Bumps package.json to 5.0.0.

CHANGELOG additions cover the post-plmbr#287 surface:

- Settings tabs: plugin marketplace picker (plmbr#284), plugin marketplace
  details + Update button (plmbr#303), per-workspace MCP disable (plmbr#286),
  JSON-paste path in Add MCP server (plmbr#285).
- Launchers: hide-with-policy (plmbr#288), brand icons for Codex / opencode
  (plmbr#325, plmbr#333), per-launch directory picker (plmbr#332).
- Chat sidebar and agentic UX: workspace @-mention in Claude mode
  (plmbr#327), reload-open-files-on-disk (plmbr#330), steered system prompt
  away from over-eager notebook creation (plmbr#336).
- Skills: multi-manifest support (plmbr#321), tracks-upstream for user-
  imported skills (plmbr#322), HTTP kill switch for the reconciler (plmbr#291).
- Accessibility: full sub-section covering plmbr#305-plmbr#320.
- Security: shell-tool sandbox (plmbr#290), Claude UI-bridge sandbox (plmbr#323),
  0o600 on encrypted token (plmbr#293), env-secret scrubbing (plmbr#295), MCP
  config shape validation (plmbr#299), XSS allowlist (plmbr#296), Copilot WS
  auth + origin (plmbr#301), GHE host detection (plmbr#292), fastmcp -> mcp SDK
  swap (plmbr#324).
- Fixed: session listing unification (plmbr#310), session preview unwrap
  (plmbr#331), down-area runtime throw (plmbr#330 follow-up), WS message-handler
  leak (plmbr#294).
- Removed: fastmcp dependency, history.jsonl session gate.

Adds a Migration note covering the five behavior changes operators
should review before upgrading from 4.x: fastmcp swap, path
sandboxes, history.jsonl gate removal, workspace @-mention pointer
shape, and the Copilot WebSocket auth/origin tightening.

Two reviewer rounds (six personas each) applied:
- Round 1 caught security overclaims (plmbr#293, plmbr#299, plmbr#323), the
  plmbr#284/plmbr#303 mis-attribution, missing migration note, 3 em dashes,
  and the stale `fastmcp==2.x.*` recommendation in the admin guide.
- Round 2 caught the missing plmbr#301 migration bullet, missing version-
  matrix 5.0.x row, missing README TOC entry, and a couple of style
  nits (sub-heading overpromise, orphan bullet).

Skipped (deferred to future PRs):
- README first-run tour mention.
- Admin guide HTTP kill-switch row in Failure-modes table.
- Terminal drag-drop trust-model precision update after plmbr#327.
- Cipher description nit in plmbr#293 (Fernet AES-128-CBC+HMAC, not
  AES-GCM).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants