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..5895900 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ 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 **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. - **Best**: start with a name โ€” `claude -n "my task"` (or `claude --name "my task"`) diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md 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. 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..d850788 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..f1b19cd 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,99 @@ 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; +let marksWatcherRetries = 0; +let marksWatcherRetryTimer: ReturnType | null = null; +const ensureMarksWatcher = () => { + if (marksWatcherCleanup) return; + if (marksWatcherRetryTimer) { + clearTimeout(marksWatcherRetryTimer); + marksWatcherRetryTimer = null; + } + marksWatcherCleanup = watchSessionMarks( + (marks) => { + marksWatcherRetries = 0; + if (switcherWindow && !switcherWindow.isDestroyed()) { + 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; + 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', + ); + } + }, + ); +}; + +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); + // 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 }; +}); + +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); + console.log('[session-marks] unpin', sessionId, 'pins:', Object.keys(marks.pins).length); + 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); + console.log('[session-marks] hide', sessionId, 'pins:', Object.keys(marks.pins).length, 'hidden:', marks.hidden.length); + 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); + console.log('[session-marks] unhide', sessionId, 'hidden:', marks.hidden.length); + 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..a839b41 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -76,6 +76,19 @@ 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); + return () => ipcRenderer.removeListener('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..4c6a388 --- /dev/null +++ b/src/session-marks.test.ts @@ -0,0 +1,119 @@ +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..2d21704 --- /dev/null +++ b/src/session-marks.ts @@ -0,0 +1,200 @@ +/** + * 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 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 watchMarksFile = ( + filePath: string, + onChange: (marks: SessionMarks) => void, + onError?: (err: Error) => void, +): (() => void) => { + 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, changed) => { + if (changed && changed !== filename) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + onChange(readMarksFile(filePath)); + }, 50); + }); + + 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?.(err); + }); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + 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(); + }); +}); diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c8207f5..97ecf33 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,24 @@ 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(''); + // 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([]); @@ -504,19 +530,224 @@ 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) { + // 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 && - 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; + // 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. 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); + } + for (const s of extraPinnedSessions) { + if (sessionMarks.pins[s.sessionId] && !pinnedById.has(s.sessionId)) { + 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]) => (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 + // 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, + // 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, + }; + 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 : []; + + const displayedSessions = [ + ...visiblePinnedRows, + ...(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 || [] }); + } + }; + const togglePin = (session: any) => { + if (sessionMarks.pins[session.sessionId]) { + window.electronAPI + .unpinSession(session.sessionId) + .then((r) => applyMarksResult(r, session.sessionId)) + .catch(() => {}); + } else { + window.electronAPI + .pinSession(session.sessionId, { cwd: session.project, accountLabel: session.accountLabel }) + .then((r) => applyMarksResult(r, session.sessionId)) + .catch(() => {}); + } + }; + const toggleHide = (session: any) => { + if (hiddenSet.has(session.sessionId)) { + window.electronAPI + .unhideSession(session.sessionId) + .then((r) => applyMarksResult(r, session.sessionId)) + .catch(() => {}); + } else { + window.electronAPI + .hideSession(session.sessionId) + .then((r) => applyMarksResult(r, session.sessionId)) + .catch(() => {}); + } + }; + const togglePinnedCollapsed = () => { + setPinnedCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem('codev-pinned-collapsed', next ? '1' : '0'); + } catch {} + return next; + }); + // The list just changed length โ€” snap the selection back to the top, + // same as the minors fold collapse does. + 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 + .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) { + suppressHoverSelection(); + setSessionMarks({ pins: m.pins || {}, hidden: m.hidden || [] }); + } + }, + ); + return unsubscribe; + }, []); + + // 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[]) => { + // 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; + 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 })); + } + }); + }).catch(() => {}); + }, [sessionMarks.pins, allSessions]); const fetchClaudeSessions = async () => { // Step 1: Show sessions immediately, preserve old active states (SWR via ref) @@ -1372,6 +1603,23 @@ 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. + // 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(); + if (selectedSessionIndex < 0) return; + const s = displayedSessions[selectedSessionIndex]; + if (s) { + // โ‡งโŒ˜D on a zone row = unpin + fold (pin/hide are exclusive) + if (e.shiftKey) { + toggleHide(s); + } else { + togglePin(s); + } + } } }} placeholder="Search sessions..." @@ -1397,12 +1645,34 @@ function SwitcherApp() { {sessionSearchValue ? 'โš ๏ธ No matching sessions found' : '๐Ÿค– No Claude Code sessions found'} ) : (<> + {showPinnedZone && ( +
e.preventDefault()} + onClick={togglePinnedCollapsed} + onKeyDown={(e) => { + 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 && (
e.preventDefault()} onClick={() => { setMinorsExpanded(false); setSelectedSessionIndex(0); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -1413,7 +1683,7 @@ function SwitcherApp() { }} style={MINOR_FOLD_HEADER_STYLE} > - โ–พ {minorSessions.length} minor sessions (โ‰ค2 msgs, untitled) + โ–พ {minorSessions.length} minor sessions {minorFoldSuffix}
)}
+ {sessionMarks.pins[session.sessionId] && ( + โ˜… + )} + {hiddenSet.has(session.sessionId) && ( + โŠ˜ + )}
+ {index === selectedSessionIndex && ( + <> + e.preventDefault()} + onClick={(e) => { e.stopPropagation(); togglePin(session); }} + style={{ cursor: 'pointer', fontSize: '11px', color: sessionMarks.pins[session.sessionId] ? '#f5b942' : '#777' }} + > + ๐Ÿ“Œ + + e.preventDefault()} + onClick={(e) => { 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); @@ -1556,7 +1852,7 @@ function SwitcherApp() { ) : null; })()} - {session.messageCount} msgs + {session.messageCount ?? 'โ€ฆ'} msgs {formatRelativeTime(session.lastTimestamp)} @@ -1645,6 +1941,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={() => setMinorsExpanded(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -1654,7 +1951,7 @@ function SwitcherApp() { }} style={{ padding: '6px 10px 8px 24px', color: '#777', fontSize: '12px', cursor: 'pointer' }} > - โ–ธ {minorSessions.length} minor sessions (โ‰ค2 msgs, untitled) + โ–ธ {minorSessions.length} minor sessions {minorFoldSuffix}
)} )} @@ -1665,6 +1962,7 @@ function SwitcherApp() {
e.preventDefault()} onClick={() => { setMinorsExpanded(false); setSelectedSessionIndex(0); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {