From c9adb952a271279cf8663fb6109f382d1a6d6f63 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 23:19:02 +0800 Subject: [PATCH 01/12] feat(sessions): pins + manual hide (Batch 1 PR-2) - Pinned zone at the top (collapsible, remembered); pin via hover icon or Cmd+D; pinned rows also keep their chronological spot with a gold star marker - Works for deep-search matches beyond the loaded 100 (fetched by id via new get-sessions-by-ids IPC + lazy enrichment) - Hide (hover icon or Shift+Cmd+D) forces a session into the minor fold; reversible from inside the fold; pin/hide are mutually exclusive (pinning unhides, hiding unpins) - Store: ~/.config/codev/session-marks.json, fs.watch push to the renderer, atomic temp+rename writes; pure transitions unit-tested (7 new tests, 52 total) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ README.md | 2 + package.json | 2 +- src/claude-session-utility.ts | 9 ++ src/electron-api.d.ts | 19 ++++ src/main.ts | 71 +++++++++++++ src/preload.ts | 10 ++ src/session-marks.test.ts | 118 +++++++++++++++++++++ src/session-marks.ts | 184 ++++++++++++++++++++++++++++++++ src/switcher-ui.tsx | 193 ++++++++++++++++++++++++++++++++-- 10 files changed, 610 insertions(+), 8 deletions(-) create mode 100644 src/session-marks.test.ts create mode 100644 src/session-marks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 777bbed..6fd8191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.84 + +- Feat: session pins & manual hide โ€” session-finding Batch 1 PR-2 (plan: `docs/session-finding-plan.md` ยง4.4) + - **๐Ÿ“Œ Pinned zone** at the top of the Sessions list (collapsible, state remembered): pin via the hover ๐Ÿ“Œ icon on any row or `โŒ˜D` on the selected row; a pinned session ALSO keeps its chronological spot (marked โ˜…) โ€” the zone is a shortcut, not a move + - Pins work on any displayed row, including deep-search matches beyond the loaded ~100 (the zone fetches those by id and lazily enriches them) + - **Hide** (hover โŠ˜ or `โ‡งโŒ˜D`): forces a session into the minor-sessions fold โ€” reversible from inside the expanded fold, still searchable; pin and hide are mutually exclusive (pinning unhides, hiding unpins) + - While searching, the pinned zone steps aside and results are one unified list (โ˜… still marks pinned matches) + - Store: `~/.config/codev/session-marks.json` (single cross-account file, fs.watch-pushed to the UI, temp+rename writes); sessionIds are resume-stable so no migration logic is needed + - 7 new unit tests (marks normalize / pin-hide transitions / file roundtrip) โ€” 52 total + ## 1.0.83 - Feat: session finding Batch 1 โ€” "search & noise" (plan: `docs/session-finding-plan.md`) diff --git a/README.md b/README.md index 9b74c15..6079532 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ CodeV can list, search, and resume Claude Code sessions. Press `โŒƒ+โŒ˜+R` to op 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. +**Pin** the sessions you keep coming back to (hover ๐Ÿ“Œ on a row, or `โŒ˜D` on the selected row): they gather in a collapsible **๐Ÿ“Œ Pinned** zone at the top while also keeping their chronological spot (marked โ˜…) โ€” works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover โŠ˜, or `โ‡งโŒ˜D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold. Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. + **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/package.json b/package.json index 43f5661..986878e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.83", + "version": "1.0.84", "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 5a5c37d..8c66d4f 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -244,6 +244,15 @@ export const searchClaudeSessions = ( return { sessions, snippets }; }; +/** Look up full session records by id from the cached full set (any account). */ +export const getSessionsByIds = (ids: string[]): ClaudeSession[] => { + if (ids.length === 0) return []; + const want = new Set(ids); + return readClaudeSessions(Number.MAX_SAFE_INTEGER).filter((s) => + want.has(s.sessionId), + ); +}; + /** * Detect active Claude Code sessions by checking running processes. * Returns a Map of session ID -> PID. diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 7f1e401..2f37396 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -128,6 +128,25 @@ interface IElectronAPI { getSessionStatuses: () => Promise>; onSessionStatusesUpdated: (callback: IpcCallback) => void; + // Session pin/hide marks + getSessionMarks: () => Promise<{ + version: number; + pins: Record< + string, + { pinnedAt: string; cwd: string; accountLabel?: string; group: string | null } + >; + hidden: string[]; + }>; + pinSession: ( + sessionId: string, + info: { cwd?: string; accountLabel?: string }, + ) => Promise<{ ok: boolean; error?: string; marks?: any }>; + unpinSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; + hideSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; + unhideSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; + onSessionMarksUpdated: (callback: IpcCallback) => void; + getSessionsByIds: (ids: string[]) => Promise; + // Claude Code sessions getClaudeSessions: (limit?: number) => Promise; searchClaudeSessions: (query: string) => Promise<{ diff --git a/src/main.ts b/src/main.ts index d24b879..bdd4173 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,7 +30,17 @@ import { setLaunchInCodevTerminalCallback, launchNewClaudeSession, scanClosedVSCodeSessions, + getSessionsByIds, } from './claude-session-utility'; +import { + readSessionMarks, + watchSessionMarks, + withHidden, + withoutHidden, + withoutPin, + withPin, + writeSessionMarks, +} from './session-marks'; import { installHooks, removeHooks, @@ -2329,6 +2339,67 @@ ipcMain.handle('search-claude-sessions', (_event, query: string) => { return searchClaudeSessions(query); }); +ipcMain.handle('get-sessions-by-ids', (_event, ids: string[]) => { + if (!Array.isArray(ids)) return []; + return getSessionsByIds( + ids.filter((x: unknown): x is string => typeof x === 'string'), + ); +}); + +// Session pin/hide marks (session-finding Batch 1 PR-2) +let marksWatcherCleanup: (() => void) | null = null; +const ensureMarksWatcher = () => { + if (marksWatcherCleanup) return; + marksWatcherCleanup = watchSessionMarks((marks) => { + switcherWindow?.webContents.send('session-marks-updated', marks); + }); +}; + +ipcMain.handle('get-session-marks', () => { + ensureMarksWatcher(); + return readSessionMarks(); +}); + +ipcMain.handle('pin-session', (_event, sessionId: string, info: { cwd?: string; accountLabel?: string }) => { + if (!sessionId || typeof sessionId !== 'string') { + return { ok: false, error: 'invalid sessionId' }; + } + const marks = withPin(readSessionMarks(), sessionId, { + pinnedAt: new Date().toISOString(), + cwd: typeof info?.cwd === 'string' ? info.cwd : '', + accountLabel: typeof info?.accountLabel === 'string' ? info.accountLabel : undefined, + }); + writeSessionMarks(marks); + return { ok: true, marks }; +}); + +ipcMain.handle('unpin-session', (_event, sessionId: string) => { + if (!sessionId || typeof sessionId !== 'string') { + return { ok: false, error: 'invalid sessionId' }; + } + const marks = withoutPin(readSessionMarks(), sessionId); + writeSessionMarks(marks); + return { ok: true, marks }; +}); + +ipcMain.handle('hide-session', (_event, sessionId: string) => { + if (!sessionId || typeof sessionId !== 'string') { + return { ok: false, error: 'invalid sessionId' }; + } + const marks = withHidden(readSessionMarks(), sessionId); + writeSessionMarks(marks); + return { ok: true, marks }; +}); + +ipcMain.handle('unhide-session', (_event, sessionId: string) => { + if (!sessionId || typeof sessionId !== 'string') { + return { ok: false, error: 'invalid sessionId' }; + } + const marks = withoutHidden(readSessionMarks(), sessionId); + writeSessionMarks(marks); + return { ok: true, marks }; +}); + ipcMain.handle('detect-active-sessions', async () => { const { activeMap, vscodeSessions, entrypoints } = await detectActiveSessions(); return { diff --git a/src/preload.ts b/src/preload.ts index 1e549c6..79a94d5 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -76,6 +76,16 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionStatuses: () => ipcRenderer.invoke('get-session-statuses'), onSessionStatusesUpdated: (callback: any) => ipcRenderer.on('session-statuses-updated', callback), + // Session pin/hide marks + getSessionMarks: () => ipcRenderer.invoke('get-session-marks'), + pinSession: (sessionId: string, info: { cwd?: string; accountLabel?: string }) => + ipcRenderer.invoke('pin-session', sessionId, info), + unpinSession: (sessionId: string) => ipcRenderer.invoke('unpin-session', sessionId), + hideSession: (sessionId: string) => ipcRenderer.invoke('hide-session', sessionId), + unhideSession: (sessionId: string) => ipcRenderer.invoke('unhide-session', sessionId), + onSessionMarksUpdated: (callback: any) => ipcRenderer.on('session-marks-updated', callback), + getSessionsByIds: (ids: string[]) => ipcRenderer.invoke('get-sessions-by-ids', ids), + // Claude Code session APIs getClaudeSessions: (limit?: number) => ipcRenderer.invoke('get-claude-sessions', limit), searchClaudeSessions: (query: string) => ipcRenderer.invoke('search-claude-sessions', query), diff --git a/src/session-marks.test.ts b/src/session-marks.test.ts new file mode 100644 index 0000000..7240711 --- /dev/null +++ b/src/session-marks.test.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyMarks, + normalizeMarks, + readMarksFile, + withHidden, + withoutHidden, + withoutPin, + withPin, + writeMarksFile, +} from './session-marks'; + +describe('normalizeMarks', () => { + it('returns empty marks for garbage input', () => { + expect(normalizeMarks(null)).toEqual(emptyMarks()); + expect(normalizeMarks('nope')).toEqual(emptyMarks()); + expect(normalizeMarks([1, 2])).toEqual(emptyMarks()); + expect(normalizeMarks({ pins: [1], hidden: 'x' })).toEqual(emptyMarks()); + }); + + it('keeps valid entries, defaults missing fields, dedupes hidden', () => { + const raw = { + pins: { + abc: { pinnedAt: '2026-07-14T00:00:00Z', cwd: '/x', group: 'g1' }, + bad: 'not-an-object', + def: {}, + }, + hidden: ['h1', 'h1', '', 42, 'h2'], + }; + const m = normalizeMarks(raw); + expect(Object.keys(m.pins).sort()).toEqual(['abc', 'def']); + expect(m.pins.abc.group).toBe('g1'); + expect(m.pins.def.cwd).toBe(''); + expect(typeof m.pins.def.pinnedAt).toBe('string'); + expect(m.hidden).toEqual(['h1', 'h2']); + }); +}); + +describe('pin/hide transitions', () => { + const info = { pinnedAt: '2026-07-14T00:00:00Z', cwd: '/repo' }; + + it('pinning unhides and hiding unpins (mutual exclusion)', () => { + let m = withHidden(emptyMarks(), 's1'); + expect(m.hidden).toEqual(['s1']); + + m = withPin(m, 's1', info); + expect(m.pins.s1).toBeTruthy(); + expect(m.hidden).toEqual([]); + + m = withHidden(m, 's1'); + expect(m.pins.s1).toBeUndefined(); + expect(m.hidden).toEqual(['s1']); + }); + + it('unpin and unhide are idempotent and non-destructive', () => { + let m = withPin(emptyMarks(), 's1', info); + m = withPin(m, 's2', { ...info, accountLabel: 'work' }); + m = withoutPin(m, 's1'); + m = withoutPin(m, 's1'); + expect(Object.keys(m.pins)).toEqual(['s2']); + expect(m.pins.s2.accountLabel).toBe('work'); + + m = withHidden(m, 'h1'); + m = withoutHidden(m, 'h1'); + m = withoutHidden(m, 'h1'); + expect(m.hidden).toEqual([]); + }); + + it('does not mutate the input marks', () => { + const base = withPin(emptyMarks(), 's1', info); + const frozen = JSON.stringify(base); + withHidden(base, 's1'); + withoutPin(base, 's1'); + withPin(base, 's2', info); + expect(JSON.stringify(base)).toBe(frozen); + }); +}); + +describe('marks file roundtrip', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codev-marks-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes and reads back the same marks (nested dir is created)', () => { + const file = path.join(dir, 'nested', 'session-marks.json'); + const marks = withHidden( + withPin(emptyMarks(), 'abc', { + pinnedAt: '2026-07-14T01:02:03Z', + cwd: '/repo', + accountLabel: 'work', + }), + 'junk-1', + ); + writeMarksFile(file, marks); + expect(readMarksFile(file)).toEqual(marks); + // no temp leftovers from the rename-based write + expect( + fs.readdirSync(path.dirname(file)).filter((f) => f.includes('.tmp-')), + ).toEqual([]); + }); + + it('returns empty marks for a missing or corrupt file', () => { + const file = path.join(dir, 'session-marks.json'); + expect(readMarksFile(file)).toEqual(emptyMarks()); + fs.writeFileSync(file, '{not json'); + expect(readMarksFile(file)).toEqual(emptyMarks()); + }); +}); diff --git a/src/session-marks.ts b/src/session-marks.ts new file mode 100644 index 0000000..1a074bf --- /dev/null +++ b/src/session-marks.ts @@ -0,0 +1,184 @@ +/** + * Pin / hide marks for Claude Code sessions (session-finding Batch 1 PR-2, + * docs/session-finding-plan.md ยง4.4). + * + * Single cross-account store at ~/.config/codev/session-marks.json (the same + * directory as the accounts registry), pushed to the renderer via fs.watch โ€” + * the same pattern as the status files. sessionIds are stable across resumes + * (verified: `--resume`/`--continue` reuse the id; only `--fork-session` + * creates a new one), so plain sessionId keying needs no migration logic. + * + * Pure helpers (normalize / with* transitions) are separated from fs wrappers + * so they are unit-testable; fs functions take an explicit file path with + * default-path wrappers for the app (same layout as share-manager.ts). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface PinInfo { + pinnedAt: string; // ISO timestamp + cwd: string; + accountLabel?: string; + group: string | null; // reserved for v2 named groups +} + +export interface SessionMarks { + version: 1; + pins: Record; + hidden: string[]; +} + +export const emptyMarks = (): SessionMarks => ({ + version: 1, + pins: {}, + hidden: [], +}); + +/** Coerce unknown JSON into a valid SessionMarks (drops malformed entries). */ +export const normalizeMarks = (raw: unknown): SessionMarks => { + const marks = emptyMarks(); + if (!raw || typeof raw !== 'object') return marks; + const obj = raw as Record; + const pins = obj.pins; + if (pins && typeof pins === 'object' && !Array.isArray(pins)) { + for (const [id, info] of Object.entries(pins as Record)) { + if (!id || !info || typeof info !== 'object') continue; + const p = info as Record; + marks.pins[id] = { + pinnedAt: + typeof p.pinnedAt === 'string' ? p.pinnedAt : new Date(0).toISOString(), + cwd: typeof p.cwd === 'string' ? p.cwd : '', + accountLabel: + typeof p.accountLabel === 'string' ? p.accountLabel : undefined, + group: typeof p.group === 'string' ? p.group : null, + }; + } + } + if (Array.isArray(obj.hidden)) { + marks.hidden = [ + ...new Set( + obj.hidden.filter( + (x: unknown): x is string => typeof x === 'string' && x.length > 0, + ), + ), + ]; + } + return marks; +}; + +// Pin and hide are mutually exclusive: pinning unhides, hiding unpins โ€” +// a pinned-but-hidden session has no sensible rendering. + +export const withPin = ( + marks: SessionMarks, + sessionId: string, + info: { pinnedAt: string; cwd: string; accountLabel?: string }, +): SessionMarks => ({ + version: 1, + pins: { + ...marks.pins, + [sessionId]: { + pinnedAt: info.pinnedAt, + cwd: info.cwd, + accountLabel: info.accountLabel, + group: null, + }, + }, + hidden: marks.hidden.filter((id) => id !== sessionId), +}); + +export const withoutPin = ( + marks: SessionMarks, + sessionId: string, +): SessionMarks => { + const pins = { ...marks.pins }; + delete pins[sessionId]; + return { version: 1, pins, hidden: [...marks.hidden] }; +}; + +export const withHidden = ( + marks: SessionMarks, + sessionId: string, +): SessionMarks => { + const pins = { ...marks.pins }; + delete pins[sessionId]; + return { + version: 1, + pins, + hidden: marks.hidden.includes(sessionId) + ? [...marks.hidden] + : [...marks.hidden, sessionId], + }; +}; + +export const withoutHidden = ( + marks: SessionMarks, + sessionId: string, +): SessionMarks => ({ + version: 1, + pins: { ...marks.pins }, + hidden: marks.hidden.filter((id) => id !== sessionId), +}); + +// --- fs layer (path-based, testable; default-path wrappers below) --- + +const MARKS_FILENAME = 'session-marks.json'; + +export const readMarksFile = (filePath: string): SessionMarks => { + try { + return normalizeMarks(JSON.parse(fs.readFileSync(filePath, 'utf-8'))); + } catch { + return emptyMarks(); + } +}; + +export const writeMarksFile = ( + filePath: string, + marks: SessionMarks, +): void => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + // temp + rename so a crash mid-write can't corrupt the store + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(marks, null, 2) + '\n'); + fs.renameSync(tmp, filePath); +}; + +const defaultMarksPath = (): string => + path.join(os.homedir(), '.config', 'codev', MARKS_FILENAME); + +export const readSessionMarks = (): SessionMarks => + readMarksFile(defaultMarksPath()); + +export const writeSessionMarks = (marks: SessionMarks): void => + writeMarksFile(defaultMarksPath(), marks); + +/** + * Watch the marks file for changes. Watches the parent DIRECTORY: the + * rename-based write replaces the file inode, which would detach a plain + * file watcher. Events for other files in ~/.config/codev (accounts.json, + * our own .tmp) are filtered out by name. + */ +export const watchSessionMarks = ( + onChange: (marks: SessionMarks) => void, +): (() => void) => { + const filePath = defaultMarksPath(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + // Debounce: fs.watch on macOS fires several times per change + let debounceTimer: ReturnType | null = null; + const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => { + if (filename && filename !== MARKS_FILENAME) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + onChange(readMarksFile(filePath)); + }, 50); + }); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + }; +}; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c8207f5..539bb46 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -46,6 +46,14 @@ const MINOR_FOLD_BAR_STYLE = { flexShrink: 0, } as const; +// Header row of the pinned zone at the top of the Sessions list. +const PINNED_HEADER_STYLE = { + padding: '6px 10px 2px 24px', + color: '#c9a227', + fontSize: '12px', + cursor: 'pointer', +} as const; + // Global styles for the switcher UI (moved from index.css) const globalStyles = ` body { @@ -395,6 +403,21 @@ function SwitcherApp() { // 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); + // Pin/hide marks (PR-2), pushed from main via fs.watch on session-marks.json + const [sessionMarks, setSessionMarks] = useState<{ + pins: Record; + hidden: string[]; + }>({ pins: {}, hidden: [] }); + const [pinnedCollapsed, setPinnedCollapsed] = useState(() => { + try { + return localStorage.getItem('codev-pinned-collapsed') === '1'; + } catch { + return false; + } + }); + // Pinned sessions living outside the loaded list (fetched by id) + const [extraPinnedSessions, setExtraPinnedSessions] = useState([]); + const extraPinnedKeyRef = useRef(''); const modeRef = useRef(initialMode); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); @@ -504,19 +527,124 @@ function SwitcherApp() { // C1: fold minor (junk) sessions while browsing; searching shows everything. // Minors keep their recency order but render below the fold row at the end. + // A user-hidden session is forced into the fold regardless of its stats. const isSearchingSessions = sessionSearchValue.trim().length > 0; + const hiddenSet = new Set(sessionMarks.hidden); const majorSessions: any[] = []; const minorSessions: any[] = []; for (const s of sessions) { const minor = !isSearchingSessions && - activeDetectionReady && - isMinorSession(s, !!customTitles[s.sessionId], !!prLinks[s.sessionId]); + (hiddenSet.has(s.sessionId) || + (activeDetectionReady && + isMinorSession(s, !!customTitles[s.sessionId], !!prLinks[s.sessionId]))); (minor ? minorSessions : majorSessions).push(s); } - const displayedSessions = minorsExpanded - ? [...majorSessions, ...minorSessions] - : majorSessions; + + // Pinned zone (PR-2): rows come from the loaded list when available, else + // from the by-id fetch; ordered by pinnedAt (newest first). A pinned session + // ALSO keeps its chronological spot below โ€” the zone is a shortcut, not a move. + const pinnedById = new Map(); + for (const s of allSessions) { + if (sessionMarks.pins[s.sessionId]) pinnedById.set(s.sessionId, s); + } + for (const s of extraPinnedSessions) { + if (sessionMarks.pins[s.sessionId] && !pinnedById.has(s.sessionId)) { + pinnedById.set(s.sessionId, s); + } + } + const pinnedRows = Object.entries(sessionMarks.pins) + .sort(([, a], [, b]) => (b.pinnedAt || '').localeCompare(a.pinnedAt || '')) + .map(([id]) => pinnedById.get(id)) + .filter(Boolean) + .map((s: any) => ({ + ...s, + __pinnedRow: true, + isActive: s.sessionId in activeStateRef.current || s.isActive, + activePid: activeStateRef.current[s.sessionId] ?? s.activePid, + })); + const showPinnedZone = !isSearchingSessions && pinnedRows.length > 0; + const visiblePinnedRows = showPinnedZone && !pinnedCollapsed ? pinnedRows : []; + + const displayedSessions = [ + ...visiblePinnedRows, + ...(minorsExpanded ? [...majorSessions, ...minorSessions] : majorSessions), + ]; + + const applyMarksResult = (r: any) => { + if (r?.ok && r.marks) { + setSessionMarks({ pins: r.marks.pins || {}, hidden: r.marks.hidden || [] }); + } + }; + const togglePin = (session: any) => { + if (sessionMarks.pins[session.sessionId]) { + window.electronAPI.unpinSession(session.sessionId).then(applyMarksResult); + } else { + window.electronAPI + .pinSession(session.sessionId, { cwd: session.project, accountLabel: session.accountLabel }) + .then(applyMarksResult); + } + }; + const toggleHide = (session: any) => { + if (hiddenSet.has(session.sessionId)) { + window.electronAPI.unhideSession(session.sessionId).then(applyMarksResult); + } else { + window.electronAPI.hideSession(session.sessionId).then(applyMarksResult); + } + }; + const togglePinnedCollapsed = () => { + setPinnedCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem('codev-pinned-collapsed', next ? '1' : '0'); + } catch {} + return next; + }); + }; + + // Load marks once + subscribe to main-side pushes (fs.watch on the store) + useEffect(() => { + window.electronAPI.getSessionMarks().then((m: any) => { + if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + }); + window.electronAPI.onSessionMarksUpdated((_event: any, m: any) => { + if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + }); + }, []); + + // Fetch pinned sessions that aren't in the loaded list (by id), then enrich + useEffect(() => { + const loaded = new Set(allSessions.map((s: any) => s.sessionId)); + const missing = Object.keys(sessionMarks.pins).filter((id) => !loaded.has(id)); + const key = missing.sort().join(','); + if (key === extraPinnedKeyRef.current) return; + extraPinnedKeyRef.current = key; + if (missing.length === 0) { + setExtraPinnedSessions([]); + return; + } + window.electronAPI.getSessionsByIds(missing).then((result: any[]) => { + const found = result || []; + setExtraPinnedSessions(found); + if (found.length === 0) return; + window.electronAPI.loadSessionEnrichment(found).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(found).then((responses: Record) => { + if (responses && Object.keys(responses).length > 0) { + setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); + } + }); + }); + }, [sessionMarks.pins, allSessions]); const fetchClaudeSessions = async () => { // Step 1: Show sessions immediately, preserve old active states (SWR via ref) @@ -1372,6 +1500,15 @@ function SwitcherApp() { clearSessionSearchOnShowRef.current = true; window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId]); } + } else if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { + // โŒ˜D toggles pin, โ‡งโŒ˜D toggles hide on the selected row + e.preventDefault(); + const idx = selectedSessionIndex >= 0 ? selectedSessionIndex : 0; + const s = displayedSessions[idx]; + if (s) { + if (e.shiftKey) toggleHide(s); + else togglePin(s); + } } }} placeholder="Search sessions..." @@ -1397,9 +1534,28 @@ function SwitcherApp() { {sessionSearchValue ? 'โš ๏ธ No matching sessions found' : '๐Ÿค– No Claude Code sessions found'} ) : (<> + {showPinnedZone && ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + togglePinnedCollapsed(); + } + }} + style={PINNED_HEADER_STYLE} + > + {pinnedCollapsed ? 'โ–ธ' : 'โ–พ'} ๐Ÿ“Œ Pinned ({pinnedRows.length}) +
+ )} {displayedSessions.map((session, index) => ( - - {minorsExpanded && minorSessions.length > 0 && index === majorSessions.length && ( + + {visiblePinnedRows.length > 0 && index === visiblePinnedRows.length && ( +
+ )} + {minorsExpanded && minorSessions.length > 0 && index === visiblePinnedRows.length + majorSessions.length && (
+ {sessionMarks.pins[session.sessionId] && ( + โ˜… + )}
+ {index === selectedSessionIndex && ( + <> + { e.stopPropagation(); togglePin(session); }} + style={{ cursor: 'pointer', fontSize: '11px', color: sessionMarks.pins[session.sessionId] ? '#f5b942' : '#777' }} + > + ๐Ÿ“Œ + + {!session.__pinnedRow && ( + { e.stopPropagation(); toggleHide(session); }} + style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} + > + โŠ˜ + + )} + + )} {prLinks[session.sessionId] && (() => { const prInfo = prLinks[session.sessionId]; const searchWords = sessionSearchValue.split(/\s+/).filter(Boolean); From 2012c2b6b3995d66f7f20767d1d8524860f8bdcd Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 23:20:14 +0800 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20plan=20status=20=E2=80=94=20PR=20?= =?UTF-8?q?#132=20merged,=20PR-2=20pins=20=3D=20#136?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/session-finding-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 252ccd8..37320a4 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -1,8 +1,8 @@ # 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. +> PR-1 "search & noise" (ยง4.1โ€“ยง4.3: B2 highlight + A1/B1 full search + C1 folding) = **PR #132 (merged)**; +> PR-2 "pins" (ยง4.4 D1, incl. C1's manual hide) = **PR #136**; 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. From c2dd8112cd1ac511797ade75a039c57c54c59ee5 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Mon, 13 Jul 2026 23:38:31 +0800 Subject: [PATCH 03/12] fix(sessions): PR #136 review round 1 (cubic) - Marks listener returns an unsubscribe; effect cleans up on unmount - Stale getSessionsByIds responses discarded (key check) so rapid pins outside the loaded list can't overwrite newer results - .catch on all marks IPC call sites (no unhandled rejections) - isDestroyed() guard before webContents.send in the marks watcher - Pinned-zone collapse resets selectedSessionIndex (same as minors) - Fold label stays honest when it contains manually hidden sessions (adds 'N hidden' to the descriptor) Co-Authored-By: Claude Fable 5 --- src/electron-api.d.ts | 2 +- src/main.ts | 4 +++- src/preload.ts | 5 +++- src/switcher-ui.tsx | 55 +++++++++++++++++++++++++++++++++---------- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 2f37396..d850788 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -144,7 +144,7 @@ interface IElectronAPI { unpinSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; hideSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; unhideSession: (sessionId: string) => Promise<{ ok: boolean; error?: string; marks?: any }>; - onSessionMarksUpdated: (callback: IpcCallback) => void; + onSessionMarksUpdated: (callback: IpcCallback) => () => void; getSessionsByIds: (ids: string[]) => Promise; // Claude Code sessions diff --git a/src/main.ts b/src/main.ts index bdd4173..f5ab02a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2351,7 +2351,9 @@ let marksWatcherCleanup: (() => void) | null = null; const ensureMarksWatcher = () => { if (marksWatcherCleanup) return; marksWatcherCleanup = watchSessionMarks((marks) => { - switcherWindow?.webContents.send('session-marks-updated', marks); + if (switcherWindow && !switcherWindow.isDestroyed()) { + switcherWindow.webContents.send('session-marks-updated', marks); + } }); }; diff --git a/src/preload.ts b/src/preload.ts index 79a94d5..a839b41 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -83,7 +83,10 @@ contextBridge.exposeInMainWorld('electronAPI', { unpinSession: (sessionId: string) => ipcRenderer.invoke('unpin-session', sessionId), hideSession: (sessionId: string) => ipcRenderer.invoke('hide-session', sessionId), unhideSession: (sessionId: string) => ipcRenderer.invoke('unhide-session', sessionId), - onSessionMarksUpdated: (callback: any) => ipcRenderer.on('session-marks-updated', callback), + onSessionMarksUpdated: (callback: any) => { + ipcRenderer.on('session-marks-updated', callback); + return () => ipcRenderer.removeListener('session-marks-updated', callback); + }, getSessionsByIds: (ids: string[]) => ipcRenderer.invoke('get-sessions-by-ids', ids), // Claude Code session APIs diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 539bb46..fec7915 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -540,6 +540,14 @@ function SwitcherApp() { isMinorSession(s, !!customTitles[s.sessionId], !!prLinks[s.sessionId]))); (minor ? minorSessions : majorSessions).push(s); } + // Manually hidden sessions may be titled/long โ€” keep the fold label honest. + const hiddenMinorCount = minorSessions.filter((s: any) => + hiddenSet.has(s.sessionId), + ).length; + const minorFoldSuffix = + hiddenMinorCount > 0 + ? `(โ‰ค2 msgs, untitled ยท ${hiddenMinorCount} hidden)` + : '(โ‰ค2 msgs, untitled)'; // Pinned zone (PR-2): rows come from the loaded list when available, else // from the by-id fetch; ordered by pinnedAt (newest first). A pinned session @@ -578,18 +586,28 @@ function SwitcherApp() { }; const togglePin = (session: any) => { if (sessionMarks.pins[session.sessionId]) { - window.electronAPI.unpinSession(session.sessionId).then(applyMarksResult); + window.electronAPI + .unpinSession(session.sessionId) + .then(applyMarksResult) + .catch(() => {}); } else { window.electronAPI .pinSession(session.sessionId, { cwd: session.project, accountLabel: session.accountLabel }) - .then(applyMarksResult); + .then(applyMarksResult) + .catch(() => {}); } }; const toggleHide = (session: any) => { if (hiddenSet.has(session.sessionId)) { - window.electronAPI.unhideSession(session.sessionId).then(applyMarksResult); + window.electronAPI + .unhideSession(session.sessionId) + .then(applyMarksResult) + .catch(() => {}); } else { - window.electronAPI.hideSession(session.sessionId).then(applyMarksResult); + window.electronAPI + .hideSession(session.sessionId) + .then(applyMarksResult) + .catch(() => {}); } }; const togglePinnedCollapsed = () => { @@ -600,16 +618,25 @@ function SwitcherApp() { } catch {} return next; }); + // The list just changed length โ€” snap the selection back to the top, + // same as the minors fold collapse does. + setSelectedSessionIndex(0); }; // Load marks once + subscribe to main-side pushes (fs.watch on the store) useEffect(() => { - window.electronAPI.getSessionMarks().then((m: any) => { - if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); - }); - window.electronAPI.onSessionMarksUpdated((_event: any, m: any) => { - if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); - }); + window.electronAPI + .getSessionMarks() + .then((m: any) => { + if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + }) + .catch(() => {}); + const unsubscribe = window.electronAPI.onSessionMarksUpdated( + (_event: any, m: any) => { + if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + }, + ); + return unsubscribe; }, []); // Fetch pinned sessions that aren't in the loaded list (by id), then enrich @@ -624,6 +651,8 @@ function SwitcherApp() { return; } window.electronAPI.getSessionsByIds(missing).then((result: any[]) => { + // Drop stale responses (a newer pin set superseded this request) + if (extraPinnedKeyRef.current !== key) return; const found = result || []; setExtraPinnedSessions(found); if (found.length === 0) return; @@ -643,7 +672,7 @@ function SwitcherApp() { setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); } }); - }); + }).catch(() => {}); }, [sessionMarks.pins, allSessions]); const fetchClaudeSessions = async () => { @@ -1569,7 +1598,7 @@ function SwitcherApp() { }} style={MINOR_FOLD_HEADER_STYLE} > - โ–พ {minorSessions.length} minor sessions (โ‰ค2 msgs, untitled) + โ–พ {minorSessions.length} minor sessions {minorFoldSuffix}
)}
- โ–ธ {minorSessions.length} minor sessions (โ‰ค2 msgs, untitled) + โ–ธ {minorSessions.length} minor sessions {minorFoldSuffix}
)} )} From 2726c84a5aa15d5e966aa0a257e748d533b225f2 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 00:20:33 +0800 Subject: [PATCH 04/12] fix(sessions): selection re-anchor after pin/hide + hidden marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning inserts a zone row above the selection, shifting every index while selectedSessionIndex stayed put โ€” the next Cmd+D then acted on an unintended row (user-reported: phantom pins, toggles that seemed to do nothing). After any pin/hide toggle the selection re-anchors to the same session's timeline copy by sessionId. Hidden rows now carry a persistent dim no-entry marker (was only discoverable by hovering inside the fold). Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index fec7915..a89e965 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -418,6 +418,8 @@ function SwitcherApp() { // Pinned sessions living outside the loaded list (fetched by id) const [extraPinnedSessions, setExtraPinnedSessions] = useState([]); const extraPinnedKeyRef = useRef(''); + // Keep the selection on the same session after pin/hide reshuffles the list + const reanchorSelectionRef = useRef(null); const modeRef = useRef(initialMode); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); @@ -585,6 +587,7 @@ function SwitcherApp() { } }; const togglePin = (session: any) => { + reanchorSelectionRef.current = session.sessionId; if (sessionMarks.pins[session.sessionId]) { window.electronAPI .unpinSession(session.sessionId) @@ -598,6 +601,7 @@ function SwitcherApp() { } }; const toggleHide = (session: any) => { + reanchorSelectionRef.current = session.sessionId; if (hiddenSet.has(session.sessionId)) { window.electronAPI .unhideSession(session.sessionId) @@ -623,6 +627,31 @@ function SwitcherApp() { setSelectedSessionIndex(0); }; + // Pinning/hiding inserts or removes rows above the selection, shifting every + // index โ€” without re-anchoring, the next โŒ˜D would act on an unintended row. + // Re-anchor to the same session's LAST occurrence (the timeline copy). + useEffect(() => { + const target = reanchorSelectionRef.current; + if (!target) return; + reanchorSelectionRef.current = null; + let next = -1; + for (let i = displayedSessions.length - 1; i >= 0; i--) { + if (displayedSessions[i].sessionId === target) { + next = i; + break; + } + } + if (next >= 0) { + setSelectedSessionIndex(next); + } else { + // The session left the visible list (hidden into a collapsed fold) โ€” + // just clamp the selection into range. + setSelectedSessionIndex((cur) => + Math.min(cur, Math.max(displayedSessions.length - 1, 0)), + ); + } + }); + // Load marks once + subscribe to main-side pushes (fs.watch on the store) useEffect(() => { window.electronAPI @@ -1645,6 +1674,9 @@ function SwitcherApp() { {sessionMarks.pins[session.sessionId] && ( โ˜… )} + {hiddenSet.has(session.sessionId) && ( + โŠ˜ + )} Date: Tue, 14 Jul 2026 00:38:14 +0800 Subject: [PATCH 05/12] fix(sessions): PR #136 review round 2 - fs.watch gets an error handler (dir gone / watcher limits would otherwise crash the main process) - Re-anchor ref now armed together with the marks state update, so an unrelated render can't consume it before the list reshuffles - Shift+Cmd+D respects the pinned-zone restriction like the mouse UI - session-marks.ts prettier'd (new file) Co-Authored-By: Claude Fable 5 --- src/session-marks.ts | 15 ++++++++++----- src/switcher-ui.tsx | 24 +++++++++++++++--------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/session-marks.ts b/src/session-marks.ts index 1a074bf..74128d1 100644 --- a/src/session-marks.ts +++ b/src/session-marks.ts @@ -48,7 +48,9 @@ export const normalizeMarks = (raw: unknown): SessionMarks => { const p = info as Record; marks.pins[id] = { pinnedAt: - typeof p.pinnedAt === 'string' ? p.pinnedAt : new Date(0).toISOString(), + typeof p.pinnedAt === 'string' + ? p.pinnedAt + : new Date(0).toISOString(), cwd: typeof p.cwd === 'string' ? p.cwd : '', accountLabel: typeof p.accountLabel === 'string' ? p.accountLabel : undefined, @@ -134,10 +136,7 @@ export const readMarksFile = (filePath: string): SessionMarks => { } }; -export const writeMarksFile = ( - filePath: string, - marks: SessionMarks, -): void => { +export const writeMarksFile = (filePath: string, marks: SessionMarks): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); // temp + rename so a crash mid-write can't corrupt the store const tmp = `${filePath}.tmp-${process.pid}`; @@ -177,6 +176,12 @@ export const watchSessionMarks = ( }, 50); }); + watcher.on('error', () => { + // Swallow watcher errors (dir deleted, permissions changed, OS watcher + // limits) โ€” an unhandled 'error' event would crash the main process. + // The next app restart recovers the watch. + }); + return () => { if (debounceTimer) clearTimeout(debounceTimer); watcher.close(); diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index a89e965..75915e6 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -581,36 +581,38 @@ function SwitcherApp() { ...(minorsExpanded ? [...majorSessions, ...minorSessions] : majorSessions), ]; - const applyMarksResult = (r: any) => { + // Arm the re-anchor together with the marks update (same commit): arming at + // toggle-call time let any unrelated render consume the ref before the list + // actually reshuffled, leaving the selection on the wrong row again. + const applyMarksResult = (r: any, reanchorSessionId?: string) => { if (r?.ok && r.marks) { + if (reanchorSessionId) reanchorSelectionRef.current = reanchorSessionId; setSessionMarks({ pins: r.marks.pins || {}, hidden: r.marks.hidden || [] }); } }; const togglePin = (session: any) => { - reanchorSelectionRef.current = session.sessionId; if (sessionMarks.pins[session.sessionId]) { window.electronAPI .unpinSession(session.sessionId) - .then(applyMarksResult) + .then((r) => applyMarksResult(r, session.sessionId)) .catch(() => {}); } else { window.electronAPI .pinSession(session.sessionId, { cwd: session.project, accountLabel: session.accountLabel }) - .then(applyMarksResult) + .then((r) => applyMarksResult(r, session.sessionId)) .catch(() => {}); } }; const toggleHide = (session: any) => { - reanchorSelectionRef.current = session.sessionId; if (hiddenSet.has(session.sessionId)) { window.electronAPI .unhideSession(session.sessionId) - .then(applyMarksResult) + .then((r) => applyMarksResult(r, session.sessionId)) .catch(() => {}); } else { window.electronAPI .hideSession(session.sessionId) - .then(applyMarksResult) + .then((r) => applyMarksResult(r, session.sessionId)) .catch(() => {}); } }; @@ -1564,8 +1566,12 @@ function SwitcherApp() { const idx = selectedSessionIndex >= 0 ? selectedSessionIndex : 0; const s = displayedSessions[idx]; if (s) { - if (e.shiftKey) toggleHide(s); - else togglePin(s); + // Match the mouse UI: pinned-zone rows expose no hide control + if (e.shiftKey) { + if (!s.__pinnedRow) toggleHide(s); + } else { + togglePin(s); + } } } }} From 8329a986c01b0e78e9ab428a71e349261e66c392 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 12:23:00 +0800 Subject: [PATCH 06/12] fix(sessions): keep Cmd+D alive after mouse clicks + stable zone count Two more root causes from live testing: - Clicking any pin/hide icon or fold control moved focus off the search input, silently killing every Cmd+D until a tab switch refocused it ('shortcut randomly stops working'). All non-input controls now preventDefault on mousedown so focus never leaves. - Pinned(N) counted only pins that resolved to a loaded session object; a pinned VS Code session (absent from history.jsonl until the closed-scan merges) flapped the count on every tab switch. Unresolvable pins now render a placeholder row built from the pin record, so the zone and its count stay stable. Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 75915e6..4e54ac5 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -565,14 +565,30 @@ function SwitcherApp() { } const pinnedRows = Object.entries(sessionMarks.pins) .sort(([, a], [, b]) => (b.pinnedAt || '').localeCompare(a.pinnedAt || '')) - .map(([id]) => pinnedById.get(id)) - .filter(Boolean) - .map((s: any) => ({ - ...s, - __pinnedRow: true, - isActive: s.sessionId in activeStateRef.current || s.isActive, - activePid: activeStateRef.current[s.sessionId] ?? s.activePid, - })); + .map(([id, info]) => { + // Fall back to a placeholder built from the pin record itself: a pin + // can be momentarily (VS Code sessions are absent from history.jsonl + // until the closed-scan merges them in) or permanently unresolvable โ€” + // without this the zone count flaps on every tab switch. + const s = pinnedById.get(id) ?? { + sessionId: id, + project: info.cwd || '', + projectName: + (info.cwd || '').split('/').filter(Boolean).pop() || id.slice(0, 8), + firstUserMessage: '', + lastUserMessage: '', + lastTimestamp: 0, + messageCount: 0, + isActive: false, + accountLabel: info.accountLabel, + }; + return { + ...s, + __pinnedRow: true, + isActive: s.sessionId in activeStateRef.current || s.isActive, + activePid: activeStateRef.current[s.sessionId] ?? s.activePid, + }; + }); const showPinnedZone = !isSearchingSessions && pinnedRows.length > 0; const visiblePinnedRows = showPinnedZone && !pinnedCollapsed ? pinnedRows : []; @@ -1602,6 +1618,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={togglePinnedCollapsed} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -1623,6 +1640,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={() => { setMinorsExpanded(false); setSelectedSessionIndex(0); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -1717,6 +1735,7 @@ function SwitcherApp() { <> e.preventDefault()} onClick={(e) => { e.stopPropagation(); togglePin(session); }} style={{ cursor: 'pointer', fontSize: '11px', color: sessionMarks.pins[session.sessionId] ? '#f5b942' : '#777' }} > @@ -1725,6 +1744,7 @@ function SwitcherApp() { {!session.__pinnedRow && ( e.preventDefault()} onClick={(e) => { e.stopPropagation(); toggleHide(session); }} style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} > @@ -1891,6 +1911,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={() => setMinorsExpanded(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -1911,6 +1932,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={() => { setMinorsExpanded(false); setSelectedSessionIndex(0); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { From 7e1b36caa260d6e4464f772c4b88aeccb350859a Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 13:27:04 +0800 Subject: [PATCH 07/12] fix(sessions): Cmd+D requires an explicit selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a tab switch selection resets to -1 and Cmd+D silently fell back to row 0 โ€” the first pinned-zone row โ€” so a bare Cmd+D toggled the most recently pinned session (user-reported: Pinned(1)<->(2) oscillation, accidental pin of the top row). Enter keeps its open-top-result default; the mutating shortcut now no-ops without a hovered/arrowed selection. Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 4e54ac5..2ccce0d 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1577,10 +1577,14 @@ function SwitcherApp() { window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId]); } } else if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { - // โŒ˜D toggles pin, โ‡งโŒ˜D toggles hide on the selected row + // โŒ˜D toggles pin, โ‡งโŒ˜D toggles hide on the selected row. + // Requires an EXPLICIT selection (hover or arrow keys): + // defaulting to row 0 โ€” unlike Enter, which opens the top + // result โ€” made a bare โŒ˜D after a tab switch silently toggle + // the first pinned-zone row (user-reported footgun). e.preventDefault(); - const idx = selectedSessionIndex >= 0 ? selectedSessionIndex : 0; - const s = displayedSessions[idx]; + if (selectedSessionIndex < 0) return; + const s = displayedSessions[selectedSessionIndex]; if (s) { // Match the mouse UI: pinned-zone rows expose no hide control if (e.shiftKey) { From d3c06730b8fad54b584417b2606538f0dc987ed5 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 14:59:40 +0800 Subject: [PATCH 08/12] fix(sessions): suppress hover re-selection around marks layout shifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of three rounds of pin chaos (wrong sessions getting pinned, counts jumping 1->2->3, a never-touched session surviving restart): pin/hide moves rows under a STATIONARY cursor; Chromium re-hit-tests after layout changes and fires mouseenter on whatever row slid under the mouse, teleporting the selection and overriding the re-anchor โ€” so the next Cmd+D acted on an unrelated session. The window-show code already suppresses exactly this phenomenon with ignoreMouseEnterRef; now every marks-driven layout change gets the same 400ms suppression (token-guarded), and the pinned zone appends new pins at the bottom (pinnedAt asc) instead of reshuffling existing rows. Co-Authored-By: Claude Fable 5 --- src/switcher-ui.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 2ccce0d..8afbc3d 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -420,6 +420,7 @@ function SwitcherApp() { const extraPinnedKeyRef = useRef(''); // Keep the selection on the same session after pin/hide reshuffles the list const reanchorSelectionRef = useRef(null); + const hoverSuppressTokenRef = useRef(0); const modeRef = useRef(initialMode); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); @@ -563,8 +564,10 @@ function SwitcherApp() { pinnedById.set(s.sessionId, s); } } + // pinnedAt ASC: a new pin APPENDS at the zone bottom instead of reshuffling + // the existing zone rows โ€” less layout movement under the cursor. const pinnedRows = Object.entries(sessionMarks.pins) - .sort(([, a], [, b]) => (b.pinnedAt || '').localeCompare(a.pinnedAt || '')) + .sort(([, a], [, b]) => (a.pinnedAt || '').localeCompare(b.pinnedAt || '')) .map(([id, info]) => { // Fall back to a placeholder built from the pin record itself: a pin // can be momentarily (VS Code sessions are absent from history.jsonl @@ -597,12 +600,28 @@ function SwitcherApp() { ...(minorsExpanded ? [...majorSessions, ...minorSessions] : majorSessions), ]; + // Pin/hide moves rows under a STATIONARY cursor; Chromium then re-hit-tests + // and fires mouseenter on whatever row slid under the mouse, teleporting the + // selection (and overriding the re-anchor below) โ€” the same phenomenon the + // window-show code suppresses with ignoreMouseEnterRef. Suppress it around + // every marks-driven layout change too. + const suppressHoverSelection = (ms = 400) => { + ignoreMouseEnterRef.current = true; + const token = ++hoverSuppressTokenRef.current; + setTimeout(() => { + if (hoverSuppressTokenRef.current === token) { + ignoreMouseEnterRef.current = false; + } + }, ms); + }; + // Arm the re-anchor together with the marks update (same commit): arming at // toggle-call time let any unrelated render consume the ref before the list // actually reshuffled, leaving the selection on the wrong row again. const applyMarksResult = (r: any, reanchorSessionId?: string) => { if (r?.ok && r.marks) { if (reanchorSessionId) reanchorSelectionRef.current = reanchorSessionId; + suppressHoverSelection(); setSessionMarks({ pins: r.marks.pins || {}, hidden: r.marks.hidden || [] }); } }; @@ -680,7 +699,10 @@ function SwitcherApp() { .catch(() => {}); const unsubscribe = window.electronAPI.onSessionMarksUpdated( (_event: any, m: any) => { - if (m) setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + if (m) { + suppressHoverSelection(); + setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + } }, ); return unsubscribe; From 9aa4b7ed5a3681bae6733feb15d82aec77155510 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 20:57:58 +0800 Subject: [PATCH 09/12] feat(sessions): pinned sessions live in the zone only (user verdict) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeline duplicate of a pinned session read as noise, not signal โ€” pinning now MOVES the row into the zone (search still shows everything). The zone rows gain the hide control (unpin + fold in one step, per the pin/hide exclusivity) since the timeline path is gone, and Shift+Cmd+D allows the same. Marks IPC handlers log an audit line for the reported cross-restart pin-loss (not yet reproduced with evidence). Co-Authored-By: Claude Fable 5 --- src/main.ts | 5 +++++ src/switcher-ui.tsx | 25 +++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index f5ab02a..ff854c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2372,6 +2372,8 @@ ipcMain.handle('pin-session', (_event, sessionId: string, info: { cwd?: string; accountLabel: typeof info?.accountLabel === 'string' ? info.accountLabel : undefined, }); writeSessionMarks(marks); + // Audit trail for the lost-pin reports (visible in Console.app / stdout) + console.log('[session-marks] pin', sessionId, 'pins:', Object.keys(marks.pins).length); return { ok: true, marks }; }); @@ -2381,6 +2383,7 @@ ipcMain.handle('unpin-session', (_event, sessionId: string) => { } const marks = withoutPin(readSessionMarks(), sessionId); writeSessionMarks(marks); + console.log('[session-marks] unpin', sessionId, 'pins:', Object.keys(marks.pins).length); return { ok: true, marks }; }); @@ -2390,6 +2393,7 @@ ipcMain.handle('hide-session', (_event, sessionId: string) => { } const marks = withHidden(readSessionMarks(), sessionId); writeSessionMarks(marks); + console.log('[session-marks] hide', sessionId, 'pins:', Object.keys(marks.pins).length, 'hidden:', marks.hidden.length); return { ok: true, marks }; }); @@ -2399,6 +2403,7 @@ ipcMain.handle('unhide-session', (_event, sessionId: string) => { } const marks = withoutHidden(readSessionMarks(), sessionId); writeSessionMarks(marks); + console.log('[session-marks] unhide', sessionId, 'hidden:', marks.hidden.length); return { ok: true, marks }; }); diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 8afbc3d..f64e245 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -536,6 +536,9 @@ function SwitcherApp() { const majorSessions: any[] = []; const minorSessions: any[] = []; for (const s of sessions) { + // Pinned sessions live in the zone ONLY (user verdict: the timeline + // duplicate was more noise than signal). Search still shows everything. + if (!isSearchingSessions && sessionMarks.pins[s.sessionId]) continue; const minor = !isSearchingSessions && (hiddenSet.has(s.sessionId) || @@ -1608,9 +1611,9 @@ function SwitcherApp() { if (selectedSessionIndex < 0) return; const s = displayedSessions[selectedSessionIndex]; if (s) { - // Match the mouse UI: pinned-zone rows expose no hide control + // โ‡งโŒ˜D on a zone row = unpin + fold (pin/hide are exclusive) if (e.shiftKey) { - if (!s.__pinnedRow) toggleHide(s); + toggleHide(s); } else { togglePin(s); } @@ -1767,16 +1770,14 @@ function SwitcherApp() { > ๐Ÿ“Œ - {!session.__pinnedRow && ( - e.preventDefault()} - onClick={(e) => { e.stopPropagation(); toggleHide(session); }} - style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} - > - โŠ˜ - - )} + e.preventDefault()} + onClick={(e) => { e.stopPropagation(); toggleHide(session); }} + style={{ cursor: 'pointer', fontSize: '11px', color: hiddenSet.has(session.sessionId) ? '#e07a5f' : '#666' }} + > + โŠ˜ + )} {prLinks[session.sessionId] && (() => { From a2f27960dfa18444cb945d6ca844291c7f4ca83d Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 23:52:31 +0800 Subject: [PATCH 10/12] docs(sessions): spell out pin/hide keyboard semantics README documents that shortcuts act on the selected (blue-border) row and require explicit selection, Cmd+D vs Shift+Cmd+D, and that the zone vanishing at zero pins is not a collapse; the zone header tooltip carries the same hints. Co-Authored-By: Claude Fable 5 --- README.md | 4 +++- src/switcher-ui.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6079532..5895900 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ CodeV can list, search, and resume Claude Code sessions. Press `โŒƒ+โŒ˜+R` to op 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. -**Pin** the sessions you keep coming back to (hover ๐Ÿ“Œ on a row, or `โŒ˜D` on the selected row): they gather in a collapsible **๐Ÿ“Œ Pinned** zone at the top while also keeping their chronological spot (marked โ˜…) โ€” works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover โŠ˜, or `โ‡งโŒ˜D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold. Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. +**Pin** the sessions you keep coming back to (hover ๐Ÿ“Œ on a row, or `โŒ˜D` on the selected row): they **move into** a collapsible **๐Ÿ“Œ Pinned** zone at the top (no duplicate left in the timeline; search still shows everything) โ€” works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover โŠ˜, or `โ‡งโŒ˜D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold (they carry a persistent โŠ˜ marker there). Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. + +Keyboard semantics worth knowing: the shortcuts act on the **selected row** (the one with the blue left border โ€” hovering selects), and require an explicit selection. `โŒ˜D` = pin/unpin toggle; `โ‡งโŒ˜D` = hide (on a pinned row this unpins *and* folds in one step โ€” pin and hide are mutually exclusive). Collapsing the zone is a mouse action on the `๐Ÿ“Œ Pinned (N)` header; when the last pin is removed the zone disappears entirely (that's normal, not a collapse). **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. diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index f64e245..7c6bd52 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1647,6 +1647,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={togglePinnedCollapsed} onKeyDown={(e) => { From f416e70d89e1d03b4527f648fd58ba960d48a593 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 14 Jul 2026 23:54:11 +0800 Subject: [PATCH 11/12] fix(sessions): PR #136 review round 3 (cubic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dead marks watcher self-heals: on error it closes and the owner recreates with bounded backoff (was: swallowed error + stale cleanup ref meant deaf-until-restart) - Placeholder pinned rows show 'โ€ฆ msgs' instead of a misleading 0 - Zone comment updated to the zone-only model Co-Authored-By: Claude Fable 5 --- src/main.ts | 23 ++++++++++++++++++----- src/session-marks.ts | 11 ++++++++--- src/switcher-ui.tsx | 10 ++++++---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/main.ts b/src/main.ts index ff854c4..f2389d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2348,13 +2348,26 @@ ipcMain.handle('get-sessions-by-ids', (_event, ids: string[]) => { // Session pin/hide marks (session-finding Batch 1 PR-2) let marksWatcherCleanup: (() => void) | null = null; +let marksWatcherRetries = 0; const ensureMarksWatcher = () => { if (marksWatcherCleanup) return; - marksWatcherCleanup = watchSessionMarks((marks) => { - if (switcherWindow && !switcherWindow.isDestroyed()) { - switcherWindow.webContents.send('session-marks-updated', marks); - } - }); + marksWatcherCleanup = watchSessionMarks( + (marks) => { + marksWatcherRetries = 0; + if (switcherWindow && !switcherWindow.isDestroyed()) { + switcherWindow.webContents.send('session-marks-updated', marks); + } + }, + () => { + // Watcher died (dir removed, OS watcher limits) โ€” recreate with a + // bounded backoff instead of silently staying deaf until restart. + marksWatcherCleanup = null; + if (marksWatcherRetries < 5) { + marksWatcherRetries += 1; + setTimeout(ensureMarksWatcher, 2000); + } + }, + ); }; ipcMain.handle('get-session-marks', () => { diff --git a/src/session-marks.ts b/src/session-marks.ts index 74128d1..65f3531 100644 --- a/src/session-marks.ts +++ b/src/session-marks.ts @@ -161,6 +161,7 @@ export const writeSessionMarks = (marks: SessionMarks): void => */ export const watchSessionMarks = ( onChange: (marks: SessionMarks) => void, + onError?: () => void, ): (() => void) => { const filePath = defaultMarksPath(); const dir = path.dirname(filePath); @@ -177,9 +178,13 @@ export const watchSessionMarks = ( }); watcher.on('error', () => { - // Swallow watcher errors (dir deleted, permissions changed, OS watcher - // limits) โ€” an unhandled 'error' event would crash the main process. - // The next app restart recovers the watch. + // A dead watcher must not crash the main process (unhandled 'error' + // would) โ€” close it and let the owner decide whether to recreate. + try { + watcher.close(); + } catch {} + if (debounceTimer) clearTimeout(debounceTimer); + onError?.(); }); return () => { diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 7c6bd52..97ecf33 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -556,8 +556,8 @@ function SwitcherApp() { : '(โ‰ค2 msgs, untitled)'; // Pinned zone (PR-2): rows come from the loaded list when available, else - // from the by-id fetch; ordered by pinnedAt (newest first). A pinned session - // ALSO keeps its chronological spot below โ€” the zone is a shortcut, not a move. + // from the by-id fetch. Zone-only model: pinning MOVES the session here + // (the timeline keeps no duplicate; search mode still shows everything). const pinnedById = new Map(); for (const s of allSessions) { if (sessionMarks.pins[s.sessionId]) pinnedById.set(s.sessionId, s); @@ -584,7 +584,9 @@ function SwitcherApp() { firstUserMessage: '', lastUserMessage: '', lastTimestamp: 0, - messageCount: 0, + // undefined, not 0: the row renders 'โ€ฆ msgs' instead of a misleading + // '0 msgs' while the session is unresolved (or permanently gone) + messageCount: undefined, isActive: false, accountLabel: info.accountLabel, }; @@ -1850,7 +1852,7 @@ function SwitcherApp() { ) : null; })()} - {session.messageCount} msgs + {session.messageCount ?? 'โ€ฆ'} msgs {formatRelativeTime(session.lastTimestamp)} From a7537ca4b8c5ee824a831584c8dd10e3445be851 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Wed, 15 Jul 2026 00:04:57 +0800 Subject: [PATCH 12/12] fix(sessions): PR #136 review round 4 (cubic P3s on watcher retry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - watchMarksFile extracted (path-based, matching the module pattern); onError now receives the Error - Retry timer tracked and cleared; watcher-death and retry-exhausted paths log to console - New test drives the fs.watch error path via a mocked fs (dead watcher closed, error reported, no further onChange) โ€” 53 total Co-Authored-By: Claude Fable 5 --- src/main.ts | 16 ++++++++++++-- src/session-marks.test.ts | 1 + src/session-marks.ts | 28 +++++++++++++---------- src/session-marks.watch.test.ts | 39 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/session-marks.watch.test.ts diff --git a/src/main.ts b/src/main.ts index f2389d9..f1b19cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2349,8 +2349,13 @@ ipcMain.handle('get-sessions-by-ids', (_event, ids: string[]) => { // Session pin/hide marks (session-finding Batch 1 PR-2) let marksWatcherCleanup: (() => void) | null = null; let marksWatcherRetries = 0; +let marksWatcherRetryTimer: ReturnType | null = null; const ensureMarksWatcher = () => { if (marksWatcherCleanup) return; + if (marksWatcherRetryTimer) { + clearTimeout(marksWatcherRetryTimer); + marksWatcherRetryTimer = null; + } marksWatcherCleanup = watchSessionMarks( (marks) => { marksWatcherRetries = 0; @@ -2358,13 +2363,20 @@ const ensureMarksWatcher = () => { switcherWindow.webContents.send('session-marks-updated', marks); } }, - () => { + (err) => { // Watcher died (dir removed, OS watcher limits) โ€” recreate with a // bounded backoff instead of silently staying deaf until restart. marksWatcherCleanup = null; if (marksWatcherRetries < 5) { marksWatcherRetries += 1; - setTimeout(ensureMarksWatcher, 2000); + console.error( + `[session-marks] watcher died (${err?.message || err}); retry ${marksWatcherRetries}/5 in 2s`, + ); + marksWatcherRetryTimer = setTimeout(ensureMarksWatcher, 2000); + } else { + console.error( + '[session-marks] watcher died and retries exhausted โ€” marks pushes disabled until restart', + ); } }, ); diff --git a/src/session-marks.test.ts b/src/session-marks.test.ts index 7240711..4c6a388 100644 --- a/src/session-marks.test.ts +++ b/src/session-marks.test.ts @@ -116,3 +116,4 @@ describe('marks file roundtrip', () => { expect(readMarksFile(file)).toEqual(emptyMarks()); }); }); + diff --git a/src/session-marks.ts b/src/session-marks.ts index 65f3531..2d21704 100644 --- a/src/session-marks.ts +++ b/src/session-marks.ts @@ -154,37 +154,38 @@ export const writeSessionMarks = (marks: SessionMarks): void => writeMarksFile(defaultMarksPath(), marks); /** - * Watch the marks file for changes. Watches the parent DIRECTORY: the - * rename-based write replaces the file inode, which would detach a plain - * file watcher. Events for other files in ~/.config/codev (accounts.json, - * our own .tmp) are filtered out by name. + * Watch a marks file for changes (path-based, testable). Watches the parent + * DIRECTORY: the rename-based write replaces the file inode, which would + * detach a plain file watcher. Events for sibling files (accounts.json, our + * own .tmp) are filtered out by name. */ -export const watchSessionMarks = ( +export const watchMarksFile = ( + filePath: string, onChange: (marks: SessionMarks) => void, - onError?: () => void, + onError?: (err: Error) => void, ): (() => void) => { - const filePath = defaultMarksPath(); const dir = path.dirname(filePath); + const filename = path.basename(filePath); fs.mkdirSync(dir, { recursive: true }); // Debounce: fs.watch on macOS fires several times per change let debounceTimer: ReturnType | null = null; - const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => { - if (filename && filename !== MARKS_FILENAME) return; + const watcher = fs.watch(dir, { persistent: false }, (_event, changed) => { + if (changed && changed !== filename) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { onChange(readMarksFile(filePath)); }, 50); }); - watcher.on('error', () => { + watcher.on('error', (err: Error) => { // A dead watcher must not crash the main process (unhandled 'error' // would) โ€” close it and let the owner decide whether to recreate. try { watcher.close(); } catch {} if (debounceTimer) clearTimeout(debounceTimer); - onError?.(); + onError?.(err); }); return () => { @@ -192,3 +193,8 @@ export const watchSessionMarks = ( watcher.close(); }; }; + +export const watchSessionMarks = ( + onChange: (marks: SessionMarks) => void, + onError?: (err: Error) => void, +): (() => void) => watchMarksFile(defaultMarksPath(), onChange, onError); diff --git a/src/session-marks.watch.test.ts b/src/session-marks.watch.test.ts new file mode 100644 index 0000000..ec8708f --- /dev/null +++ b/src/session-marks.watch.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock fs BEFORE the module under test imports it: fs.watch is replaced by a +// controllable fake so the watcher 'error' path can be driven deterministically. +const handlers: Record void> = {}; +const fakeWatcher = { + close: vi.fn(), + on: (event: string, cb: (...args: any[]) => void) => { + handlers[event] = cb; + return fakeWatcher; + }, +}; + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdirSync: vi.fn(), + watch: vi.fn(() => fakeWatcher), + }; +}); + +import { watchMarksFile } from './session-marks'; + +describe('watchMarksFile error recovery', () => { + it('closes the dead watcher and reports the error to the owner', () => { + const onChange = vi.fn(); + const onError = vi.fn(); + watchMarksFile('/tmp/nowhere/session-marks.json', onChange, onError); + expect(typeof handlers.error).toBe('function'); + + const boom = new Error('watcher blew up'); + handlers.error(boom); + + expect(fakeWatcher.close).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(boom); + expect(onChange).not.toHaveBeenCalled(); + }); +});