Skip to content

feat(core,cli): two-phase session listing for instant /resume first frame - #3988

Closed
qqqys wants to merge 12 commits into
QwenLM:mainfrom
qqqys:feat/session-list-progressive
Closed

feat(core,cli): two-phase session listing for instant /resume first frame#3988
qqqys wants to merge 12 commits into
QwenLM:mainfrom
qqqys:feat/session-list-progressive

Conversation

@qqqys

@qqqys qqqys commented May 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Splits SessionService.listSessions into a stat-only listSessionsLite and an enrichSessions step, then wires the picker to render the first frame from lite items immediately while enrichment hydrates each row in the background. The legacy listSessions(opts) is preserved as a thin wrapper around both, so SDK / WebUI / VSCode-companion callers don't break.

Stacked on #3897. This PR depends on the perf foundation introduced there (lazy countSessionMessages, head/tail buffer pool, project-scope counting). Diff is presented against main; if #3897 lands first the merge is clean.

Why

After #3897, the per-page cost of listSessions for 20 rows is ~30 ms median on local NVMe — fine on modern hardware, but it scales linearly with page size and dominates time-to-first-visible-row on slow disks, NFS, and large-N projects.

This PR decouples first-paint latency from N entirely:

Path Median (313 sessions, 127 MB on disk)
listSessionsLite size=20 ~1.0 ms
enrichSessions 20 rows ~24 ms
Legacy listSessions size=20 ~46 ms

First frame goes from ~30 ms → ~1 ms. Enrichment cost is unchanged but happens in parallel with the user's eyes registering the picker frame.

Visual proof (tmux capture, with 800 ms enrich delay injected for demo)

T+86ms — lite phase, picker frame already painted, every row a placeholder:

│   …8472daa7                       │
│   1 hour ago                      │
│                                   │
│   …0fd3744b                       │
│   2 hours ago                     │
│   ...                             │

T+906ms — enrichment lands, rows swap to real metadata:

│   add support for streaming responses             │
│   1 hour ago · fix/auth-race                      │
│                                                   │
│   mock: refactor-session-storage                  │
│   2 hours ago · chore/upgrade-deps                │
│   ...                                             │

The injected delay was reverted before commit; on local NVMe the swap is imperceptible.

Type changes

SessionListItem's shape is now lite-aware:

  • Always present: sessionId, mtime, filePath, plus the new fileSize? (cheap stat field)
  • New: pending?: booleantrue while still in lite form
  • All head/tail-derived fields (cwd, startTime, prompt, gitBranch, customTitle, titleSource) are now optional

messageCount was already optional from #3897. Existing consumers that handle the optional case (e.g. vscode-ide-companion's ?? messages.length, the picker's typeof === 'number' check) keep working unchanged. One ACP integration callsite needed a cwd ?? cwd fallback.

Picker changes

  • useSessionPicker: initial-load + load-more both call lite first, then enrich in the background. AbortController on unmount cancels in-flight enrichment.
  • SessionPicker.tsx: pending rows render …<sessionId-prefix> in the dim color slot; metadata row hides the time/branch suffix when fields are absent.

Compatibility

  • listSessions(opts) returns the same shape it always did — same project-filter semantics, same pagination cursor (with the wrapper looping when sibling-project files drop out during enrichment, matching pre-split behaviour).
  • ACP listing path defensively falls back to the request's cwd if a head record decode failed mid-page.

Test plan

  • tsc --noEmit clean for both packages/core and packages/cli
  • vitest run for sessionService.test.ts (47 cases, +2 new), sessionStorageUtils.test.ts (44 cases), sessionService.rename.test.ts (17 cases) — 108/108 pass
  • vitest run for StandaloneSessionPicker.test.tsx (38 cases, +1 new), sessionPickerUtils.test.ts, useResumeCommand.test.ts — 60/60 pass
  • Regression pin: listSessionsLite does not call jsonl.readLines — guards against silently re-introducing head IO into the fast path
  • enrichSessions applies the project-hash filter — sibling-project files in a shared chats dir get dropped during enrichment, matching pre-split semantics
  • Picker first frame paints from lite items before enrichment lands — held-open enrich Promise lets the test inspect the placeholder frame, then verify swap-in
  • Live tmux walk through /resume against 313-session / 127 MB chats dir confirms the lite frame appears at T+~86 ms and rows swap as enrichment completes

🤖 Generated with Claude Code

qqqys and others added 12 commits May 7, 2026 15:28
`listSessions` previously called `countSessionMessages` per file, which
streamed the entire JSONL through `readline` to count unique
user/assistant UUIDs. For a project with N sessions averaging M bytes
each, every /resume open paid O(N · M) wall time before showing the
picker — by far the dominant cost once a project accumulated many
multi-MB sessions.

