From 1ef13eaf4416403bf66af94a79df13700c918759 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 12 Jul 2026 17:14:12 +0800 Subject: [PATCH 01/10] feat(sessions): full-prompt search, snippets, junk fold (Batch 1) - Search ALL sessions x ALL user prompts main-side (fixes #131); matches beyond the loaded 100 append with lazy enrichment - Matched-prompt snippet line (prompt #N + context) when the hit isn't visible in the row's first/last lines - Unified high-contrast amber search highlight (9 sites) - Fold minor sessions (<=2 msgs, untitled, no PR, closed) into an expandable row - New pure module session-search.ts + 10 unit tests (45 total) - Plan doc: docs/session-finding-plan-zh-tw.md Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 ++ README.md | 2 + docs/session-finding-plan-zh-tw.md | 149 +++++++++++++++++++ package.json | 2 +- src/claude-session-utility.ts | 65 +++++++-- src/electron-api.d.ts | 8 +- src/session-search.test.ts | 80 ++++++++++ src/session-search.ts | 73 ++++++++++ src/switcher-ui.tsx | 226 ++++++++++++++++++++--------- 9 files changed, 534 insertions(+), 80 deletions(-) create mode 100644 docs/session-finding-plan-zh-tw.md create mode 100644 src/session-search.test.ts create mode 100644 src/session-search.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1371ed6..53e67ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.83 + +- Feat: session finding Batch 1 — "search & noise" (plan: `docs/session-finding-plan-zh-tw.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 + ## 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-zh-tw.md b/docs/session-finding-plan-zh-tw.md new file mode 100644 index 0000000..2f400c1 --- /dev/null +++ b/docs/session-finding-plan-zh-tw.md @@ -0,0 +1,149 @@ +# Session 尋找體驗改善計畫(search / browse / pins / preview) + +> **狀態:已定案、待實作**(2026-07-12 brainstorm 定稿;Batch 1 尚未開工)。 +> 本文件是跨 session / 跨 model 的實作依據:所有「決策」都已跟使用者逐點確認, +> 不要重新開放已定案的選項;實作細節(§4–§6)則可依實況調整。 +> 相關文件/issue:`docs/pin-feature-handoff.md`(pin 前期研究,其 §4 [FACT] 全部仍有效)、 +> issue #106(session list perf)、#66(detail view 構想)、#105(real-time preview)。 + +## 1. 問題定義 + +使用者找 session 的痛點(原話重點): +1. 搜尋只涵蓋 1st user message / final AI message / final user message / branch / custom title / AI title / PR link,無全文搜尋 → 命中率低。使用者習慣為非一次性 session 手動加 custom title 來補償。 +2. match 落在 UI 截斷區時,highlight 根本看不到(有 match 但不知為何 match)。 +3. highlight 顏色對比不足(淡灰底疊在 orange/green 文字上幾乎隱形)。 +4. 對照 Notion 的經驗:**(a) 驗證候選超便宜**(點一下右欄立刻看到內容,錯了就下一個)、**(b) noise 少**。CodeV 驗證一個候選 = 開 terminal → resume → 等載入,成本高;且列表混著大量一次性 junk session(如 `!command claude auth status` 的 1-msg session)。 + +分析框架 —— 找 session 有三條互補路徑 + 一個放大器: +- **A. 記得關鍵字** → 搜尋涵蓋面(資料層) +- **B. 搜尋結果可信可讀** → match 呈現(呈現層) +- **C. 認得樣子** → 肉眼瀏覽(signal/noise) +- **D. 重要的常駐** → pins(不用找) +- **放大器:快速 verify(preview)** —— 讓 A–D 每項價值翻倍 + +## 2. 實測數據(2026-07-12,使用者機器) + +| 項目 | 數值 | 含義 | +|---|---|---| +| Transcripts | personal 868MB / 549 檔 + work 26MB / 39 檔 ≈ **894MB / 588 檔** | SQLite FTS5 或 on-demand `rg` 都可行,不需 Rust/外部 engine | +| `history.jsonl` | 15,324 行 prompt、**414 unique sessions**、6.7MB(work 帳號 142 行/50KB) | 每行 = 一個完整 user prompt(`display` 欄位,實測最長 9,224 字,**未截斷**)| +| Transcript 保留 | 最舊 2026-04-09(約 3 個月) | Claude Code `cleanupPeriodDays` 會清舊 transcript;**FTS index 建好後兼作永久文字備份**(過期 session 可讀不可 resume)| +| 現行搜尋範圍 | **實際只搜已載入的 ~100 個**:搜尋是 renderer 的 `filterSessionsLocally`(`switcher-ui.tsx:384`)client-side filter;main-side `searchClaudeSessions`(pool 500)IPC/preload 全接好但**無人呼叫 = dead code**(issue #131)| 以 414 sessions 計,**~314 個 session 今天完全搜不到**(也不會顯示)→ A1 從加分升級為必要;cache 本存全量(`claude-session-utility.ts:178`),放寬零成本 | + +參考專案調查結論:**沒有人靠黑科技**。claude-code-history-viewer(Tauri+Rust)的搜尋只是 Web Worker 裡 client-side 全掃,真正價值是 conversation reader;c9watch 也是暴力掃 JSONL;raine/claude-history(Rust TUI)有 field-aware lexical + local embedding hybrid,但我們不需要到那程度。TencentDB-Agent-Memory 是純 local(SQLite+sqlite-vec)但解的是 agent 長期記憶,非人找 session,略過。 + +## 3. 已定案決策(與使用者逐點確認,勿重開) + +| # | 決策 | 理由 | +|---|---|---| +| 1 | **C2 compact/title-only mode:不做** | 使用者已習慣為非一次性 session 加 title,搜尋目標多為這類 | +| 2 | **A2' rg 過渡版:不做,直接做 A2 FTS5 完整版** | 使用者:要做就做完整版 | +| 3 | **A2+ file-path 反查:降為小加分**,做 A2 時順手才做 | | +| 4 | **A3 semantic/vector、E ask-AI、Tencent memory:park** | FTS+preview 上線後再評估剩餘痛點 | +| 5 | **C4 preview 提前**(使用者:有 C4 是否還需 B1?→ 見 §5.1;B1 獨立項取消,併入 A1 與 A2)| | +| 6 | **B2 highlight 顏色:立刻做**(純 CSS)| | +| 7 | **C1 junk 摺疊:做**,利用「無 custom title + 極少 msgs」當 signal | 對應 Notion noise 少的優勢 | +| 8 | **D1 pin UI:做**(spec 見 §4.4);**D3 in-session `/pin`:Batch 3**,走 custom slash command、接受一次 LLM turn | 使用者原勉強接受 `!` 路徑但不喜歡沒自動完成;slash command 在 Claude Code 內建 autocomplete,且 `!` 路徑的「不經 LLM」賣點已被實測推翻(見 §7)| +| 9 | AI 自動 summary/grouping:不做批次版;改 **A4-lite**:preview 內「Generate title」按鈕(haiku,寫入 custom title,貼合手動 title 習慣)| 使用者對 AI title 品質存疑,heuristic 先行 | + +**否決記錄(留檔備查,含重開條件):** +- **C2 compact/title-only mode**:原構想 = Notion cmd+P 風格,有 title 的 session 只顯示一行 title、untitled 才展開三行,提升肉眼掃視密度。砍因:使用者搜尋目標多為已加 title 的非一次性 session,C1(去噪)+ C4(快速驗證)已覆蓋掃視需求。重開條件:Batch 1/2 上線後肉眼瀏覽仍吃力。 +- **A2' rg deep-search 過渡版**:原構想 = 按 Enter 才 shell out 到 ripgrep 掃 894MB transcripts(2–3 天可上線,零索引維護),當 FTS 前的需求驗證。砍因:使用者拍板直接做 FTS5 完整版,不花過渡功。殘值:`rg` 一次性掃描仍是**索引除錯工具**(驗證 FTS 增量索引有沒有漏資料),實作 A2 時可當 debug 手段,不做成產品功能。 +- **A3 semantic/vector、E ask-AI、Tencent memory**:park 而非砍——重開條件:FTS + preview 上線後「憑概念找但想不起關鍵字」仍是常見失敗模式。 + +## 4. Batch 1 —— 快贏(每項 S effort) + +### 4.1 B2:highlight 對比修正(hours) +現況:淡灰底 box。改成 amber/yellow 底 + 深色字(或 theme accent + bold),確保在 orange(1st msg)/green(title)/白色文字上都可讀。純 CSS,`switcher-ui.tsx` 的 highlight 渲染處。 + +### 4.2 A1+B1 合併:全 session × 全 user-prompt 搜尋 + match snippet(修 issue #131) +- **現況(issue #131)**:搜尋 = renderer `filterSessionsLocally`(`switcher-ui.tsx:384-393`)只 filter 已載入的 ~100 個;main-side `searchClaudeSessions`(`claude-session-utility.ts:186`,pool 500、上限 50)是 IPC/preload 全接好但無人呼叫的 **dead code**。⇒ ~314/414 個 session 今天完全搜不到。 +- **為何跟 snippet 合併**:全 prompt 搜尋的 match 多半落在「中段 prompt」——UI 本來就不顯示 → 沒有 snippet 等於白搜。兩者是一個 feature。 +- 資料:`history.jsonl` 每行一個 prompt;現在 `SessionAccum` 只留 first/last、中間丟棄(`claude-session-utility.ts:115-150`)。改法:掃描時另建 **main-process 模組級** `Map`(全部 prompts,~+7MB RAM)。 +- **設計:雙路 union**(因為 enrichment 欄位只存在 renderer、且只有已載入的 ~100 個有): + 1. main-side search IPC(重寫或取代 dead 的 `searchClaudeSessions`):搜**全部** sessions 的 project name + **全部 prompts**,回傳 matched sessions + `matchedSnippet`(match 為中心前後 ~40 字)+ `matchedField` badge(prompt#N / project / …)。 + 2. renderer 保留 `filterSessionsLocally`(cover branch / PR / AI response / custom title 等 enriched 欄位)。 + 3. 兩路 union;main-side 命中但不在已載入 100 內的 session **append 進列表 + lazy enrichment**。 +- **重要:不要把全部 prompts 塞進 IPC 回傳的 session 物件**(每次傳 7MB 會炸掉效能)。 +- Renderer:match 不在可見欄位時,該 row 的一行換成 snippet 顯示。 +- 效能:6.7MB in-memory string scan ≈ 5–20ms/query,加 debounce 無感;**不影響 list update 路徑**(那是 #106 的 IPC/process-scan/enrichment 成本,正交)。 + +### 4.3 C1:junk session 摺疊 +- 判準(全部符合才摺):`messageCount ≤ 2` **且** 無 custom title **且** 無 PR link **且** 非 active。 +- UI:摺成一行灰色「· N minor sessions」(可展開);另提供 row 右鍵「Hide session」手動隱藏(進 §4.4 同一個 store 的 `hidden` 清單)。 +- 保守優先:誤摺(展開就看得到)好過誤藏。 + +### 4.4 D1:Pin ★ + Pinned section +前期研究見 `docs/pin-feature-handoff.md`(其 §5 [REC] 是本設計的底,以下含 multi-account 時代的偏離)。 + +**UI spec(已向使用者提案):** +- Session row hover 時右側出現 📌 按鈕,click toggle;已 pin 的 row 顯示常駐小 ★。 +- Sessions list 頂部:collapsible「📌 Pinned (N)」section,預設展開;row 完整重用既有 session row(status dot / badge / PR / title 全保留)。 +- Pinned session **同時**仍出現在時間軸原位(帶 ★)——section 是捷徑不是搬家(Notion favorites 同樣兩處都在)。 +- 搜尋時:pinned section 隱藏,結果統一列出(match 到的 pinned row 帶 ★)。 +- Unpin:pinned row hover-`x`(recent projects 已有同 pattern)或再點 📌。 +- Empty state 提示一行。 +- v1 排序:pinnedAt desc;群組(named groups)是 v2(store schema 先留 `group?` 欄位)。 + +**Store(偏離 handoff [REC] 的理由:multi-account 時代 + 要放 hidden 清單):** +- 單一檔 `~/.config/codev/session-marks.json`(跨帳號一份;`~/.config/codev/` 已是 accounts registry 所在): + ```json + { "pins": { "": { "pinnedAt": "…", "cwd": "…", "accountLabel": "…", "group": null } }, + "hidden": ["", "…"] } + ``` +- `fs.watch` 同 status-files pattern(`session-status-hooks.ts` 是範本)。 + +**sessionId 穩定性(2026-07-12 已驗證,設計因此大幅簡化):** +- [FACT] Claude Code 2.1.207:`--resume` / `--continue` **預設沿用原 sessionId、續寫同一個 transcript 檔**;產生新 id 是 opt-in 的 `--fork-session`(help 原文:「When resuming, create a new session ID **instead of reusing the original**」)。使用者日常觀察一致(同一 row 的 msgs 數跨 resume 累積)。 +- ⇒ **pin 用 sessionId 當 key 就夠了**,一般 resume 不需要任何遷移機制。 +- 邊界情況(v1 一律不處理,遇到就手動 re-pin):明確 `--fork-session`、cross-account copy-fork(issue #128)。 + +## 5. Batch 2 —— 結構性投資 + +### 5.1 C4:Preview / 詳情(v1 card → v2 pane) +- 對應 issue #66 的「detail view」與 #105;解掉「要 resume 才能驗證」的根本瓶頸(Notion 右欄的對應物)。 +- **v1(card)**:點 row(或按 Space)展開 in-place detail card:custom/AI title、完整 first/last message、branch、PR、msgs、account、時間 + 最後 N 則訊息的文字摘要。 +- **v2(pane/reader)**:list 左 + read-only transcript reader 右:lazy-load 最後 N 則、markdown 渲染(AI Chat tab 已有 md renderer 可重用)、tool call 摺成 one-liner chips、**從搜尋進來自動 jump-to-match**(A2 上線後 match 定位由 FTS 提供)。 +- 「有 C4 還要 B1 嗎?」的定案:兩者服務不同步驟——B1 讓你**掃 list 就知道為何 match**(不用逐個打開),C4 讓你**深看單一候選**。但 B1 的獨立工作量已被吸收:prompt match 的 snippet 在 §4.2 做掉,transcript match 的 snippet 由 A2 的 `snippet()` 免費提供 → **不存在獨立的 B1 工項**。 +- 讀 transcript 注意:行內含 base64 圖片與大型 tool_result,只抽 text block 渲染;檔案可能很大(本 session 檔就數十 MB),要 tail-read + 分頁。 + +### 5.2 A2:FTS5 全文索引(完整版,不做 rg 過渡) +- Engine:**better-sqlite3(已是依賴)+ FTS5**。DB 放 `~/.config/codev/search-index.db`。 +- Schema 草案:`messages(session_id, account, project, role, ts, text)` + FTS5 virtual table(content 外部表或 contentless 皆可);另表 `files(path, mtime, bytes_indexed)` 記增量進度。 +- **增量索引**:transcript 是 append-only → 記 per-file byte offset,只 parse 新增部分;掃描時機:app focus / 定時 / session end。首次全量建置放 background(chunked,別卡 UI)。 +- **抽取規則 v1**:user + assistant 的 text block;**排除** thinking、tool_use/tool_result、base64。tool output 索引(對「哪個 session 碰過檔案 X」有用)留為 flag/v2,即 A2+ file-path 反查(從 tool_use 參數抽 file_path 建小倒排,順手才做)。 +- **CJK 陷阱與解法**:FTS5 trigram tokenizer 需 ≥3 字,「上限」這種 2 字詞會 miss → **索引與查詢時把 CJK 連續段預切成空白分隔的 bigram**(ASCII 詞保留原樣),用預設 unicode61 tokenizer。純 JS 預處理、無 native tokenizer 依賴、中英混合 query 皆可。 +- 排序:bm25 × recency;`snippet()` 直接供 B1/C4 用。 +- Multi-account:一顆 DB、`account` 欄;掃描來源用既有 `getScannableAccounts()`。 +- 重複內容:一般 resume 沿用同檔(§4.4),無跨檔重複;只有明確 `--fork-session` 產生祖先/後代重複 match,罕見,v1 不處理。 +- 過期 session(transcript 已被 cleanup 清掉):index 保留文字 → 結果標「expired」(可讀不可 resume)。 + +## 6. Batch 3 —— 看手感加 + +| 項 | 內容 | 備註 | +|---|---|---| +| D3 `/pin` | Custom slash command:利用 Claude Code 的 slash **autocomplete**(解使用者對 `!` 無補全的不滿);command 內跑 `codev pin`,sessionId 取自 **`CLAUDE_CODE_SESSION_ID` env var**([FACT],見 §7);接受一次 LLM turn(使用者已表態可接受)。可帶參數 `/pin as "…"`。UI pin 仍是 primary。 | +| B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` chips | | +| A4-lite | Preview 內「Generate title」按鈕(haiku、寫 custom title) | 不做批次 auto-summary | +| C3 chain collapse | **基本作廢**(2026-07-12):一般 resume 沿用同 sessionId(§4.4),不產生世代鏈;僅在 `--fork-session` / copy-fork 常用化時才有意義 | 留檔備查 | + +## 7. 關鍵技術事實(gotchas —— 實作前必讀) + +1. **In-session typed trigger 沒有乾淨路徑**([FACT],`pin-feature-handoff.md` §4 實機驗證):`/pin` 會觸發 LLM turn;**`!` bash-mode 也會**(輸出會送進 model——文件宣稱不會,實測會);`UserPromptSubmit` hook block(exit 2)不觸發 turn 但必顯示 blocked notice(`suppressOutput` 壓不掉)。⇒ 別再重試「無 turn 且無 notice」的方案;D3 的定位就是「接受 turn 換 autocomplete」。 +2. **`CLAUDE_CODE_SESSION_ID`** env var 存在於 session shell,值為 session UUID(不是網路上常誤傳的 `CLAUDE_SESSION_ID`)。 +3. **Custom title 存在 transcript 內**:`/rename` 寫入 `"type":"custom-title"` 行;CodeV 用 grep tail -1 讀(`claude-session-utility.ts:312,413,489`)。resume 沿用同檔(fact 4)→ title 自然延續;pin 以 sessionId 為 key 同樣自然延續。 +4. **Resume 語意(2.1.207 以 `--help` 驗證)**:`--resume` / `--continue` **預設沿用原 sessionId、續寫同一檔**;`--fork-session` 才產生新 id 新檔。⚠️ 舊版 Claude Code 曾預設 fork——網路舊文與舊經驗常仍這樣講,勿信(本計畫初稿就因此寫錯過一次)。 +5. **history.jsonl 每行 = 一個完整 user prompt**(`display` 未截斷,實測最長 9,224 字);一個 session 多行;目前 accumulator 只留 first/last。 +6. **cachedSessions 是 metadata 不是 transcript**:每 session 一筆小物件(id/project/first/last/timestamps/count/account),414 sessions << 1MB;5s TTL(`CACHE_TTL_MS`);transcripts(894MB)只在 enrichment 時對可見 ≤100 個 session 做 per-file tail read。**任何新功能都不要把大資料塞進 IPC 回傳的 session 物件。** +7. **Search pool 500**:超過後舊 session 無聲消失於搜尋;放寬 = 把 `searchClaudeSessions` 內的 `readClaudeSessions(500)` 改全量(cache 已全量,零成本)。 +8. **FTS5 trigram ≥3 字** → CJK 2 字詞 miss → 用 bigram 預切(§5.2)。 +9. **Transcript 行內有 base64 圖與巨大 tool_result** → 索引/渲染都只抽 text block。 +10. Multi-account 一律用 `getScannableAccounts()` 迭代;跨帳號的使用者層資料(pins/hidden/index)放 `~/.config/codev/`,單一份。 +11. Lint:repo 無 CI lint gate,舊檔非 prettier 格式 → **只格式化自己改的行**,新檔可全 prettier。 + +## 8. 開放問題(實作時再定) + +- Pinned section 與時間軸「兩處都顯示」是提案預設,使用者未最終拍板(反對再改成只在 section)。 +- `session-marks.json` 單檔 vs 沿用 handoff 的 `~/.claude/codev-status/pinned.json`:本文傾向前者(multi-account + hidden 清單),實作時可再議。 +- C4 v1 card 與 v2 pane 是否合併一次做:視當時 effort 感覺。 +- FTS 是否索引 thinking block:v1 不索引(體積/噪音),留 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..43f413a 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; @@ -103,6 +112,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 +128,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 +192,51 @@ 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 }; }; /** 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..82d80e7 --- /dev/null +++ b/src/session-search.test.ts @@ -0,0 +1,80 @@ +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..65290a5 --- /dev/null +++ b/src/session-search.ts @@ -0,0 +1,73 @@ +/** + * 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..50e292b 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1,16 +1,27 @@ 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; + // Global styles for the switcher UI (moved from index.css) const globalStyles = ` body { @@ -355,11 +366,16 @@ function SwitcherApp() { const [assistantResponses, setAssistantResponses] = useState>({}); const [terminalApps, setTerminalApps] = useState>({}); const [sessionStatuses, setSessionStatuses] = useState>({}); + const [searchSnippets, setSearchSnippets] = useState>({}); + const [minorsExpanded, setMinorsExpanded] = 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 +408,82 @@ 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) + const loaded = new Set(allSessionsRef.current.map((s: any) => s.sessionId)); + const appended = deepMatchesRef.current + .filter((s: any) => !loaded.has(s.sessionId)) + .slice(0, 30); + 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 && + 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 +496,7 @@ function SwitcherApp() { }); setAllSessions(newSessions); allSessionsRef.current = newSessions; - setSessions(sessionSearchValue.trim() ? filterSessionsLocally(newSessions, sessionSearchValue) : newSessions); + setSessions(sessionSearchValue.trim() ? applySearchFilter(newSessions, sessionSearchValue) : newSessions); // Step 2: Load last assistant responses for all sessions (first 100) window.electronAPI.loadLastAssistantResponses((result || []).slice(0, 100)).then((responses: Record) => { @@ -444,7 +536,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 +578,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 @@ -770,6 +862,7 @@ function SwitcherApp() { 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 +1278,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 +1287,7 @@ function SwitcherApp() { if (sessionSearchValue) { setSessionSearchValue(''); sessionSearchRef2.current = ''; + scheduleDeepSearch(''); setSessions(allSessions); } else { hideApp(); @@ -1200,7 +1295,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 +1311,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 +1324,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 +1354,18 @@ 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); }} + style={{ padding: '6px 10px 4px 24px', color: '#777', fontSize: '12px', cursor: 'pointer' }} + > + ▾ {minorSessions.length} minor sessions (≤2 msgs, untitled) +
+ )}
{ clearSessionSearchOnShowRef.current = true; @@ -1308,12 +1411,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 +1420,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 +1430,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 +1462,7 @@ function SwitcherApp() { ); @@ -1416,12 +1499,7 @@ function SwitcherApp() { ) : null; @@ -1443,12 +1521,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 +1531,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 +1542,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,20 +1581,24 @@ 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)} + style={{ padding: '6px 10px 8px 24px', color: '#777', fontSize: '12px', cursor: 'pointer' }} + > + ▸ {minorSessions.length} minor sessions (≤2 msgs, untitled) +
+ )} + )} ) : ( From c41de40cebf15aa8060809a3551070361c6330fe Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 12 Jul 2026 17:34:08 +0800 Subject: [PATCH 02/10] fix(sessions): address PR #132 review round 1 - Read search value via ref in fetchClaudeSessions (stale closure could clear the filtered list on window focus) [cubic P1] - Gate minor-session folding on first active-detection completion so a just-started session never folds at app start [cubic P2] - Un-cap deep-match enrichment (was 30) to match the uncapped append; both now bounded by the search result cap of 100 [CodeRabbit Major, resolved toward no-silent-caps] - Prettier: new files wholesale; changed lines only in claude-session-utility.ts / switcher-ui.tsx - Docs: plan status header reflects PR-1 in flight; D3 table row third cell [cubic P2 / CodeRabbit Minor] Co-Authored-By: Claude Fable 5 --- docs/session-finding-plan-zh-tw.md | 6 +++-- src/claude-session-utility.ts | 8 +++++-- src/session-search.test.ts | 38 ++++++++++++++++++++++-------- src/session-search.ts | 5 +++- src/switcher-ui.tsx | 23 +++++++++++++----- 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/docs/session-finding-plan-zh-tw.md b/docs/session-finding-plan-zh-tw.md index 2f400c1..4fa94ca 100644 --- a/docs/session-finding-plan-zh-tw.md +++ b/docs/session-finding-plan-zh-tw.md @@ -1,6 +1,8 @@ # Session 尋找體驗改善計畫(search / browse / pins / preview) -> **狀態:已定案、待實作**(2026-07-12 brainstorm 定稿;Batch 1 尚未開工)。 +> **狀態:已定案;Batch 1 進行中**(2026-07-12 brainstorm 定稿)。 +> PR-1「search & noise」(§4.1–§4.3:B2 highlight + A1/B1 全量搜尋 + C1 摺疊)= **PR #132**; +> PR-2「pins」(§4.4 D1,含 C1 的手動 hide)尚未開工;Batch 2/3 未開工。 > 本文件是跨 session / 跨 model 的實作依據:所有「決策」都已跟使用者逐點確認, > 不要重新開放已定案的選項;實作細節(§4–§6)則可依實況調整。 > 相關文件/issue:`docs/pin-feature-handoff.md`(pin 前期研究,其 §4 [FACT] 全部仍有效)、 @@ -122,7 +124,7 @@ | 項 | 內容 | 備註 | |---|---|---| -| D3 `/pin` | Custom slash command:利用 Claude Code 的 slash **autocomplete**(解使用者對 `!` 無補全的不滿);command 內跑 `codev pin`,sessionId 取自 **`CLAUDE_CODE_SESSION_ID` env var**([FACT],見 §7);接受一次 LLM turn(使用者已表態可接受)。可帶參數 `/pin as "…"`。UI pin 仍是 primary。 | +| D3 `/pin` | Custom slash command:利用 Claude Code 的 slash **autocomplete**(解使用者對 `!` 無補全的不滿);command 內跑 `codev pin`,sessionId 取自 **`CLAUDE_CODE_SESSION_ID` env var**([FACT],見 §7);接受一次 LLM turn(使用者已表態可接受)。可帶參數 `/pin as "…"` | UI pin 仍是 primary | | B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` chips | | | A4-lite | Preview 內「Generate title」按鈕(haiku、寫 custom title) | 不做批次 auto-summary | | C3 chain collapse | **基本作廢**(2026-07-12):一般 resume 沿用同 sessionId(§4.4),不產生世代鏈;僅在 `--fork-session` / copy-fork 常用化時才有意義 | 留檔備查 | diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 43f413a..dad07e6 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -212,7 +212,10 @@ export interface SessionSearchResult { * 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 = 100): SessionSearchResult => { +export const searchClaudeSessions = ( + query: string, + limit = 100, +): SessionSearchResult => { const words = query.toLowerCase().split(/\s+/).filter(Boolean); if (words.length === 0) return { sessions: [], snippets: {} }; @@ -223,7 +226,8 @@ export const searchClaudeSessions = (query: string, limit = 100): SessionSearchR for (const s of allSessions) { const sessionPrompts = promptsBySession.get(s.sessionId) || []; - const target = `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); + const target = + `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); if (!matchesAllWords(target, words)) continue; sessions.push(s); diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 82d80e7..533f9f4 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -9,14 +9,19 @@ import { describe('matchesAllWords', () => { it('requires every word (AND semantics), matching across the combined text', () => { - const target = 'codev multi account /effort 我問一下 sessions 上限'.toLowerCase(); + 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); + expect(matchesAllWords('原本的 sessions 數我有設上限', ['上限'])).toBe( + true, + ); + expect(matchesAllWords('原本的 sessions 數我有設上限', ['下限'])).toBe( + false, + ); }); }); @@ -28,7 +33,8 @@ describe('extractSnippet', () => { }); 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 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); @@ -63,15 +69,27 @@ describe('findPromptMatch', () => { 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); + 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); + 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)', () => { diff --git a/src/session-search.ts b/src/session-search.ts index 65290a5..23df714 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -49,7 +49,10 @@ export const findPromptMatch = ( for (const w of wordsLower) { const idx = lower.indexOf(w); if (idx !== -1) { - return { promptIndex: i, snippet: extractSnippet(prompts[i], idx, w.length) }; + return { + promptIndex: i, + snippet: extractSnippet(prompts[i], idx, w.length), + }; } } } diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 50e292b..3703d5e 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -368,6 +368,9 @@ function SwitcherApp() { 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([]); @@ -424,7 +427,9 @@ function SwitcherApp() { })); if (extra.length === 0) return base; const merged = [...base, ...extra]; - merged.sort((a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0)); + merged.sort( + (a: any, b: any) => (b.lastTimestamp || 0) - (a.lastTimestamp || 0), + ); return merged; }; @@ -444,11 +449,11 @@ function SwitcherApp() { deepMatchesRef.current = res?.sessions || []; setSearchSnippets(res?.snippets || {}); setSessions(applySearchFilter(allSessionsRef.current, query)); - // Lazy-enrich deep matches that aren't in the loaded list (bounded) + // 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)) - .slice(0, 30); + .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) { @@ -476,7 +481,9 @@ function SwitcherApp() { const majorSessions: any[] = []; const minorSessions: any[] = []; for (const s of sessions) { - const minor = !isSearchingSessions && + const minor = + !isSearchingSessions && + activeDetectionReady && isMinorSession(s, !!customTitles[s.sessionId], !!prLinks[s.sessionId]); (minor ? minorSessions : majorSessions).push(s); } @@ -496,7 +503,10 @@ function SwitcherApp() { }); setAllSessions(newSessions); allSessionsRef.current = newSessions; - setSessions(sessionSearchValue.trim() ? applySearchFilter(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) => { @@ -513,6 +523,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 From 6fa6316e92cfeddf48a91ac8b9803cc61abc949c Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 12 Jul 2026 23:40:18 +0800 Subject: [PATCH 03/10] docs: English plan doc as the committed version - Add docs/session-finding-plan.md (full English translation, same status/decisions/gotchas) - Untrack the zh-TW copy (stays as a local working copy; note added pointing at the English canonical version) - CHANGELOG plan reference updated Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/session-finding-plan-zh-tw.md | 151 ----------------- docs/session-finding-plan.md | 256 +++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 152 deletions(-) delete mode 100644 docs/session-finding-plan-zh-tw.md create mode 100644 docs/session-finding-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e67ac..e2523b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.0.83 -- Feat: session finding Batch 1 — "search & noise" (plan: `docs/session-finding-plan-zh-tw.md`) +- 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) diff --git a/docs/session-finding-plan-zh-tw.md b/docs/session-finding-plan-zh-tw.md deleted file mode 100644 index 4fa94ca..0000000 --- a/docs/session-finding-plan-zh-tw.md +++ /dev/null @@ -1,151 +0,0 @@ -# Session 尋找體驗改善計畫(search / browse / pins / preview) - -> **狀態:已定案;Batch 1 進行中**(2026-07-12 brainstorm 定稿)。 -> PR-1「search & noise」(§4.1–§4.3:B2 highlight + A1/B1 全量搜尋 + C1 摺疊)= **PR #132**; -> PR-2「pins」(§4.4 D1,含 C1 的手動 hide)尚未開工;Batch 2/3 未開工。 -> 本文件是跨 session / 跨 model 的實作依據:所有「決策」都已跟使用者逐點確認, -> 不要重新開放已定案的選項;實作細節(§4–§6)則可依實況調整。 -> 相關文件/issue:`docs/pin-feature-handoff.md`(pin 前期研究,其 §4 [FACT] 全部仍有效)、 -> issue #106(session list perf)、#66(detail view 構想)、#105(real-time preview)。 - -## 1. 問題定義 - -使用者找 session 的痛點(原話重點): -1. 搜尋只涵蓋 1st user message / final AI message / final user message / branch / custom title / AI title / PR link,無全文搜尋 → 命中率低。使用者習慣為非一次性 session 手動加 custom title 來補償。 -2. match 落在 UI 截斷區時,highlight 根本看不到(有 match 但不知為何 match)。 -3. highlight 顏色對比不足(淡灰底疊在 orange/green 文字上幾乎隱形)。 -4. 對照 Notion 的經驗:**(a) 驗證候選超便宜**(點一下右欄立刻看到內容,錯了就下一個)、**(b) noise 少**。CodeV 驗證一個候選 = 開 terminal → resume → 等載入,成本高;且列表混著大量一次性 junk session(如 `!command claude auth status` 的 1-msg session)。 - -分析框架 —— 找 session 有三條互補路徑 + 一個放大器: -- **A. 記得關鍵字** → 搜尋涵蓋面(資料層) -- **B. 搜尋結果可信可讀** → match 呈現(呈現層) -- **C. 認得樣子** → 肉眼瀏覽(signal/noise) -- **D. 重要的常駐** → pins(不用找) -- **放大器:快速 verify(preview)** —— 讓 A–D 每項價值翻倍 - -## 2. 實測數據(2026-07-12,使用者機器) - -| 項目 | 數值 | 含義 | -|---|---|---| -| Transcripts | personal 868MB / 549 檔 + work 26MB / 39 檔 ≈ **894MB / 588 檔** | SQLite FTS5 或 on-demand `rg` 都可行,不需 Rust/外部 engine | -| `history.jsonl` | 15,324 行 prompt、**414 unique sessions**、6.7MB(work 帳號 142 行/50KB) | 每行 = 一個完整 user prompt(`display` 欄位,實測最長 9,224 字,**未截斷**)| -| Transcript 保留 | 最舊 2026-04-09(約 3 個月) | Claude Code `cleanupPeriodDays` 會清舊 transcript;**FTS index 建好後兼作永久文字備份**(過期 session 可讀不可 resume)| -| 現行搜尋範圍 | **實際只搜已載入的 ~100 個**:搜尋是 renderer 的 `filterSessionsLocally`(`switcher-ui.tsx:384`)client-side filter;main-side `searchClaudeSessions`(pool 500)IPC/preload 全接好但**無人呼叫 = dead code**(issue #131)| 以 414 sessions 計,**~314 個 session 今天完全搜不到**(也不會顯示)→ A1 從加分升級為必要;cache 本存全量(`claude-session-utility.ts:178`),放寬零成本 | - -參考專案調查結論:**沒有人靠黑科技**。claude-code-history-viewer(Tauri+Rust)的搜尋只是 Web Worker 裡 client-side 全掃,真正價值是 conversation reader;c9watch 也是暴力掃 JSONL;raine/claude-history(Rust TUI)有 field-aware lexical + local embedding hybrid,但我們不需要到那程度。TencentDB-Agent-Memory 是純 local(SQLite+sqlite-vec)但解的是 agent 長期記憶,非人找 session,略過。 - -## 3. 已定案決策(與使用者逐點確認,勿重開) - -| # | 決策 | 理由 | -|---|---|---| -| 1 | **C2 compact/title-only mode:不做** | 使用者已習慣為非一次性 session 加 title,搜尋目標多為這類 | -| 2 | **A2' rg 過渡版:不做,直接做 A2 FTS5 完整版** | 使用者:要做就做完整版 | -| 3 | **A2+ file-path 反查:降為小加分**,做 A2 時順手才做 | | -| 4 | **A3 semantic/vector、E ask-AI、Tencent memory:park** | FTS+preview 上線後再評估剩餘痛點 | -| 5 | **C4 preview 提前**(使用者:有 C4 是否還需 B1?→ 見 §5.1;B1 獨立項取消,併入 A1 與 A2)| | -| 6 | **B2 highlight 顏色:立刻做**(純 CSS)| | -| 7 | **C1 junk 摺疊:做**,利用「無 custom title + 極少 msgs」當 signal | 對應 Notion noise 少的優勢 | -| 8 | **D1 pin UI:做**(spec 見 §4.4);**D3 in-session `/pin`:Batch 3**,走 custom slash command、接受一次 LLM turn | 使用者原勉強接受 `!` 路徑但不喜歡沒自動完成;slash command 在 Claude Code 內建 autocomplete,且 `!` 路徑的「不經 LLM」賣點已被實測推翻(見 §7)| -| 9 | AI 自動 summary/grouping:不做批次版;改 **A4-lite**:preview 內「Generate title」按鈕(haiku,寫入 custom title,貼合手動 title 習慣)| 使用者對 AI title 品質存疑,heuristic 先行 | - -**否決記錄(留檔備查,含重開條件):** -- **C2 compact/title-only mode**:原構想 = Notion cmd+P 風格,有 title 的 session 只顯示一行 title、untitled 才展開三行,提升肉眼掃視密度。砍因:使用者搜尋目標多為已加 title 的非一次性 session,C1(去噪)+ C4(快速驗證)已覆蓋掃視需求。重開條件:Batch 1/2 上線後肉眼瀏覽仍吃力。 -- **A2' rg deep-search 過渡版**:原構想 = 按 Enter 才 shell out 到 ripgrep 掃 894MB transcripts(2–3 天可上線,零索引維護),當 FTS 前的需求驗證。砍因:使用者拍板直接做 FTS5 完整版,不花過渡功。殘值:`rg` 一次性掃描仍是**索引除錯工具**(驗證 FTS 增量索引有沒有漏資料),實作 A2 時可當 debug 手段,不做成產品功能。 -- **A3 semantic/vector、E ask-AI、Tencent memory**:park 而非砍——重開條件:FTS + preview 上線後「憑概念找但想不起關鍵字」仍是常見失敗模式。 - -## 4. Batch 1 —— 快贏(每項 S effort) - -### 4.1 B2:highlight 對比修正(hours) -現況:淡灰底 box。改成 amber/yellow 底 + 深色字(或 theme accent + bold),確保在 orange(1st msg)/green(title)/白色文字上都可讀。純 CSS,`switcher-ui.tsx` 的 highlight 渲染處。 - -### 4.2 A1+B1 合併:全 session × 全 user-prompt 搜尋 + match snippet(修 issue #131) -- **現況(issue #131)**:搜尋 = renderer `filterSessionsLocally`(`switcher-ui.tsx:384-393`)只 filter 已載入的 ~100 個;main-side `searchClaudeSessions`(`claude-session-utility.ts:186`,pool 500、上限 50)是 IPC/preload 全接好但無人呼叫的 **dead code**。⇒ ~314/414 個 session 今天完全搜不到。 -- **為何跟 snippet 合併**:全 prompt 搜尋的 match 多半落在「中段 prompt」——UI 本來就不顯示 → 沒有 snippet 等於白搜。兩者是一個 feature。 -- 資料:`history.jsonl` 每行一個 prompt;現在 `SessionAccum` 只留 first/last、中間丟棄(`claude-session-utility.ts:115-150`)。改法:掃描時另建 **main-process 模組級** `Map`(全部 prompts,~+7MB RAM)。 -- **設計:雙路 union**(因為 enrichment 欄位只存在 renderer、且只有已載入的 ~100 個有): - 1. main-side search IPC(重寫或取代 dead 的 `searchClaudeSessions`):搜**全部** sessions 的 project name + **全部 prompts**,回傳 matched sessions + `matchedSnippet`(match 為中心前後 ~40 字)+ `matchedField` badge(prompt#N / project / …)。 - 2. renderer 保留 `filterSessionsLocally`(cover branch / PR / AI response / custom title 等 enriched 欄位)。 - 3. 兩路 union;main-side 命中但不在已載入 100 內的 session **append 進列表 + lazy enrichment**。 -- **重要:不要把全部 prompts 塞進 IPC 回傳的 session 物件**(每次傳 7MB 會炸掉效能)。 -- Renderer:match 不在可見欄位時,該 row 的一行換成 snippet 顯示。 -- 效能:6.7MB in-memory string scan ≈ 5–20ms/query,加 debounce 無感;**不影響 list update 路徑**(那是 #106 的 IPC/process-scan/enrichment 成本,正交)。 - -### 4.3 C1:junk session 摺疊 -- 判準(全部符合才摺):`messageCount ≤ 2` **且** 無 custom title **且** 無 PR link **且** 非 active。 -- UI:摺成一行灰色「· N minor sessions」(可展開);另提供 row 右鍵「Hide session」手動隱藏(進 §4.4 同一個 store 的 `hidden` 清單)。 -- 保守優先:誤摺(展開就看得到)好過誤藏。 - -### 4.4 D1:Pin ★ + Pinned section -前期研究見 `docs/pin-feature-handoff.md`(其 §5 [REC] 是本設計的底,以下含 multi-account 時代的偏離)。 - -**UI spec(已向使用者提案):** -- Session row hover 時右側出現 📌 按鈕,click toggle;已 pin 的 row 顯示常駐小 ★。 -- Sessions list 頂部:collapsible「📌 Pinned (N)」section,預設展開;row 完整重用既有 session row(status dot / badge / PR / title 全保留)。 -- Pinned session **同時**仍出現在時間軸原位(帶 ★)——section 是捷徑不是搬家(Notion favorites 同樣兩處都在)。 -- 搜尋時:pinned section 隱藏,結果統一列出(match 到的 pinned row 帶 ★)。 -- Unpin:pinned row hover-`x`(recent projects 已有同 pattern)或再點 📌。 -- Empty state 提示一行。 -- v1 排序:pinnedAt desc;群組(named groups)是 v2(store schema 先留 `group?` 欄位)。 - -**Store(偏離 handoff [REC] 的理由:multi-account 時代 + 要放 hidden 清單):** -- 單一檔 `~/.config/codev/session-marks.json`(跨帳號一份;`~/.config/codev/` 已是 accounts registry 所在): - ```json - { "pins": { "": { "pinnedAt": "…", "cwd": "…", "accountLabel": "…", "group": null } }, - "hidden": ["", "…"] } - ``` -- `fs.watch` 同 status-files pattern(`session-status-hooks.ts` 是範本)。 - -**sessionId 穩定性(2026-07-12 已驗證,設計因此大幅簡化):** -- [FACT] Claude Code 2.1.207:`--resume` / `--continue` **預設沿用原 sessionId、續寫同一個 transcript 檔**;產生新 id 是 opt-in 的 `--fork-session`(help 原文:「When resuming, create a new session ID **instead of reusing the original**」)。使用者日常觀察一致(同一 row 的 msgs 數跨 resume 累積)。 -- ⇒ **pin 用 sessionId 當 key 就夠了**,一般 resume 不需要任何遷移機制。 -- 邊界情況(v1 一律不處理,遇到就手動 re-pin):明確 `--fork-session`、cross-account copy-fork(issue #128)。 - -## 5. Batch 2 —— 結構性投資 - -### 5.1 C4:Preview / 詳情(v1 card → v2 pane) -- 對應 issue #66 的「detail view」與 #105;解掉「要 resume 才能驗證」的根本瓶頸(Notion 右欄的對應物)。 -- **v1(card)**:點 row(或按 Space)展開 in-place detail card:custom/AI title、完整 first/last message、branch、PR、msgs、account、時間 + 最後 N 則訊息的文字摘要。 -- **v2(pane/reader)**:list 左 + read-only transcript reader 右:lazy-load 最後 N 則、markdown 渲染(AI Chat tab 已有 md renderer 可重用)、tool call 摺成 one-liner chips、**從搜尋進來自動 jump-to-match**(A2 上線後 match 定位由 FTS 提供)。 -- 「有 C4 還要 B1 嗎?」的定案:兩者服務不同步驟——B1 讓你**掃 list 就知道為何 match**(不用逐個打開),C4 讓你**深看單一候選**。但 B1 的獨立工作量已被吸收:prompt match 的 snippet 在 §4.2 做掉,transcript match 的 snippet 由 A2 的 `snippet()` 免費提供 → **不存在獨立的 B1 工項**。 -- 讀 transcript 注意:行內含 base64 圖片與大型 tool_result,只抽 text block 渲染;檔案可能很大(本 session 檔就數十 MB),要 tail-read + 分頁。 - -### 5.2 A2:FTS5 全文索引(完整版,不做 rg 過渡) -- Engine:**better-sqlite3(已是依賴)+ FTS5**。DB 放 `~/.config/codev/search-index.db`。 -- Schema 草案:`messages(session_id, account, project, role, ts, text)` + FTS5 virtual table(content 外部表或 contentless 皆可);另表 `files(path, mtime, bytes_indexed)` 記增量進度。 -- **增量索引**:transcript 是 append-only → 記 per-file byte offset,只 parse 新增部分;掃描時機:app focus / 定時 / session end。首次全量建置放 background(chunked,別卡 UI)。 -- **抽取規則 v1**:user + assistant 的 text block;**排除** thinking、tool_use/tool_result、base64。tool output 索引(對「哪個 session 碰過檔案 X」有用)留為 flag/v2,即 A2+ file-path 反查(從 tool_use 參數抽 file_path 建小倒排,順手才做)。 -- **CJK 陷阱與解法**:FTS5 trigram tokenizer 需 ≥3 字,「上限」這種 2 字詞會 miss → **索引與查詢時把 CJK 連續段預切成空白分隔的 bigram**(ASCII 詞保留原樣),用預設 unicode61 tokenizer。純 JS 預處理、無 native tokenizer 依賴、中英混合 query 皆可。 -- 排序:bm25 × recency;`snippet()` 直接供 B1/C4 用。 -- Multi-account:一顆 DB、`account` 欄;掃描來源用既有 `getScannableAccounts()`。 -- 重複內容:一般 resume 沿用同檔(§4.4),無跨檔重複;只有明確 `--fork-session` 產生祖先/後代重複 match,罕見,v1 不處理。 -- 過期 session(transcript 已被 cleanup 清掉):index 保留文字 → 結果標「expired」(可讀不可 resume)。 - -## 6. Batch 3 —— 看手感加 - -| 項 | 內容 | 備註 | -|---|---|---| -| D3 `/pin` | Custom slash command:利用 Claude Code 的 slash **autocomplete**(解使用者對 `!` 無補全的不滿);command 內跑 `codev pin`,sessionId 取自 **`CLAUDE_CODE_SESSION_ID` env var**([FACT],見 §7);接受一次 LLM turn(使用者已表態可接受)。可帶參數 `/pin as "…"` | UI pin 仍是 primary | -| B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` chips | | -| A4-lite | Preview 內「Generate title」按鈕(haiku、寫 custom title) | 不做批次 auto-summary | -| C3 chain collapse | **基本作廢**(2026-07-12):一般 resume 沿用同 sessionId(§4.4),不產生世代鏈;僅在 `--fork-session` / copy-fork 常用化時才有意義 | 留檔備查 | - -## 7. 關鍵技術事實(gotchas —— 實作前必讀) - -1. **In-session typed trigger 沒有乾淨路徑**([FACT],`pin-feature-handoff.md` §4 實機驗證):`/pin` 會觸發 LLM turn;**`!` bash-mode 也會**(輸出會送進 model——文件宣稱不會,實測會);`UserPromptSubmit` hook block(exit 2)不觸發 turn 但必顯示 blocked notice(`suppressOutput` 壓不掉)。⇒ 別再重試「無 turn 且無 notice」的方案;D3 的定位就是「接受 turn 換 autocomplete」。 -2. **`CLAUDE_CODE_SESSION_ID`** env var 存在於 session shell,值為 session UUID(不是網路上常誤傳的 `CLAUDE_SESSION_ID`)。 -3. **Custom title 存在 transcript 內**:`/rename` 寫入 `"type":"custom-title"` 行;CodeV 用 grep tail -1 讀(`claude-session-utility.ts:312,413,489`)。resume 沿用同檔(fact 4)→ title 自然延續;pin 以 sessionId 為 key 同樣自然延續。 -4. **Resume 語意(2.1.207 以 `--help` 驗證)**:`--resume` / `--continue` **預設沿用原 sessionId、續寫同一檔**;`--fork-session` 才產生新 id 新檔。⚠️ 舊版 Claude Code 曾預設 fork——網路舊文與舊經驗常仍這樣講,勿信(本計畫初稿就因此寫錯過一次)。 -5. **history.jsonl 每行 = 一個完整 user prompt**(`display` 未截斷,實測最長 9,224 字);一個 session 多行;目前 accumulator 只留 first/last。 -6. **cachedSessions 是 metadata 不是 transcript**:每 session 一筆小物件(id/project/first/last/timestamps/count/account),414 sessions << 1MB;5s TTL(`CACHE_TTL_MS`);transcripts(894MB)只在 enrichment 時對可見 ≤100 個 session 做 per-file tail read。**任何新功能都不要把大資料塞進 IPC 回傳的 session 物件。** -7. **Search pool 500**:超過後舊 session 無聲消失於搜尋;放寬 = 把 `searchClaudeSessions` 內的 `readClaudeSessions(500)` 改全量(cache 已全量,零成本)。 -8. **FTS5 trigram ≥3 字** → CJK 2 字詞 miss → 用 bigram 預切(§5.2)。 -9. **Transcript 行內有 base64 圖與巨大 tool_result** → 索引/渲染都只抽 text block。 -10. Multi-account 一律用 `getScannableAccounts()` 迭代;跨帳號的使用者層資料(pins/hidden/index)放 `~/.config/codev/`,單一份。 -11. Lint:repo 無 CI lint gate,舊檔非 prettier 格式 → **只格式化自己改的行**,新檔可全 prettier。 - -## 8. 開放問題(實作時再定) - -- Pinned section 與時間軸「兩處都顯示」是提案預設,使用者未最終拍板(反對再改成只在 section)。 -- `session-marks.json` 單檔 vs 沿用 handoff 的 `~/.claude/codev-status/pinned.json`:本文傾向前者(multi-account + hidden 清單),實作時可再議。 -- C4 v1 card 與 v2 pane 是否合併一次做:視當時 effort 感覺。 -- FTS 是否索引 thinking block:v1 不索引(體積/噪音),留 flag。 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. From 84993cbce1d19dc3cf854cb164c6da62277f7203 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sun, 12 Jul 2026 23:51:27 +0800 Subject: [PATCH 04/10] style(sessions): prettier wraps on two changed lines (review round 2) Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 3703d5e..ef532ce 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -451,9 +451,12 @@ function SwitcherApp() { 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)); + 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) { @@ -506,7 +509,9 @@ function SwitcherApp() { // 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); + 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) => { From d07c385248289c20c3143e25eaabe4d8981d96c8 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 00:13:51 +0800 Subject: [PATCH 05/10] fix(sessions): stop random title/branch dropout in enrichment Pre-existing bug surfaced during Batch 1 testing: - Enrichment spawned ~400 concurrent exec greps (100 sessions x 4); the biggest transcripts regularly blew the silent 3s timeout (errors resolve to ''), so their title/branch vanished at random. Now batched 10 sessions at a time; timeout 3s -> 5s. Same batching applied to loadLastAssistantResponses (100 concurrent tails). - Branch was read from the last 5 transcript lines; an active session's tail is often tool output with no gitBranch field (measured on a live 26MB session: tail -5 hit 0, tail -20 hit 12). Window widened to 50 lines. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ src/claude-session-utility.ts | 27 ++++++++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2523b0..69d48e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - **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) ## 1.0.82 diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index dad07e6..dddde18 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1709,6 +1709,20 @@ export interface SessionEnrichment { let cachedBranches: Map | null = null; let cachedPRLinks: Map | null = null; +// 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)); + } +}; + export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise => { const now = Date.now(); if (cachedCustomTitles && cachedBranches && cachedPRLinks && (now - titlesCacheTimestamp) < CACHE_TTL_MS) { @@ -1718,7 +1732,7 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< const { exec } = require('child_process'); const execPromise = (cmd: string): Promise => new Promise((resolve) => { - exec(cmd, { encoding: 'utf-8', timeout: 3000 }, (err: any, stdout: string) => { + exec(cmd, { encoding: 'utf-8', timeout: 5000 }, (err: any, stdout: string) => { resolve(err ? '' : stdout); }); }); @@ -1726,7 +1740,7 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< const titles = new Map(); const branches = new Map(); const prLinks = new Map(); - const promises = sessions.map(async (session) => { + await runInBatches(sessions, 10, async (session) => { const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); const jsonlPath = path.join( getProjectsDir(session.accountDir), @@ -1740,7 +1754,9 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< 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`), + // 50 lines, not 5: an active session's tail is often tool output with + // no gitBranch field (measured: tail -5 hit 0, tail -20 hit 12). + execPromise(`tail -n 50 "${jsonlPath}" 2>/dev/null | grep -o '"gitBranch":"[^"]*"' | tail -1`), execPromise(`grep '"type":"pr-link"' "${jsonlPath}" 2>/dev/null | tail -1`), ]); @@ -1781,8 +1797,6 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< } }); - await Promise.all(promises); - cachedCustomTitles = titles; cachedBranches = branches; cachedPRLinks = prLinks; @@ -1808,7 +1822,7 @@ export const loadLastAssistantResponses = async ( }); const responses = new Map(); - const promises = sessions.map(async (session) => { + await runInBatches(sessions, 10, async (session) => { const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); const jsonlPath = path.join( getProjectsDir(session.accountDir), @@ -1841,7 +1855,6 @@ export const loadLastAssistantResponses = async ( } }); - await Promise.all(promises); return responses; }; From f8cfa43473fdf8cbeb01aeea912cda024dac0978 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 02:20:21 +0800 Subject: [PATCH 06/10] perf(sessions): mtime-keyed enrichment cache, no more TTL rescans Fixes the cubic finding (entry-time TTL stamp: any scan slower than the 5s TTL wrote an already-stale cache, so every popup interaction triggered another multi-second full rescan) and the 8-10s cold path the user measured (issue #134): - Per-file {mtimeMs, size} scan state: transcripts unchanged since their last scan are never re-grepped; a ~ms statSync pass replaces the wall-clock TTL entirely - titles/branches/prLinks become persistent accumulator maps; concurrent callers serialize through a promise queue and hit the accumulated cache (correct across different session sets) - File state recorded after a session's scan, so a failed pass retries; invalidateSessionCache clears the state (+ prLinks, which it previously missed) - Scan batches 10 -> 25 (both enrichment and last-assistant reads) for a faster first cold pass Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + src/claude-session-utility.ts | 68 +++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d48e0..b82b5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - 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 ## 1.0.82 diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index dddde18..3943767 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -87,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; @@ -96,6 +95,8 @@ export const invalidateSessionCache = () => { cachedEntrypoints = null; cachedCustomTitles = null; cachedBranches = null; + cachedPRLinks = null; + enrichedFileState.clear(); }; /** @@ -1709,6 +1710,16 @@ export interface SessionEnrichment { let cachedBranches: Map | null = null; let cachedPRLinks: Map | null = null; +// 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 @@ -1724,10 +1735,10 @@ const runInBatches = async ( }; 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 }; - } + // 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()); const { exec } = require('child_process'); const execPromise = (cmd: string): Promise => @@ -1737,19 +1748,28 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< }); }); - const titles = new Map(); - const branches = new Map(); - const prLinks = new Map(); - await runInBatches(sessions, 10, async (session) => { - const encodedProject = session.project.replace(/[^a-zA-Z0-9-]/g, '-'); - const jsonlPath = path.join( - getProjectsDir(session.accountDir), - encodedProject, - `${session.sessionId}.jsonl`, - ); - - if (!fs.existsSync(jsonlPath)) return; + const scan = async () => { + // Re-grep 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 { + 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 }); + } + await runInBatches(toScan, 25, async ({ session, jsonlPath, mtimeMs, size }) => { // 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`), @@ -1795,12 +1815,14 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< } } catch {} } - }); - cachedCustomTitles = titles; - cachedBranches = branches; - cachedPRLinks = prLinks; - titlesCacheTimestamp = now; + // Record AFTER the scan so a failed pass retries on the next call. + enrichedFileState.set(session.sessionId, { mtimeMs, size }); + }); + }; + + enrichmentQueue = enrichmentQueue.then(scan, scan); + await enrichmentQueue; return { titles, branches, prLinks }; }; @@ -1822,7 +1844,7 @@ export const loadLastAssistantResponses = async ( }); const responses = new Map(); - await runInBatches(sessions, 10, 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), From 62f97005f069b9c153c72de108ada9e8f256a329 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 22:06:31 +0800 Subject: [PATCH 07/10] feat(sessions): sticky fold header + collapse on each popup show Expanded minors header now sticks to the top of the scroll area so collapsing never requires scrolling back to the boundary row; each fresh popup show starts folded again. Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index ef532ce..c953414 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -872,6 +872,8 @@ 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) { @@ -1376,7 +1378,18 @@ function SwitcherApp() { {minorsExpanded && minorSessions.length > 0 && index === majorSessions.length && (
{ setMinorsExpanded(false); setSelectedSessionIndex(0); }} - style={{ padding: '6px 10px 4px 24px', color: '#777', fontSize: '12px', cursor: 'pointer' }} + style={{ + padding: '6px 10px 4px 24px', + color: '#777', + fontSize: '12px', + cursor: 'pointer', + // Stick to the top while scrolled inside the minors zone, + // so collapsing never requires scrolling back to find it. + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: '#1a1a1a', + }} > ▾ {minorSessions.length} minor sessions (≤2 msgs, untitled)
From 8a02bf7ebacd16c28bbfff816f4b4583a578d155 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 22:20:31 +0800 Subject: [PATCH 08/10] feat(sessions): always-visible fold bar below the list when expanded Sticky header only pins while scrolled inside the minors zone; the new bar sits outside the scroll area so collapsing is one click from any scroll position. Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c953414..657ccaf 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1629,6 +1629,23 @@ function SwitcherApp() { )} )} + {/* 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); }} + style={{ + padding: '6px 15px 8px', + color: '#888', + fontSize: '12px', + cursor: 'pointer', + borderTop: '1px solid #2e2e2e', + flexShrink: 0, + }} + > + ▾ {minorSessions.length} minor sessions shown — click to fold +
+ )} ) : (
From ba288c1b148fea134ba8d80ff78dddddc502aeef Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 22:32:55 +0800 Subject: [PATCH 09/10] fix(sessions): shell-free enrichment reads + retry-aware freshness Review round: exec -> execFile (grep args, no shell interpolation); branch tail via in-process 256KB read (no spawn); failures resolve null and skip the fresh-mark so they retry next pass; fold controls get role=button + tabIndex + Enter/Space; fold styles extracted to consts (fixes flagged prettier indents). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + src/claude-session-utility.ts | 176 +++++++++++++++++++++++----------- src/switcher-ui.tsx | 68 +++++++++---- 3 files changed, 174 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b82b5aa..777bbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - 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 diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 3943767..2883839 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1734,23 +1734,65 @@ const runInBatches = async ( } }; -export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise => { +// Read the last `bytes` of a file without spawning a process. +const readTailUtf8 = (filePath: string, bytes: number): string | null => { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const size = fs.fstatSync(fd).size; + const len = Math.min(bytes, size); + const buf = Buffer.alloc(len); + // Cast: this @types/node version mistypes Buffer vs ArrayBufferView. + fs.readSync(fd, buf as unknown as Uint8Array, 0, len, size - len); + return buf.toString('utf-8'); + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +}; + +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()); - const { exec } = require('child_process'); - const execPromise = (cmd: string): Promise => + 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) => { - exec(cmd, { encoding: 'utf-8', timeout: 5000 }, (err: any, stdout: string) => { - resolve(err ? '' : stdout); - }); + 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] || ''; + }; const scan = async () => { - // Re-grep only transcripts that are new or changed since their last scan. - const toScan: { session: ClaudeSession; jsonlPath: string; mtimeMs: number; size: number }[] = []; + // 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( @@ -1765,60 +1807,86 @@ export const loadSessionEnrichment = async (sessions: ClaudeSession[]): Promise< 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 (prev && prev.mtimeMs === stat.mtimeMs && prev.size === stat.size) { + continue; + } + toScan.push({ + session, + jsonlPath, + mtimeMs: stat.mtimeMs, + size: stat.size, + }); } - await runInBatches(toScan, 25, async ({ session, jsonlPath, mtimeMs, size }) => { - // 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`), - // 50 lines, not 5: an active session's tail is often tool output with - // no gitBranch field (measured: tail -5 hit 0, tail -20 hit 12). - execPromise(`tail -n 50 "${jsonlPath}" 2>/dev/null | grep -o '"gitBranch":"[^"]*"' | tail -1`), - execPromise(`grep '"type":"pr-link"' "${jsonlPath}" 2>/dev/null | tail -1`), - ]); - - // Priority: custom-title > ai-title - if (titleOutput.trim()) { - try { - const parsed = JSON.parse(titleOutput.trim()); - const title = (parsed.customTitle || '').replace(/^"|"$/g, '').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] = await Promise.all([ + grepFileP('"type":"custom-title"', jsonlPath), + grepFileP('"type":"ai-title"', jsonlPath), + grepFileP('"type":"pr-link"', jsonlPath), + ]); + const tailOutput = 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 {} } - } catch {} - } - 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); + 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); + } + } - 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 {} - } - // Record AFTER the scan so a failed pass retries on the next call. - enrichedFileState.set(session.sessionId, { mtimeMs, size }); - }); + // 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 }); + } + }, + ); }; enrichmentQueue = enrichmentQueue.then(scan, scan); diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 657ccaf..c8207f5 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -22,6 +22,30 @@ const SEARCH_HIGHLIGHT_STYLE = { 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 { @@ -1377,19 +1401,17 @@ function SwitcherApp() { {minorsExpanded && minorSessions.length > 0 && index === majorSessions.length && (
{ setMinorsExpanded(false); setSelectedSessionIndex(0); }} - style={{ - padding: '6px 10px 4px 24px', - color: '#777', - fontSize: '12px', - cursor: 'pointer', - // Stick to the top while scrolled inside the minors zone, - // so collapsing never requires scrolling back to find it. - position: 'sticky', - top: 0, - zIndex: 1, - backgroundColor: '#1a1a1a', + 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)
@@ -1621,7 +1643,15 @@ function SwitcherApp() { ))} {!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) @@ -1633,15 +1663,17 @@ function SwitcherApp() { are expanded — collapsing never depends on scroll position. */} {!isSearchingSessions && minorsExpanded && minorSessions.length > 0 && (
{ setMinorsExpanded(false); setSelectedSessionIndex(0); }} - style={{ - padding: '6px 15px 8px', - color: '#888', - fontSize: '12px', - cursor: 'pointer', - borderTop: '1px solid #2e2e2e', - flexShrink: 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
From 0ce8447230cd807c7831296abb4fdec9293075f2 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 22:51:52 +0800 Subject: [PATCH 10/10] fix(sessions): async tail read + detached-HEAD branch clearing Review round: readTailUtf8 -> fs.promises (no sync IO on the main event loop; joins the per-file Promise.all); an explicit gitBranch HEAD in the tail now clears the stale branch, while a tail with no gitBranch line keeps the old value (usually transient tool output). Co-Authored-By: Claude Fable 5 --- src/claude-session-utility.ts | 47 +++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 2883839..5a5c37d 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1734,22 +1734,25 @@ const runInBatches = async ( } }; -// Read the last `bytes` of a file without spawning a process. -const readTailUtf8 = (filePath: string, bytes: number): string | null => { +// 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 { - const fd = fs.openSync(filePath, 'r'); - try { - const size = fs.fstatSync(fd).size; - const len = Math.min(bytes, size); - const buf = Buffer.alloc(len); - // Cast: this @types/node version mistypes Buffer vs ArrayBufferView. - fs.readSync(fd, buf as unknown as Uint8Array, 0, len, size - len); - return buf.toString('utf-8'); - } finally { - fs.closeSync(fd); - } + 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(() => {}); } }; @@ -1826,12 +1829,13 @@ export const loadSessionEnrichment = async ( // 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] = await Promise.all([ - grepFileP('"type":"custom-title"', jsonlPath), - grepFileP('"type":"ai-title"', jsonlPath), - grepFileP('"type":"pr-link"', jsonlPath), - ]); - const tailOutput = readTailUtf8(jsonlPath, 256 * 1024); + 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) { @@ -1860,6 +1864,11 @@ export const loadSessionEnrichment = async ( 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); } }