Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`)
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
Expand Down
4 changes: 2 additions & 2 deletions docs/session-finding-plan.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions src/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ interface IElectronAPI {
getSessionStatuses: () => Promise<Record<string, string | null>>;
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<any[]>;

// Claude Code sessions
getClaudeSessions: (limit?: number) => Promise<any>;
searchClaudeSessions: (query: string) => Promise<{
Expand Down
103 changes: 103 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof setTimeout> | 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;
Comment thread
grimmerk marked this conversation as resolved.
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 {
Expand Down
13 changes: 13 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
119 changes: 119 additions & 0 deletions src/session-marks.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});

Loading
Loading