diff --git a/CHANGELOG.md b/CHANGELOG.md index 1371ed6..777bbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.0.83 + +- Feat: session finding Batch 1 — "search & noise" (plan: `docs/session-finding-plan.md`) + - **Search now covers ALL sessions × ALL user prompts** (fixes #131 — previously only the ~100 loaded sessions' visible fields were searchable, leaving ~3/4 of history unfindable): a debounced main-process search scans every prompt of every session (multi-account) and appends matches beyond the loaded list, with lazy enrichment + - **Matched-prompt snippet line** (`⌕ #N …context…`) shows *why* a row matched when the hit is in a middle prompt that isn't visible in the row + - **High-contrast search highlight**: unified amber background + dark text (the old per-field translucent styles were near-invisible on colored text) + - **Minor-session folding**: closed, untitled, PR-less sessions with ≤2 messages collapse into an expandable "N minor sessions" row (search always shows everything; manual hide comes with the pins PR) + - Prompt text stays in the main process — only small snippets cross IPC; search is in-memory (ms-scale) and does not touch the list-refresh path +- Fix (pre-existing): custom titles / branch names no longer vanish at random + - Per-session enrichment greps now run in bounded batches of 10 (was ~400 concurrent process spawns, which pushed the biggest transcripts past the silent 3s exec timeout; timeout also raised to 5s) + - Branch is read from the last 50 transcript lines instead of 5 — an active session's tail is often tool output with no `gitBranch` field (measured: tail -5 hit 0, tail -20 hit 12) + - Enrichment cache is now mtime-keyed per transcript (#134): unchanged files are never re-grepped (a ~ms `stat` pass replaces the 5s wall-clock TTL, whose entry-time stamp made any >5s scan stale on arrival — perpetual rescans); concurrent callers are serialized onto one accumulated cache; scan batches went 10 → 25 for a faster cold start + - Enrichment reads are now shell-free (`execFile` + in-process tail read): no interpolated paths near a shell, and a timed-out/failed read no longer marks the transcript as scanned — it retries on the next pass +- Minor-session fold UX: sticky boundary header while scrolled inside the group, plus an always-visible fold bar under the list while expanded; folding resets on each popup show; all fold controls are keyboard-accessible (Enter/Space) + ## 1.0.82 - Feat: multi-account Batch 3 — cross-account sharing (share engine + CLI + UI) diff --git a/README.md b/README.md index d54f26a..9b74c15 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Press `⌃+⌘+R` or click the menu bar icon to launch the Quick Switcher. Searc CodeV can list, search, and resume Claude Code sessions. Press `⌃+⌘+R` to open the Quick Switcher, then `Tab` to toggle to Sessions mode. Live status dots show session state: working (orange pulse), idle (green), needs attention (orange blink). +Search covers **every session and every user prompt you ever typed** (not just the ~100 most recent sessions shown in the list) plus titles, branches, PR links, and last AI replies. When a match sits in the middle of a conversation, the row shows a `⌕ #N …` snippet with the surrounding context. Closed one-shot sessions (≤2 messages, untitled, no PR) fold into an expandable "minor sessions" row to keep the list scannable. + **Simple rule**: when running multiple sessions in the same project directory at the same time, give each running session a name. Closed sessions don't need names — they won't cause issues. - **Best**: start with a name — `claude -n "my task"` (or `claude --name "my task"`) diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md new file mode 100644 index 0000000..252ccd8 --- /dev/null +++ b/docs/session-finding-plan.md @@ -0,0 +1,256 @@ +# Session-Finding Improvement Plan (search / browse / pins / preview) + +> **Status: decided; Batch 1 in flight** (finalized 2026-07-12 via a brainstorm session). +> PR-1 "search & noise" (§4.1–§4.3: B2 highlight + A1/B1 full search + C1 folding) = **PR #132**; +> PR-2 "pins" (§4.4 D1, incl. C1's manual hide) not started; Batch 2/3 not started. +> This document is the cross-session / cross-model implementation reference: every "decision" +> below was confirmed point-by-point with the user — do not re-open decided options; +> implementation details (§4–§6) may adapt to what you find. +> Related: `docs/pin-feature-handoff.md` (pin groundwork; its §4 [FACT]s remain valid), +> issue #106 (session-list perf), #66 (detail-view idea), #105 (real-time preview), #131 (search cap). +> A Traditional Chinese working copy may exist locally as `session-finding-plan-zh-tw.md` (untracked). + +## 1. Problem definition + +The user's pain points when trying to find a session (paraphrased): + +1. Search only covered 1st user message / final AI message / final user message / branch / + custom title / AI title / PR link — no full-text search, so hit rate was low. The user + compensates by hand-titling every non-throwaway session. +2. When a match sits in a UI-truncated region, the highlight is invisible (a row matches but + you can't see *why*). +3. Highlight colors had too little contrast (translucent gray over orange/green text ≈ invisible). +4. Compared with Notion: **(a) verifying a candidate is nearly free there** (click → content + appears in the right pane → wrong? next), **(b) less noise**. In CodeV, verifying a candidate + = open terminal → resume → wait; and the list is polluted by throwaway one-shot sessions. + +Framing — finding a session has three complementary paths plus one multiplier: + +- **A. You remember a keyword** → search coverage (data layer) +- **B. Search results are trustworthy & readable** → match presentation +- **C. You recognize it by sight** → browsing signal/noise +- **D. The important ones are always at hand** → pins (no search needed) +- **Multiplier: fast verify (preview)** — raises the value of A–D across the board + +## 2. Measured data (2026-07-12, the user's machine) + +| Item | Value | Implication | +|---|---|---| +| Transcripts | personal 868MB / 549 files + work 26MB / 39 files ≈ **894MB / 588 files** | SQLite FTS5 (or even on-demand `rg`) is plenty — no Rust / external engine needed | +| `history.jsonl` | 15,324 prompt lines, **414 unique sessions**, 6.7MB (work account: 142 lines / 50KB) | Each line = one complete user prompt (`display` field, longest measured 9,224 chars, **not truncated**) | +| Transcript retention | oldest 2026-04-09 (~3 months) | Claude Code's `cleanupPeriodDays` prunes old transcripts; **once the FTS index exists it doubles as a permanent text archive** (expired sessions stay readable, not resumable) | +| Actual search scope (pre-PR-#132) | **Only the ~100 loaded sessions**: search was the renderer's `filterSessionsLocally` client-side filter; main-side `searchClaudeSessions` (500-pool) was wired but never called — dead code (issue #131) | With 414 sessions, **~314 were completely unfindable** → A1 went from nice-to-have to mandatory; the cache already holds the full set, so widening costs nothing | + +Open-source survey conclusion: **nobody uses exotic tech**. claude-code-history-viewer +(Tauri+Rust) does client-side full scans in a Web Worker — its real value is the conversation +*reader*; c9watch also brute-scans JSONL; raine/claude-history (Rust TUI) has field-aware +lexical + local-embedding hybrid search, more than we need. TencentDB-Agent-Memory is fully +local (SQLite + sqlite-vec) but solves *agent long-term memory*, not *humans finding sessions* — skipped. + +## 3. Decided (confirmed point-by-point with the user — do not re-open) + +| # | Decision | Rationale | +|---|---|---| +| 1 | **C2 compact/title-only mode: dropped** | The user already titles non-throwaway sessions; search targets are mostly those | +| 2 | **A2' transitional rg version: dropped — build the full A2 FTS5 directly** | User's call: if we build it, build the real one | +| 3 | **A2+ file-path reverse lookup: demoted to a small bonus**, only if trivial while doing A2 | | +| 4 | **A3 semantic/vector, E ask-AI, Tencent memory: parked** | Re-evaluate remaining pain after FTS + preview ship | +| 5 | **C4 preview promoted** (user asked: with C4, is B1 still needed? → see §5.1; B1 as a standalone item is dissolved into A1 and A2) | | +| 6 | **B2 highlight colors: do immediately** (pure CSS) | | +| 7 | **C1 junk folding: do it**, using "no custom title + very few msgs" as the signal | Matches Notion's low-noise advantage | +| 8 | **D1 pin UI: do it** (spec in §4.4); **D3 in-session `/pin`: Batch 3**, as a custom slash command, accepting one LLM turn | User dislikes the `!` path's lack of autocompletion; slash commands get Claude Code's built-in autocomplete, and the `!` path's "no LLM turn" selling point was empirically disproven (§7) | +| 9 | AI batch summaries/grouping: no; instead **A4-lite**: a "Generate title" button in the preview (haiku, writes a custom title — fits the user's manual-titling habit) | User is skeptical of AI-title quality; heuristics first | + +**Rejected-options record (kept for reference, with re-open conditions):** +- **C2 compact/title-only mode**: idea = Notion cmd+P-style one-line title rows for titled + sessions, expanded rows only for untitled — denser visual scanning. Dropped because search + targets are mostly titled sessions, and C1 (de-noise) + C4 (fast verify) cover the scanning + need. Re-open if browsing still feels hard after Batches 1/2. +- **A2' rg deep-search stopgap**: idea = press Enter → shell out to ripgrep over the 894MB of + transcripts (2–3 days to ship, zero index maintenance) as demand validation before FTS. + Dropped: the user chose to go straight to full FTS5. Residual value: one-shot `rg` scans + remain a good **index-debugging tool** (checking the incremental FTS index for gaps) during + A2 development — not a product feature. +- **A3 semantic/vector, E ask-AI, Tencent memory**: parked, not killed — re-open if + "I remember the concept but not the keyword" stays a common failure mode after FTS + preview. + +## 4. Batch 1 — quick wins (each S effort) + +### 4.1 B2: highlight contrast fix (hours) + +Was: translucent gray boxes per call site. Now: one shared amber background + dark text style +(`SEARCH_HIGHLIGHT_STYLE`), readable over orange (1st msg) / green (title) / white text. +Pure CSS in `switcher-ui.tsx`. + +### 4.2 A1+B1 merged: all-sessions × all-user-prompts search + match snippet (fixes issue #131) + +- **Pre-PR state (issue #131)**: search = renderer `filterSessionsLocally` + (`switcher-ui.tsx`) filtering only the ~100 loaded sessions; main-side + `searchClaudeSessions` (`claude-session-utility.ts`, 500-pool, 50-result cap) was fully + wired (IPC handler + preload) but never called — **dead code**. ⇒ ~314/414 sessions unfindable. +- **Why merge with the snippet work**: full-prompt matches mostly land in *middle* prompts — + which the UI never shows → without a snippet the match is invisible. They are one feature. +- Data: `history.jsonl` has one line per prompt; the accumulator used to keep only first/last + and discard the middle. Change: while scanning, also build a **main-process module-level** + `Map` of all prompts (~+7MB RAM). +- **Design: dual-path union** (because enrichment fields exist only renderer-side, and only + for the loaded ~100): + 1. Main-side search IPC (rewrites the dead `searchClaudeSessions`): searches **all** + sessions' project name + **all prompts**; returns matched sessions + `matchedSnippet` + (~40 chars around the match) + field/index attribution. + 2. Renderer keeps `filterSessionsLocally` (covers branch / PR / AI response / custom title + / terminal badge). + 3. Union both; main-side hits outside the loaded 100 are **appended to the list + lazily + enriched**. +- **Important: never ship all prompts inside the IPC-returned session objects** (7MB per call + would wreck IPC). +- Renderer: when the match isn't in a visible field, one row line switches to the snippet. +- Perf: scanning 6.7MB of in-memory strings ≈ 5–20ms/query, imperceptible behind a debounce; + **does not touch the list-refresh path** (that cost is issue #106's IPC/process-scan/ + enrichment — orthogonal). + +### 4.3 C1: junk-session folding + +- Criteria (ALL must hold to fold): `messageCount ≤ 2` **and** no custom title **and** no PR + link **and** not active. +- UI: collapse into a gray "· N minor sessions" row (expandable); plus a right-click + "Hide session" for manual hiding (stored in §4.4's file, `hidden` list — ships with PR-2). +- Conservative by design: over-showing (expand reveals everything) beats over-hiding. + +### 4.4 D1: Pin ★ + Pinned section + +Groundwork in `docs/pin-feature-handoff.md` (its §5 [REC] is the base; deviations below are +multi-account-era updates). + +**UI spec (proposed to the user):** +- Hovering a session row reveals a 📌 button on the right; click toggles; pinned rows show a + persistent small ★. +- Top of the Sessions list: a collapsible "📌 Pinned (N)" section, expanded by default; rows + fully reuse the existing session row (status dot / badges / PR / title all intact). +- A pinned session **also** stays in its chronological position (with the ★) — the section is + a shortcut, not a move (Notion favorites behave the same). +- While searching: the pinned section hides; results are one unified list (matching pinned + rows keep the ★). +- Unpin: hover-`x` on the pinned row (the recent-projects list already has this pattern) or + click 📌 again. +- One-line empty-state hint. +- v1 ordering: pinnedAt desc; named groups are v2 (schema keeps a `group?` field now). + +**Store (deviation from the handoff [REC], justified: multi-account era + hidden list too):** +- Single file `~/.config/codev/session-marks.json` (one per machine, cross-account; + `~/.config/codev/` already hosts the accounts registry): + ```json + { "pins": { "": { "pinnedAt": "…", "cwd": "…", "accountLabel": "…", "group": null } }, + "hidden": ["", "…"] } + ``` +- `fs.watch` on that file, same pattern as the status files (`session-status-hooks.ts` is the template). + +**sessionId stability (verified 2026-07-12 — simplified the design substantially):** +- [FACT] Claude Code 2.1.207: `--resume` / `--continue` **reuse the original sessionId and + append to the same transcript file by default**; a new id is opt-in via `--fork-session` + (help text: "When resuming, create a new session ID **instead of reusing the original**"). + Matches the user's daily observation (msgs count accumulating on one row across resumes). +- ⇒ **Keying pins by sessionId is enough**; normal resumes need no migration machinery. +- Edge cases (v1 ignores them; re-pin manually if hit): explicit `--fork-session`, + cross-account copy-fork (issue #128). + +## 5. Batch 2 — structural investments + +### 5.1 C4: preview / detail (v1 card → v2 pane) + +- Corresponds to issue #66's "detail view" and #105; kills the root bottleneck ("must resume + to verify") — the counterpart of Notion's right pane. +- **v1 (card)**: click a row (or press Space) → in-place detail card: custom/AI title, full + first/last message, branch, PR, msgs, account, time + a text digest of the last N messages. +- **v2 (pane/reader)**: list left + read-only transcript reader right: lazy-load the last N + messages, markdown rendering (the AI Chat tab already has an md renderer to reuse), tool + calls collapsed to one-liner chips, **jump-to-match when arriving from search** (match + positions come from A2's FTS). +- The "with C4 do we still need B1?" ruling: they serve different steps — B1 lets you **scan + the list and see why each row matched** (no opening each one), C4 lets you **inspect one + candidate deeply**. But B1's standalone work is absorbed: prompt-match snippets ship in + §4.2, transcript-match snippets come free from A2's `snippet()` → **there is no standalone + B1 work item**. +- Transcript-reading caveats: lines contain base64 images and huge tool_results — extract + text blocks only; files can be tens of MB (tail-read + paginate). + +### 5.2 A2: FTS5 full-text index (full version, no rg stopgap) + +- Engine: **better-sqlite3 (already a dependency) + FTS5**. DB at `~/.config/codev/search-index.db`. +- Schema sketch: `messages(session_id, account, project, role, ts, text)` + an FTS5 virtual + table (external-content or contentless both fine); plus `files(path, mtime, bytes_indexed)` + for incremental progress. +- **Incremental indexing**: transcripts are append-only → track a per-file byte offset, parse + only the delta; triggers: app focus / timer / session end. First full build runs in the + background (chunked; never block the UI). +- **Extraction rules v1**: user + assistant text blocks; **exclude** thinking, + tool_use/tool_result, base64. Indexing tool output (useful for "which session touched file + X") stays behind a flag / v2 — that's A2+ (build a small inverted index from tool_use + file_path args if trivial). +- **CJK trap and fix**: FTS5's trigram tokenizer needs ≥3 chars — 2-char CJK terms like + 「上限」 would miss → **pre-segment CJK runs into space-joined bigrams at index AND query + time** (ASCII words left intact), on top of the default unicode61 tokenizer. Pure JS + preprocessing, no native tokenizer dependency, mixed CJK/EN queries fine. +- Ranking: bm25 × recency; `snippet()` feeds B1/C4 directly. +- Multi-account: one DB with an `account` column; scan sources via the existing + `getScannableAccounts()`. +- Duplicate-content note: normal resumes append to the same file (§4.4) — no cross-file + duplication; only explicit `--fork-session` creates ancestor/descendant double-matches — + rare, v1 ignores. +- Expired sessions (transcript already cleaned up): the index keeps the text → results get an + "expired" badge (readable, not resumable). + +## 6. Batch 3 — add by feel + +| Item | Content | Note | +|---|---|---| +| D3 `/pin` | Custom slash command: leverages Claude Code's slash **autocomplete** (answers the user's dislike of `!` having none); the command runs `codev pin`; sessionId from the **`CLAUDE_CODE_SESSION_ID` env var** ([FACT], §7); accepts one LLM turn (user OK'd). Args possible: `/pin as "…"` | UI pin remains primary | +| B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` chips | | +| A4-lite | "Generate title" button in the preview (haiku, writes a custom title) | No batch auto-summarizing | +| C3 chain collapse | **Essentially defunct** (2026-07-12): normal resumes reuse the sessionId (§4.4) — no generation chains exist; only meaningful if `--fork-session` / copy-fork become common | Kept for the record | + +## 7. Key technical facts (gotchas — read before implementing) + +1. **There is no clean in-session typed trigger** ([FACT], empirically proven — + `pin-feature-handoff.md` §4): `/pin` triggers an LLM turn; **`!` bash-mode does too** (the + output is submitted to the model — docs claim otherwise; on the real machine it turned); + a `UserPromptSubmit` hook block (exit 2) avoids the turn but always shows a blocked notice + (`suppressOutput` can't hide it). ⇒ Don't re-attempt "no turn AND no notice"; D3's premise + is "accept the turn, gain autocomplete". +2. **`CLAUDE_CODE_SESSION_ID`** exists in the session shell and holds the session UUID (NOT + the commonly-cited `CLAUDE_SESSION_ID`). +3. **Custom titles live inside the transcript**: `/rename` writes a `"type":"custom-title"` + line; CodeV reads it via grep + tail -1 (`claude-session-utility.ts`). Resumes append to + the same file (fact 4) → titles persist naturally; pins keyed by sessionId persist the + same way. +4. **Resume semantics (verified on 2.1.207 via `--help`)**: `--resume` / `--continue` + **reuse the sessionId and continue the same file by default**; only `--fork-session` + creates a new id/file. ⚠️ Old Claude Code versions forked by default — stale web posts and + old experience still claim that; don't trust them (this plan's first draft got it wrong + until the user challenged it). +5. **history.jsonl: one line = one complete user prompt** (`display` untruncated, longest + measured 9,224 chars); a session spans many lines; the accumulator keeps first/last and, + since PR #132, all prompts in a main-side map. +6. **cachedSessions is metadata, not transcripts**: one small object per session + (id/project/first/last/timestamps/count/account); 414 sessions ≪ 1MB; 5s TTL + (`CACHE_TTL_MS`); the 894MB of transcripts are only ever tail-read per visible session + during enrichment. **Never stuff large data into IPC-returned session objects.** +7. **The real pre-#132 search cap was the ~100 loaded sessions** (issue #131): renderer + `filterSessionsLocally` only filtered the loaded list; main-side `searchClaudeSessions` + was dead code. Fixed by §4.2 (shipped in PR #132). +8. **FTS5 trigram needs ≥3 chars** → 2-char CJK terms miss → bigram pre-segmentation (§5.2). +9. **Transcript lines contain base64 images and huge tool_results** → extract/render text + blocks only, for both indexing and preview. +10. Multi-account: always iterate via `getScannableAccounts()`; user-level cross-account data + (pins/hidden/index) lives in `~/.config/codev/`, one copy. +11. Lint: no CI lint gate; pre-existing files are not prettier-formatted → **format only the + lines you change**; new files may be fully prettier'd. + +## 8. Open questions (decide during implementation) + +- "Pinned section AND chronological position both show the row" is the proposed default; the + user hasn't given a final verdict (switch to section-only if they object). +- `session-marks.json` single file vs. the handoff's `~/.claude/codev-status/pinned.json`: + this doc leans to the former (multi-account + hidden list); revisit at implementation time. +- Whether C4 v1 card and v2 pane ship together: judge by effort at the time. +- Whether FTS indexes thinking blocks: v1 no (size/noise), keep a flag. diff --git a/package.json b/package.json index 0f2e391..43f5661 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.82", + "version": "1.0.83", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 9f23b48..5a5c37d 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -13,6 +13,11 @@ import { getProjectsDir, getAccountByLabel, } from './accounts'; +import { + findPromptMatch, + matchesAllWords, + PromptMatch, +} from './session-search'; export interface ClaudeSession { sessionId: string; @@ -69,6 +74,10 @@ let cachedSessions: ClaudeSession[] | null = null; let cacheTimestamp = 0; const CACHE_TTL_MS = 5000; // refresh cache after 5 seconds +// All user prompts per session, same rebuild lifecycle as cachedSessions. +// Main-process-only: searched here, never shipped over IPC (~MBs of text). +let promptsBySession: Map = new Map(); + // Cache for active session detection to avoid spawning processes on every keystroke let cachedActiveMap: Map | null = null; let cachedVSCodeSessions: ClaudeSession[] | null = null; @@ -78,7 +87,6 @@ const ACTIVE_CACHE_TTL_MS = 5000; // Cache for custom titles let cachedCustomTitles: Map | null = null; -let titlesCacheTimestamp = 0; export const invalidateSessionCache = () => { cachedSessions = null; @@ -87,6 +95,8 @@ export const invalidateSessionCache = () => { cachedEntrypoints = null; cachedCustomTitles = null; cachedBranches = null; + cachedPRLinks = null; + enrichedFileState.clear(); }; /** @@ -103,6 +113,7 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { // Multi-account: scan every configured account's history.jsonl and merge. // sessionIds are UUIDs (unique across accounts), so no cross-account dedupe. const bySession = new Map(); + const prompts = new Map(); // Per-account try/catch: one unreadable/corrupt history must not hide the // sessions of every other account. @@ -118,6 +129,12 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { const raw: HistoryLine = JSON.parse(line); if (!raw.sessionId) continue; + if (raw.display) { + const list = prompts.get(raw.sessionId); + if (list) list.push(raw.display); + else prompts.set(raw.sessionId, [raw.display]); + } + const existing = bySession.get(raw.sessionId); if (existing) { existing.promptCount++; @@ -176,24 +193,55 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { })); cachedSessions = allSessions; + promptsBySession = prompts; cacheTimestamp = now; return allSessions.slice(0, limit); }; +export interface SessionSearchMatch extends PromptMatch { + isLastPrompt: boolean; +} + +export interface SessionSearchResult { + sessions: ClaudeSession[]; + /** sessionId -> where the match sits inside the prompt list (when in a prompt). */ + snippets: Record; +} + /** - * Search Claude Code sessions by project name or first message + * Full search across ALL sessions (not just the ~100 the UI loads) and ALL + * user prompts (not just first/last) — fixes issue #131. Sessions come back + * newest-first; prompt text stays in this process (only snippets cross IPC). */ -export const searchClaudeSessions = (query: string, limit = 50): ClaudeSession[] => { - const allSessions = readClaudeSessions(500); +export const searchClaudeSessions = ( + query: string, + limit = 100, +): SessionSearchResult => { const words = query.toLowerCase().split(/\s+/).filter(Boolean); - if (words.length === 0) return allSessions.slice(0, limit); - - return allSessions - .filter((s) => { - const searchTarget = `${s.projectName} ${s.project} ${s.firstUserMessage} ${s.lastUserMessage}`.toLowerCase(); - return words.every((word) => searchTarget.includes(word)); - }) - .slice(0, limit); + if (words.length === 0) return { sessions: [], snippets: {} }; + + // Recency-sorted full set; also (re)builds promptsBySession when stale. + const allSessions = readClaudeSessions(Number.MAX_SAFE_INTEGER); + const sessions: ClaudeSession[] = []; + const snippets: Record = {}; + + for (const s of allSessions) { + const sessionPrompts = promptsBySession.get(s.sessionId) || []; + const target = + `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); + if (!matchesAllWords(target, words)) continue; + + sessions.push(s); + const match = findPromptMatch(sessionPrompts, words); + if (match) { + snippets[s.sessionId] = { + ...match, + isLastPrompt: match.promptIndex === sessionPrompts.length - 1, + }; + } + if (sessions.length >= limit) break; + } + return { sessions, snippets }; }; /** @@ -1662,84 +1710,196 @@ export interface SessionEnrichment { let cachedBranches: Map | null = null; let cachedPRLinks: Map | null = null; -export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise => { - const now = Date.now(); - if (cachedCustomTitles && cachedBranches && cachedPRLinks && (now - titlesCacheTimestamp) < CACHE_TTL_MS) { - return { titles: cachedCustomTitles, branches: cachedBranches, prLinks: cachedPRLinks }; +// Per-file enrichment scan state: a transcript unchanged since its last scan +// (same mtime+size) is never re-grepped — the stat check IS the freshness +// test. Replaces the 5s wall-clock TTL, which broke once a full scan took +// longer than the TTL itself: the just-written cache was already stale, so +// every popup interaction kicked off another multi-second full rescan. +const enrichedFileState = new Map(); +// Serialize scans: concurrent callers queue up and then mostly hit the +// accumulated cache (correct even when they pass different session sets). +let enrichmentQueue: Promise = Promise.resolve(); + +// Run per-session async work in bounded batches. ~100 sessions × several +// exec() greps each used to spawn hundreds of concurrent processes at once, +// starving the biggest (busiest) transcripts into the exec timeout — whose +// errors resolve to '' — which is why titles/branches vanished at random. +const runInBatches = async ( + items: T[], + batchSize: number, + worker: (item: T) => Promise, +): Promise => { + for (let i = 0; i < items.length; i += batchSize) { + await Promise.all(items.slice(i, i + batchSize).map(worker)); } +}; - const { exec } = require('child_process'); - const execPromise = (cmd: string): Promise => - new Promise((resolve) => { - exec(cmd, { encoding: 'utf-8', timeout: 3000 }, (err: any, stdout: string) => { - resolve(err ? '' : stdout); - }); - }); - - const titles = new Map(); - const branches = new Map(); - const prLinks = new Map(); - const promises = sessions.map(async (session) => { - const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); - const jsonlPath = path.join( - getProjectsDir(session.accountDir), - encodedProject, - `${session.sessionId}.jsonl`, - ); +// Read the last `bytes` of a file without spawning a process. Async so the +// main process event loop is never blocked by transcript reads. +const readTailUtf8 = async ( + filePath: string, + bytes: number, +): Promise => { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(filePath, 'r'); + const size = (await fh.stat()).size; + const len = Math.min(bytes, size); + const buf = Buffer.alloc(len); + // Cast: this @types/node version mistypes Buffer vs ArrayBufferView. + await fh.read(buf as unknown as Uint8Array, 0, len, size - len); + return buf.toString('utf-8'); + } catch { + return null; + } finally { + await fh?.close().catch(() => {}); + } +}; - if (!fs.existsSync(jsonlPath)) return; +export const loadSessionEnrichment = async ( + sessions: ClaudeSession[], +): Promise => { + // Accumulator maps persist across calls; scans only fill/refresh entries. + const titles = (cachedCustomTitles ??= new Map()); + const branches = (cachedBranches ??= new Map()); + const prLinks = (cachedPRLinks ??= new Map()); - // Run title, ai-title, branch, and PR link greps in parallel for each file - const [titleOutput, aiTitleOutput, branchOutput, prLinkOutput] = await Promise.all([ - execPromise(`grep '"type":"custom-title"' "${jsonlPath}" 2>/dev/null | tail -1`), - execPromise(`grep '"type":"ai-title"' "${jsonlPath}" 2>/dev/null | tail -1`), - execPromise(`tail -n 5 "${jsonlPath}" 2>/dev/null | grep -o '"gitBranch":"[^"]*"' | tail -1`), - execPromise(`grep '"type":"pr-link"' "${jsonlPath}" 2>/dev/null | tail -1`), - ]); + const { execFile } = require('child_process'); + // Shell-free (no interpolated paths anywhere near a shell). Resolves null + // on real failures (timeout/spawn) so they are distinguishable from + // no-match: grep exits 1 for "no match", a successful empty read. + const grepFileP = ( + pattern: string, + filePath: string, + ): Promise => + new Promise((resolve) => { + execFile( + 'grep', + [pattern, filePath], + { encoding: 'utf-8', timeout: 5000, maxBuffer: 10 * 1024 * 1024 }, + (err: any, stdout: string) => { + if (err && err.code !== 1) resolve(null); + else resolve(stdout || ''); + }, + ); + }); + const lastLine = (out: string): string => { + const lines = out.trim().split('\n'); + return lines[lines.length - 1] || ''; + }; - // Priority: custom-title > ai-title - if (titleOutput.trim()) { + const scan = async () => { + // Re-scan only transcripts that are new or changed since their last scan. + const toScan: { + session: ClaudeSession; + jsonlPath: string; + mtimeMs: number; + size: number; + }[] = []; + for (const session of sessions) { + const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); + const jsonlPath = path.join( + getProjectsDir(session.accountDir), + encodedProject, + `${session.sessionId}.jsonl`, + ); + let stat: fs.Stats; try { - const parsed = JSON.parse(titleOutput.trim()); - const title = (parsed.customTitle || '').replace(/^"|"$/g, '').trim(); - if (title) { - titles.set(session.sessionId, title); - } - } catch {} + stat = fs.statSync(jsonlPath); + } catch { + continue; + } + const prev = enrichedFileState.get(session.sessionId); + if (prev && prev.mtimeMs === stat.mtimeMs && prev.size === stat.size) { + continue; + } + toScan.push({ + session, + jsonlPath, + mtimeMs: stat.mtimeMs, + size: stat.size, + }); } - if (!titles.has(session.sessionId) && aiTitleOutput.trim()) { - try { - const parsed = JSON.parse(aiTitleOutput.trim()); - const title = (parsed.aiTitle || '').trim(); - if (title) { - titles.set(session.sessionId, title); + + await runInBatches( + toScan, + 25, + async ({ session, jsonlPath, mtimeMs, size }) => { + // Title, ai-title, and PR-link greps run in parallel; the branch comes + // from an in-process tail read (~256KB reaches far beyond the old + // 50-line window — an active session's tail is often tool output with + // no gitBranch field: measured tail -5 hit 0, tail -20 hit 12). + const [titleOutput, aiTitleOutput, prLinkOutput, tailOutput] = + await Promise.all([ + grepFileP('"type":"custom-title"', jsonlPath), + grepFileP('"type":"ai-title"', jsonlPath), + grepFileP('"type":"pr-link"', jsonlPath), + readTailUtf8(jsonlPath, 256 * 1024), + ]); + + // Priority: custom-title > ai-title + if (titleOutput) { + try { + const parsed = JSON.parse(lastLine(titleOutput)); + const title = (parsed.customTitle || '') + .replace(/^"|"$/g, '') + .trim(); + if (title) { + titles.set(session.sessionId, title); + } + } catch {} + } + if (!titles.has(session.sessionId) && aiTitleOutput) { + try { + const parsed = JSON.parse(lastLine(aiTitleOutput)); + const title = (parsed.aiTitle || '').trim(); + if (title) { + titles.set(session.sessionId, title); + } + } catch {} } - } catch {} - } - if (branchOutput.trim()) { - const match = branchOutput.match(/"gitBranch":"([^"]*)"/); - if (match && match[1] && match[1] !== 'HEAD') { - branches.set(session.sessionId, match[1]); - } - } + if (tailOutput) { + const all = [...tailOutput.matchAll(/"gitBranch":"([^"]*)"/g)]; + const branch = all.length > 0 ? all[all.length - 1][1] : ''; + if (branch && branch !== 'HEAD') { + branches.set(session.sessionId, branch); + } else if (branch === 'HEAD') { + // Explicit detached HEAD: drop the stale branch. A tail with NO + // gitBranch line at all keeps the old value on purpose — it is + // usually transient tool output (measured), not a branch change. + branches.delete(session.sessionId); + } + } - if (prLinkOutput.trim()) { - try { - const parsed = JSON.parse(prLinkOutput.trim()); - if (parsed.prNumber && parsed.prUrl) { - prLinks.set(session.sessionId, { prNumber: parsed.prNumber, prUrl: parsed.prUrl }); + if (prLinkOutput) { + try { + const parsed = JSON.parse(lastLine(prLinkOutput)); + if (parsed.prNumber && parsed.prUrl) { + prLinks.set(session.sessionId, { + prNumber: parsed.prNumber, + prUrl: parsed.prUrl, + }); + } + } catch {} } - } catch {} - } - }); - await Promise.all(promises); + // Mark fresh ONLY when every read succeeded — a timed-out or failed + // pass stays unrecorded so the next call retries it. + if ( + titleOutput !== null && + aiTitleOutput !== null && + prLinkOutput !== null && + tailOutput !== null + ) { + enrichedFileState.set(session.sessionId, { mtimeMs, size }); + } + }, + ); + }; - cachedCustomTitles = titles; - cachedBranches = branches; - cachedPRLinks = prLinks; - titlesCacheTimestamp = now; + enrichmentQueue = enrichmentQueue.then(scan, scan); + await enrichmentQueue; return { titles, branches, prLinks }; }; @@ -1761,7 +1921,7 @@ export const loadLastAssistantResponses = async ( }); const responses = new Map(); - const promises = sessions.map(async (session) => { + await runInBatches(sessions, 25, async (session) => { const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); const jsonlPath = path.join( getProjectsDir(session.accountDir), @@ -1794,7 +1954,6 @@ export const loadLastAssistantResponses = async ( } }); - await Promise.all(promises); return responses; }; diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index cd72b3d..7f1e401 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -130,7 +130,13 @@ interface IElectronAPI { // Claude Code sessions getClaudeSessions: (limit?: number) => Promise; - searchClaudeSessions: (query: string) => Promise; + searchClaudeSessions: (query: string) => Promise<{ + sessions: any[]; + snippets: Record< + string, + { snippet: string; promptIndex: number; isLastPrompt: boolean } + >; + }>; detectActiveSessions: () => Promise<{ activeMap: Record; vscodeSessions: any[]; diff --git a/src/session-search.test.ts b/src/session-search.test.ts new file mode 100644 index 0000000..533f9f4 --- /dev/null +++ b/src/session-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractSnippet, + findPromptMatch, + isMinorSession, + matchesAllWords, +} from './session-search'; + +describe('matchesAllWords', () => { + it('requires every word (AND semantics), matching across the combined text', () => { + const target = + 'codev multi account /effort 我問一下 sessions 上限'.toLowerCase(); + expect(matchesAllWords(target, ['multi', 'sessions'])).toBe(true); + expect(matchesAllWords(target, ['multi', 'missing'])).toBe(false); + }); + + it('matches 2-char CJK substrings', () => { + expect(matchesAllWords('原本的 sessions 數我有設上限', ['上限'])).toBe( + true, + ); + expect(matchesAllWords('原本的 sessions 數我有設上限', ['下限'])).toBe( + false, + ); + }); +}); + +describe('extractSnippet', () => { + it('adds ellipses only where text is truncated', () => { + const text = 'a'.repeat(100) + 'NEEDLE' + 'b'.repeat(100); + const snippet = extractSnippet(text, 100, 6, 10); + expect(snippet).toBe('…aaaaaaaaaaNEEDLEbbbbbbbbbb…'); + }); + + it('omits leading ellipsis at start of text and collapses whitespace', () => { + const text = + 'NEEDLE line one\n\n line two after newline and more trailing text'; + const snippet = extractSnippet(text, 0, 6, 30); + expect(snippet.startsWith('NEEDLE line one line two')).toBe(true); + expect(snippet.endsWith('…')).toBe(true); + expect(snippet).not.toContain('\n'); + }); +}); + +describe('findPromptMatch', () => { + const prompts = [ + 'first prompt about setup', + 'middle prompt mentioning 上限 and performance', + 'last prompt wrapping up', + ]; + + it('finds the first prompt containing a word and reports its index', () => { + const m = findPromptMatch(prompts, ['上限']); + expect(m).not.toBeNull(); + expect(m!.promptIndex).toBe(1); + expect(m!.snippet).toContain('上限'); + }); + + it('is case-insensitive against the prompt text', () => { + const m = findPromptMatch(['Deploy STAGING now'], ['staging']); + expect(m).not.toBeNull(); + expect(m!.snippet).toContain('STAGING'); + }); + + it('returns null when no prompt contains any word (project-only match)', () => { + expect(findPromptMatch(prompts, ['codev'])).toBeNull(); + }); +}); + +describe('isMinorSession', () => { + it('folds closed, untitled, PR-less sessions with ≤2 messages', () => { + expect( + isMinorSession({ messageCount: 1, isActive: false }, false, false), + ).toBe(true); + expect( + isMinorSession({ messageCount: 2, isActive: false }, false, false), + ).toBe(true); + }); + + it('never folds active sessions, titled sessions, PR sessions, or 3+ msgs', () => { + expect( + isMinorSession({ messageCount: 1, isActive: true }, false, false), + ).toBe(false); + expect( + isMinorSession({ messageCount: 1, isActive: false }, true, false), + ).toBe(false); + expect( + isMinorSession({ messageCount: 1, isActive: false }, false, true), + ).toBe(false); + expect( + isMinorSession({ messageCount: 3, isActive: false }, false, false), + ).toBe(false); + }); + + it('treats unknown messageCount as not minor (conservative)', () => { + expect(isMinorSession({ isActive: false }, false, false)).toBe(false); + }); +}); diff --git a/src/session-search.ts b/src/session-search.ts new file mode 100644 index 0000000..23df714 --- /dev/null +++ b/src/session-search.ts @@ -0,0 +1,76 @@ +/** + * Pure session-search / list helpers (no fs, no electron) so they are unit-testable. + * + * Used by: + * - claude-session-utility.ts — main-side full-prompt search (issue #131) + * - switcher-ui.tsx — minor-session folding predicate + */ + +export interface PromptMatch { + /** 0-based index into the session's prompt list (0 = first user message). */ + promptIndex: number; + /** Human-readable context window centered on the first matched word. */ + snippet: string; +} + +/** AND-match: every word must appear somewhere in the haystack (both lowercased). */ +export const matchesAllWords = ( + haystackLower: string, + wordsLower: string[], +): boolean => wordsLower.every((w) => haystackLower.includes(w)); + +/** + * Extract a snippet of `radius` chars on each side of the match, collapsing + * whitespace/newlines so it renders as a single line. Ellipses mark truncation. + */ +export const extractSnippet = ( + text: string, + matchStart: number, + matchLen: number, + radius = 40, +): string => { + const start = Math.max(0, matchStart - radius); + const end = Math.min(text.length, matchStart + matchLen + radius); + const core = text.slice(start, end).replace(/\s+/g, ' ').trim(); + return `${start > 0 ? '…' : ''}${core}${end < text.length ? '…' : ''}`; +}; + +/** + * Find the first prompt containing any of the search words and return a + * snippet around it. Returns null when no prompt contains any word (e.g. the + * session matched on project name only). + */ +export const findPromptMatch = ( + prompts: string[], + wordsLower: string[], +): PromptMatch | null => { + for (let i = 0; i < prompts.length; i++) { + const lower = prompts[i].toLowerCase(); + for (const w of wordsLower) { + const idx = lower.indexOf(w); + if (idx !== -1) { + return { + promptIndex: i, + snippet: extractSnippet(prompts[i], idx, w.length), + }; + } + } + } + return null; +}; + +/** + * Minor-session ("junk") folding predicate: a closed session with almost no + * content and no user-assigned identity. Conservative on purpose — sessions + * with an unknown messageCount are NOT minor (fold less, never hide real work). + */ +export const isMinorSession = ( + session: { messageCount?: number; isActive?: boolean }, + hasCustomTitle: boolean, + hasPrLink: boolean, +): boolean => + !session.isActive && + !hasCustomTitle && + !hasPrLink && + typeof session.messageCount === 'number' && + session.messageCount <= 2; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 9609cb2..c8207f5 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1,16 +1,51 @@ import { VSWindow as VSWindowModel } from '@prisma/client'; -import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FC, Fragment, useCallback, useEffect, useRef, useState } from 'react'; import * as ReactDOM from 'react-dom/client'; import Highlighter from 'react-highlight-words'; import Select, { components, OptionProps } from 'react-select'; import { HoverButton } from './HoverButton'; import PopupDefaultExample from './popup'; +import { isMinorSession } from './session-search'; import TerminalTab from './terminal-tab'; type SwitcherMode = 'projects' | 'sessions' | 'terminal'; // import { fetchVSCodeBasedOpenedWindows, SERVER_URL, deleteRecentProjectRecord } from "./vscode-based-ide-utility" export const SERVER_URL = 'http://localhost:55688'; +// Unified search-match highlight — high contrast on every row color scheme +// (the previous per-site translucent styles were near-invisible on colored text). +const SEARCH_HIGHLIGHT_STYLE = { + backgroundColor: '#f5b942', + color: '#1a1a1a', + padding: '0 2px', + borderRadius: '2px', + fontWeight: 600, +} as const; + +// Boundary header of the expanded minor-sessions group. Sticky: it pins to +// the top while scrolled inside the minors zone, so collapsing never +// requires scrolling back to the boundary row. +const MINOR_FOLD_HEADER_STYLE = { + padding: '6px 10px 4px 24px', + color: '#777', + fontSize: '12px', + cursor: 'pointer', + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: '#1a1a1a', +} as const; + +// Always-visible fold bar below the scroll area while minors are expanded. +const MINOR_FOLD_BAR_STYLE = { + padding: '6px 15px 8px', + color: '#888', + fontSize: '12px', + cursor: 'pointer', + borderTop: '1px solid #2e2e2e', + flexShrink: 0, +} as const; + // Global styles for the switcher UI (moved from index.css) const globalStyles = ` body { @@ -355,11 +390,19 @@ function SwitcherApp() { const [assistantResponses, setAssistantResponses] = useState>({}); const [terminalApps, setTerminalApps] = useState>({}); const [sessionStatuses, setSessionStatuses] = useState>({}); + const [searchSnippets, setSearchSnippets] = useState>({}); + const [minorsExpanded, setMinorsExpanded] = useState(false); + // Folding waits for the first active-session detection so a just-started + // (≤2 msgs, not-yet-detected) session is never folded away at app start. + const [activeDetectionReady, setActiveDetectionReady] = useState(false); const modeRef = useRef(initialMode); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); const lastAssistantFetchRef = useRef>({}); const sessionSearchRef2 = useRef(''); // tracks current search value for use in closures + const deepSearchTimerRef = useRef | null>(null); + const deepSearchSeqRef = useRef(0); + const deepMatchesRef = useRef([]); // latest main-side full-prompt matches // Set true when a session is opened; on the next window show, clear the search so // returning to Sessions shows the full list. Toggling away without selecting keeps it. const clearSessionSearchOnShowRef = useRef(false); @@ -392,6 +435,89 @@ function SwitcherApp() { }); }; + // Union of the local field filter and the latest main-side full-prompt search + // results (issue #131). Deep matches outside the loaded list are appended, + // then everything re-sorts into the usual recency order. + const applySearchFilter = (allItems: any[], query: string) => { + const base = filterSessionsLocally(allItems, query); + if (!query.trim() || deepMatchesRef.current.length === 0) return base; + const seen = new Set(base.map((s: any) => s.sessionId)); + const extra = deepMatchesRef.current + .filter((s: any) => !seen.has(s.sessionId)) + .map((s: any) => ({ + ...s, + isActive: s.sessionId in activeStateRef.current, + activePid: activeStateRef.current[s.sessionId], + })); + if (extra.length === 0) return base; + const merged = [...base, ...extra]; + merged.sort( + (a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0), + ); + return merged; + }; + + // Debounced main-side search over ALL sessions × ALL user prompts. + const scheduleDeepSearch = (query: string) => { + if (deepSearchTimerRef.current) clearTimeout(deepSearchTimerRef.current); + deepMatchesRef.current = []; + const seq = ++deepSearchSeqRef.current; + if (!query.trim()) { + setSearchSnippets({}); + return; + } + deepSearchTimerRef.current = setTimeout(async () => { + const res = await window.electronAPI.searchClaudeSessions(query); + // Drop stale responses (query changed while this one was in flight) + if (seq !== deepSearchSeqRef.current || sessionSearchRef2.current !== query) return; + deepMatchesRef.current = res?.sessions || []; + setSearchSnippets(res?.snippets || {}); + setSessions(applySearchFilter(allSessionsRef.current, query)); + // Lazy-enrich deep matches that aren't in the loaded list. Bounded by + // the deep-search result cap (100), same magnitude as the initial load. + const loaded = new Set( + allSessionsRef.current.map((s: any) => s.sessionId), + ); + const appended = deepMatchesRef.current.filter( + (s: any) => !loaded.has(s.sessionId), + ); + if (appended.length > 0) { + window.electronAPI.loadSessionEnrichment(appended).then((enrichment) => { + if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { + setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); + } + if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { + setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); + } + if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { + setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); + } + }); + window.electronAPI.loadLastAssistantResponses(appended).then((responses: Record) => { + if (responses && Object.keys(responses).length > 0) { + setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); + } + }); + } + }, 180); + }; + + // C1: fold minor (junk) sessions while browsing; searching shows everything. + // Minors keep their recency order but render below the fold row at the end. + const isSearchingSessions = sessionSearchValue.trim().length > 0; + const majorSessions: any[] = []; + const minorSessions: any[] = []; + for (const s of sessions) { + const minor = + !isSearchingSessions && + activeDetectionReady && + isMinorSession(s, !!customTitles[s.sessionId], !!prLinks[s.sessionId]); + (minor ? minorSessions : majorSessions).push(s); + } + const displayedSessions = minorsExpanded + ? [...majorSessions, ...minorSessions] + : majorSessions; + const fetchClaudeSessions = async () => { // Step 1: Show sessions immediately, preserve old active states (SWR via ref) const result = await window.electronAPI.getClaudeSessions(100); @@ -404,7 +530,12 @@ function SwitcherApp() { }); setAllSessions(newSessions); allSessionsRef.current = newSessions; - setSessions(sessionSearchValue.trim() ? filterSessionsLocally(newSessions, sessionSearchValue) : newSessions); + // Read via ref, not state: this runs from persistent callbacks (window + // focus, mode toggle) whose closure would hold a stale sessionSearchValue. + const search = sessionSearchRef2.current; + setSessions( + search.trim() ? applySearchFilter(newSessions, search) : newSessions, + ); // Step 2: Load last assistant responses for all sessions (first 100) window.electronAPI.loadLastAssistantResponses((result || []).slice(0, 100)).then((responses: Record) => { @@ -421,6 +552,7 @@ function SwitcherApp() { // Save to ref for SWR on next refresh activeStateRef.current = activeMap; + setActiveDetectionReady(true); const updateActive = (list: any[]) => { // Mark existing sessions as active/inactive @@ -444,7 +576,7 @@ function SwitcherApp() { setSessions((prev: any[]) => { const updated = updateActive(prev); const search = sessionSearchRef2.current; - return search.trim() ? filterSessionsLocally(updated, search) : updated; + return search.trim() ? applySearchFilter(updated, search) : updated; }); if (Object.keys(activeMap).length > 0) { @@ -486,7 +618,7 @@ function SwitcherApp() { setSessions((prev: any[]) => { const merged = mergeAndCap(prev); const search = sessionSearchRef2.current; - return search.trim() ? filterSessionsLocally(merged, search) : merged; + return search.trim() ? applySearchFilter(merged, search) : merged; }); allVSCode.push(...closedVS); // Use pre-loaded assistant responses from closed sessions @@ -764,12 +896,15 @@ function SwitcherApp() { }); } if (modeRef.current === 'sessions') { + // Each fresh popup show starts with minor sessions folded again. + setMinorsExpanded(false); // If a session was just opened, reset the search before refetching so the // full list shows on return (keyword is kept when merely toggling away). if (clearSessionSearchOnShowRef.current) { clearSessionSearchOnShowRef.current = false; setSessionSearchValue(''); sessionSearchRef2.current = ''; + scheduleDeepSearch(''); setSelectedSessionIndex(-1); // Drop the stale filtered list immediately so the empty input and the // visible list agree before fetchClaudeSessions() resolves. @@ -1185,7 +1320,8 @@ function SwitcherApp() { const val = e.target.value; setSessionSearchValue(val); sessionSearchRef2.current = val; - setSessions(filterSessionsLocally(allSessions, val)); + scheduleDeepSearch(val); + setSessions(applySearchFilter(allSessions, val)); setSelectedSessionIndex(0); }} onKeyDown={(e) => { @@ -1193,6 +1329,7 @@ function SwitcherApp() { if (sessionSearchValue) { setSessionSearchValue(''); sessionSearchRef2.current = ''; + scheduleDeepSearch(''); setSessions(allSessions); } else { hideApp(); @@ -1200,7 +1337,7 @@ function SwitcherApp() { } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSessionIndex((i) => { - const next = Math.min(i + 1, sessions.length - 1); + const next = Math.min(i + 1, displayedSessions.length - 1); setTimeout(() => document.querySelector(`[data-session-index="${next}"]`)?.scrollIntoView({ block: 'nearest' }), 0); return next; }); @@ -1216,7 +1353,7 @@ function SwitcherApp() { } else if (e.key === 'PageDown') { e.preventDefault(); setSelectedSessionIndex((i) => { - const next = Math.min(i + 5, sessions.length - 1); + const next = Math.min(i + 5, displayedSessions.length - 1); setTimeout(() => document.querySelector(`[data-session-index="${next}"]`)?.scrollIntoView({ block: 'nearest' }), 0); return next; }); @@ -1229,7 +1366,7 @@ function SwitcherApp() { }); } else if (e.key === 'Enter') { const idx = selectedSessionIndex >= 0 ? selectedSessionIndex : 0; - const s = sessions[idx]; + const s = displayedSessions[idx]; if (s) { // Arm before opening, in case the bridge triggers the focus cycle synchronously. clearSessionSearchOnShowRef.current = true; @@ -1259,10 +1396,27 @@ function SwitcherApp() {
{sessionSearchValue ? '⚠️ No matching sessions found' : '🤖 No Claude Code sessions found'}
- ) : ( - sessions.map((session, index) => ( + ) : (<> + {displayedSessions.map((session, index) => ( + + {minorsExpanded && minorSessions.length > 0 && index === majorSessions.length && ( +
{ setMinorsExpanded(false); setSelectedSessionIndex(0); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setMinorsExpanded(false); + setSelectedSessionIndex(0); + } + }} + style={MINOR_FOLD_HEADER_STYLE} + > + ▾ {minorSessions.length} minor sessions (≤2 msgs, untitled) +
+ )}
{ clearSessionSearchOnShowRef.current = true; @@ -1308,12 +1462,7 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={session.projectName} - highlightStyle={{ - backgroundColor: 'rgba(0, 188, 212, 0.2)', - color: '#fff', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> {customTitles[session.sessionId] && ( @@ -1322,12 +1471,7 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={customTitles[session.sessionId].slice(0, 35)} - highlightStyle={{ - backgroundColor: 'rgba(126, 200, 126, 0.2)', - color: '#a0e8a0', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> )} @@ -1337,12 +1481,7 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={branches[session.sessionId]} - highlightStyle={{ - backgroundColor: 'rgba(200, 200, 200, 0.15)', - color: '#bbb', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} />] )} @@ -1374,12 +1513,7 @@ function SwitcherApp() { ); @@ -1416,12 +1550,7 @@ function SwitcherApp() { ) : null; @@ -1443,12 +1572,7 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={(session.firstUserMessage || '').slice(0, sessionDisplayMode === 'both' ? 50 : 80)} - highlightStyle={{ - backgroundColor: 'rgba(0, 188, 212, 0.1)', - color: '#bbb', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> )} @@ -1458,12 +1582,7 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={(session.lastUserMessage || '').slice(0, 80)} - highlightStyle={{ - backgroundColor: 'rgba(232, 169, 70, 0.15)', - color: '#e8a946', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> )} @@ -1474,17 +1593,37 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={(session.lastUserMessage || '').slice(0, 40)} - highlightStyle={{ - backgroundColor: 'rgba(232, 169, 70, 0.15)', - color: '#e8a946', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> )}
)} + {/* Matched-prompt snippet (main-side deep search) — shown when the + match isn't already visible in the first/last lines above */} + {(() => { + const m = searchSnippets[session.sessionId]; + if (!m || !isSearchingSessions) return null; + const words = sessionSearchValue.split(/\s+/).filter(Boolean); + // Stale guard: snippet must still match the current query + if (!words.some((w) => m.snippet.toLowerCase().includes(w.toLowerCase()))) return null; + const dupFirst = m.promptIndex === 0 && (sessionDisplayMode === 'first' || sessionDisplayMode === 'both'); + const dupLast = m.isLastPrompt && (sessionDisplayMode === 'last' || sessionDisplayMode === 'both'); + if (dupFirst || dupLast) return null; + return ( +
+ + ⌕ #{m.promptIndex + 1}{' '} + + +
+ ); + })()} {/* Line 3: Last assistant response */} {assistantResponses[session.sessionId] && (
@@ -1493,21 +1632,52 @@ function SwitcherApp() { searchWords={sessionSearchValue.split(/\s+/).filter(Boolean)} autoEscape textToHighlight={assistantResponses[session.sessionId].slice(0, 80)} - highlightStyle={{ - backgroundColor: 'rgba(139, 184, 208, 0.15)', - color: '#A8CDE0', - padding: '0 2px', - borderRadius: '2px', - }} + highlightStyle={SEARCH_HIGHLIGHT_STYLE} />
)} - )) - )} +
+ ))} + {!isSearchingSessions && !minorsExpanded && minorSessions.length > 0 && ( +
setMinorsExpanded(true)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setMinorsExpanded(true); + } + }} + style={{ padding: '6px 10px 8px 24px', color: '#777', fontSize: '12px', cursor: 'pointer' }} + > + ▸ {minorSessions.length} minor sessions (≤2 msgs, untitled) +
+ )} + )} + {/* Always-visible fold bar (outside the scroll area) while minors + are expanded — collapsing never depends on scroll position. */} + {!isSearchingSessions && minorsExpanded && minorSessions.length > 0 && ( +
{ setMinorsExpanded(false); setSelectedSessionIndex(0); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setMinorsExpanded(false); + setSelectedSessionIndex(0); + } + }} + style={MINOR_FOLD_BAR_STYLE} + > + ▾ {minorSessions.length} minor sessions shown — click to fold +
+ )} ) : (