This change:

- Drops the per-file count from listSessions / findSessionsByTitle.
  `SessionListItem.messageCount` is now optional. Callers that need
  a count call the new public `SessionService.countSessionMessages
  (sessionId)` lazily — typically only when a SessionPreview panel is
  about to display the badge.
- Pools a single 64KB tail-read buffer across the per-file metadata
  reads in listSessions / findSessionsByTitle, mirroring the pattern
  in claude-code's `enrichLogs`. The two helpers in
  `sessionStorageUtils` (`readLastJsonStringFieldSync` and
  `readLastJsonStringFieldsSync`) accept an optional caller-owned
  scratch buffer; one-off callers (rename, single-session lookup) pass
  nothing and keep the original alloc behaviour.
- Updates SessionPicker's row metadata to omit the "N messages"
  segment when `messageCount` isn't available, keeping the visual
  layout intact.

Tests:
- Pin that listSessions does not populate messageCount (regression
  guard against silently re-introducing the per-file scan).
- Smoke test the buffer-pool plumbing — same caller-owned buffer
  handed to two reads of different file sizes returns both correct
  values without state bleed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Tightens the title-write / title-read invariant so the picker's
metadata read is bounded to a fixed 2 × 64KB per file, regardless
of session length, with no fallback that scales with file size.

Writer (`ChatRecordingService`):
  - New `bytesSinceTitleAnchor` counter and `TITLE_REANCHOR_BYTES`
    threshold (32 KB, half of LITE_READ_BUF_SIZE).
  - `appendRecord` now updates the counter and, once it crosses the
    threshold while a title is set, re-appends a fresh `custom_title`
    record to EOF (`reanchorTitle`). The recursive append routes
    back through the same tracking path, which sees the title
    record and resets the counter to zero.
  - Sessions that never set a title pay zero overhead — the early
    return in `updateTitleAnchorTracking` short-circuits.

Reader (`sessionStorageUtils`):
  - `readLastJsonStringFieldSync` / `readLastJsonStringFieldsSync`
    now read the file's last 64 KB, fall back to the first 64 KB
    on miss, and return `undefined` if neither contains the field.
    The previous Phase-2 streaming full-file scan (capped at 64 MB)
    is removed entirely.
  - The pooled scratch buffer (already optional) now backs both the
    tail and head reads — only one allocation per `listSessions`
    page even with the head fallback firing.
  - `MAX_FULL_SCAN_BYTES` constant deleted (unused).

The two changes are coupled: the reader's tighter bound only works
because the writer guarantees the title stays in tail. A title
buried mid-file is now intentionally `undefined` (the picker
falls back to `firstPrompt` for display) rather than triggering a
full-file scan that would freeze the UI on long agent transcripts.

Tests:
  - `chatRecordingService.customTitle.test.ts` — three scenarios
    pinning the re-anchor invariant: threshold trigger, no-spurious
    on no-title sessions, no-trigger on small write bursts.
  - `sessionStorageUtils.test.ts` — replaces Phase-2 tests with
    head-window fallback tests; pins the new "buried beyond both
    windows returns undefined" contract; updates buffer-pool reuse
    test to cover both tail and head reads with the same scratch.

E2E coverage (35/35 scenarios on a separate harness against real
fs): R1–R10 reader contract, M1–M4 multi-field, W1–W7 writer
re-anchor, L1–L6 listing latency + regression pins, S1 stress
(200 × 3 MB), C1 concurrency, I1–I5 writer/reader integration.
Measured 2.6× speedup on listSessions(50) over 50 × 4 MB
sessions vs the legacy `countSessionMessages` baseline; speedup
scales linearly with average file size.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- countSessionMessages: valid id counts unique user/assistant uuids and
  ignores other types/malformed lines; invalid id short-circuits without
  filesystem access; ENOENT degrades to 0 instead of bubbling.
- readLastJsonStringFieldsSync: mirror the single-field variant's
  scratch-buffer reuse test across a tail-hit then head-fallback to
  catch any decode that ignores bytesRead.
- ChatRecordingService re-anchor: legacy resumed session (source
  undefined) must omit titleSource on threshold-triggered re-anchor,
  not silently reclassify as 'manual'.

Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
`String.length` undercounts the on-disk size of multi-byte payloads
(CJK, emoji are 1 UTF-16 unit but 3 UTF-8 bytes), so a session full
of CJK content could push >96KB of writes past the last anchor before
the 32KB threshold thinks it has — silently drifting the title past
the 64KB tail window the picker scans. Switch the byte counter to
`Buffer.byteLength(..., 'utf8')` for parity with `jsonl.writeLine`.

Also reset `bytesSinceTitleAnchor` to 0 in the reanchor catch:
without it, a failing reanchor pins the counter at the threshold
and turns a single transient I/O fault into a per-record retry
storm. One missed anchor is the right tradeoff — finalize() will
re-emit on the next lifecycle event.

Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
The 64KB head-window fallback in `readLastJsonStringFieldSync` /
`readLastJsonStringFieldsSync` reads a fixed slice and hands it
straight to the extractor — its trailing bytes can fall mid-record.
A partial line whose `customTitle` value happens to close inside
the buffer but whose body extends past 64KB would otherwise win
the latest-match race and surface as the picker's title.

Drop everything past the last `\n` before extracting (only when the
buffer is shorter than the file — a small file is necessarily whole-
line). Honors the original Phase-2 contract that only complete lines
get a vote, without paying for the deleted full-file scan.

Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
…ession-list-perf

# Conflicts:
#	packages/core/src/services/sessionService.ts
…, pin perf contract

Address QwenLM#3897 follow-up review findings:

- SessionPreview footer no longer drops the message count when listSessions
  omits it. The prop is the override path; default falls back to a unique
  user/assistant uuid count derived from the already-loaded conversation,
  matching countSessionMessages semantics with zero extra disk I/O.
- countSessionMessages now scopes to the current project, mirroring the
  first-record cwd check in deleteSession/renameSession/loadSession. A
  valid sessionId from another project sharing the chats dir no longer
  bypasses project boundaries on lazy count.
- New regression tests:
  - SessionPicker row renders cleanly with messageCount === undefined
    (no "messages"/"undefined"/dangling separator)
  - findSessionsByTitle perf contract: matches have messageCount
    undefined and fs.createReadStream is never called
  - countSessionMessages: cross-project sessions return 0 without
    streaming, empty file returns 0
- Update corruption-recovery tests to call private countSessionMessagesFromPath
  (the streaming entry point) instead of the public sessionId-shaped API the
  merge from main pointed them at.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>
The session metadata reader uses two bounded 64KB windows (tail + head)
since the perf rework on this branch — never a full-file scan. Two
comments still described the prior behavior. Reported in MR review.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
ACP renameSession constructed a fresh SessionService and wrote the
custom_title record straight to disk. When the same session was live
in this process, ChatRecordingService.currentCustomTitle stayed at
the old value, so the next title re-anchor (every 32KB) or finalize()
re-emitted the stale title at EOF and silently reverted the rename.

Now we look up the live ChatRecordingService first and route through
recordCustomTitle, which keeps the in-memory cache and the on-disk
record in sync. The SessionService path remains for the non-live case
(e.g., another client renaming a backgrounded session). Reported in
MR review.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…rame

`SessionService.listSessions` is split into a stat-only `listSessionsLite`
(readdir + stat, no JSONL IO) and an `enrichSessions` step that does the
head + tail reads and applies the project-hash filter. The picker hook
now drives both: the lite phase paints the picker frame with `pending:
true` placeholder rows immediately, then enrichment hydrates each row
with its full prompt / branch / customTitle.

Why this matters:
- The legacy single-call path took ~30 ms median for a page of 20 on a
  warm cache (reading head/tail per file). Within tail-buffer pooling
  and lazy message counts, that's already fine on modern NVMe — but
  on slow disks, NFS, or a project with thousands of sessions it
  scales linearly with page size and dominates the time-to-first-
  visible-row.
- Lite-only first frame measures ~1 ms in the same conditions.
  Enrichment runs in parallel with the user's eyes registering the
  picker frame, so even if the IO budget is identical, perceived
  latency stops being a function of N.

`SessionListItem` is now `pending`-aware: every field except
`sessionId / mtime / filePath` (and the new `fileSize`) is optional,
because lite items omit them. Callers that already coped with the
optional `messageCount` keep working; one ACP integration callsite
needed a `?? cwd` fallback.

The legacy `listSessions(opts)` is a thin wrapper around
`listSessionsLite + enrichSessions` — same public shape, same project-
filter semantics — so SDK / WebUI / VSCode-companion callers don't
break.

Tests:
- listSessionsLite returns stat-only items (regression pin: no
  jsonl.readLines call during the lite phase).
- enrichSessions applies the project-hash filter and drops siblings
  living in a shared chats dir.
- Picker renders the first frame from lite items before enrichment
  resolves: a held-open enrich Promise lets the test inspect the
  intermediate placeholder frame, then verify the swap-in.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qqqys qqqys closed this May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant