Skip to content
Merged
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 1.0.74

- Fix: session status dot stuck on purple for sessions with large responses (#116)
- `tail -n 50` on large JSONL files exceeded `execFile` maxBuffer (1MB)
- Reduced to 15 lines + raised maxBuffer to 5MB
- Fix: eliminate tab flash on startup (default tab now passed via URL hash)
- Style: project paths display `~/` instead of `/Users/<user>/`
- Style: shortcut display uses macOS symbols (`⌘⌃R` instead of `Cmd+Ctrl+R`)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Modifier symbol order ⌘⌃R doesn't match Apple's standard order or the actual UI output. The code (switcher-ui.tsx:162, 675) renders ⌃⌘R (Control before Command), which follows Apple's HIG. The changelog should match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 10:

<comment>Modifier symbol order `⌘⌃R` doesn't match Apple's standard order or the actual UI output. The code (switcher-ui.tsx:162, 675) renders `⌃⌘R` (Control before Command), which follows Apple's HIG. The changelog should match.</comment>

<file context>
@@ -5,12 +5,14 @@
+- Fix: eliminate tab flash on startup (default tab now passed via URL hash)
 - Style: project paths display `~/` instead of `/Users/<user>/`
-- Style: shortcut display uses macOS symbols (`⌃⌘R` instead of `Cmd+Ctrl+R`)
+- Style: shortcut display uses macOS symbols (`⌘⌃R` instead of `Cmd+Ctrl+R`)
+- Style: needs-attention dot changed from orange `#FFA726` to warm red `#F06856`
+- Style: working pulse animation slowed from 2s to 2.5s
</file context>
Suggested change
- Style: shortcut display uses macOS symbols (`⌘⌃R` instead of `Cmd+Ctrl+R`)
- Style: shortcut display uses macOS symbols (`⌃⌘R` instead of `Cmd+Ctrl+R`)
Fix with Cubic

- Style: needs-attention dot changed from orange `#FFA726` to warm red `#F06856`
- Style: working pulse animation slowed from 2s to 2.5s
- Style: normal mode banner only shown on first launch
- Feat: clicking shortcut in title bar opens Settings → Shortcuts tab
- Feat: project search supports `~/` prefix and full path matching (with highlight)

## 1.0.73

- Feat: embedded terminal search (`Cmd+F`)
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.73",
"version": "1.0.74",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"repository": {
"type": "git",
Expand Down
18 changes: 13 additions & 5 deletions src/claude-session-utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,10 @@ export const detectActiveSessions = async (): Promise<ActiveSessionResult> => {
if (!pid || !sessionId) continue;

// Verify process is still alive
try { process.kill(pid, 0); } catch { continue; }
try { process.kill(pid, 0); } catch {
console.log(`[detect-active] PID ${pid} (${sessionId}) not alive, skipping`);
continue;
}

entrypoints.set(sessionId, entrypoint);

Expand Down Expand Up @@ -924,17 +927,22 @@ export const detectActiveSessions = async (): Promise<ActiveSessionResult> => {
activeMap.set(sessionId, pid);
} else if (cwd) {
// sessionId not in history — find session by cwd
console.log(`[detect-active] PID ${pid} sessionId ${sessionId} not in history.jsonl, trying cwd match (${cwd})`);
const cwdCandidates = allSessions.filter(s => s.project === cwd && !activeMap.has(s.sessionId));
if (cwdCandidates.length === 1) {
activeMap.set(cwdCandidates[0].sessionId, pid);
} else if (cwdCandidates.length > 1) {
// Multiple same-cwd candidates — queue for cross-reference
needsCrossRef.push({ pid, cwd, candidates: cwdCandidates });
} else {
console.log(`[detect-active] PID ${pid} sessionId ${sessionId}: no cwd match found`);
}
} else {
console.log(`[detect-active] PID ${pid} sessionId ${sessionId}: not in history and no cwd`);
}
}
} catch {
// skip malformed files
} catch (err) {
console.error(`[detect-active] Error processing session file ${file}:`, err);
}
}

Expand All @@ -954,8 +962,8 @@ export const detectActiveSessions = async (): Promise<ActiveSessionResult> => {
if (!fs.existsSync(sessionsDir)) {
await detectActiveSessionsLegacy(activeMap);
}
} catch {
// ignore
} catch (err) {
console.error('[detect-active] Error in detectActiveSessions:', err);
}

cachedActiveMap = activeMap;
Expand Down
3 changes: 3 additions & 0 deletions src/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ type IpcCallback = (event: Electron.IpcRendererEvent, ...args: any[]) => void;

interface IElectronAPI {
// App actions
getHomeDir: () => Promise<string>;
getBannerSeen: () => Promise<boolean>;
setBannerSeen: () => void;
invokeVSCode: (path: string, option: string) => void;
hideApp: () => void;
openFolderSelector: () => void;
Expand Down
38 changes: 34 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ const createSettingsWindow = (
return settingsWindow;
};

const createSwitcherWindow = (): BrowserWindow => {
const createSwitcherWindow = (initialMode?: string): BrowserWindow => {
// Create the browser window.
const window = new BrowserWindow({
// maximizable: false,
Expand All @@ -431,7 +431,9 @@ const createSwitcherWindow = (): BrowserWindow => {
});

// and load the index.html of the app.
window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY);
// Pass initial mode via hash so renderer can use it synchronously (no async IPC)
const hash = initialMode ? `#mode=${initialMode}` : '';
window.loadURL(SWITCHER_WINDOW_WEBPACK_ENTRY + hash);

// Open external links in default browser
const { shell } = require('electron');
Expand Down Expand Up @@ -619,6 +621,10 @@ ipcMain.on('search-working-folder', (event, path: string) => {
}
});

ipcMain.handle('get-home-dir', () => {
return require('os').homedir();
});

ipcMain.on('hide-app', (event) => {
hideSwitcherWindow();
});
Expand Down Expand Up @@ -1094,7 +1100,8 @@ const trayToggleEvtHandler = async () => {
}
}

switcherWindow = createSwitcherWindow();
const defaultSwitcherMode = ((await settings.get('default-switcher-mode')) as string) || 'projects';
switcherWindow = createSwitcherWindow(defaultSwitcherMode);
if (isDebug) {
console.log('when ready');
}
Expand Down Expand Up @@ -1980,6 +1987,10 @@ ipcMain.handle('get-session-statuses', async () => {
// Scan active sessions that don't have status files yet + cleanup stale ones
try {
const { activeMap, vscodeSessions } = await detectActiveSessions();
if (isDebug) {
console.log('[session-status] activeMap keys:', Array.from(activeMap.keys()));
console.log('[session-status] status files:', Object.keys(obj));
}
cleanupStaleStatuses(new Set(activeMap.keys()));
const allSessions = readClaudeSessions(500); // hoisted out of loop
// Merge VS Code sessions for status scanning
Expand All @@ -1988,19 +1999,30 @@ ipcMain.handle('get-session-statuses', async () => {
.filter(([sessionId]) => !obj[sessionId])
.map(([sessionId]) => {
const session = allKnown.find((s: any) => s.sessionId === sessionId);
if (!session && isDebug) {
console.log(`[session-status] active session ${sessionId} not found in allKnown (${allKnown.length} sessions)`);
}
return session ? { sessionId, project: session.project } : null;
})
.filter(Boolean) as { sessionId: string; project: string }[];

if (sessionsWithoutStatus.length > 0) {
if (isDebug) {
console.log('[session-status] scanning', sessionsWithoutStatus.length, 'sessions without status:', sessionsWithoutStatus.map(s => s.sessionId));
}
const scanned = await scanInitialStatuses(sessionsWithoutStatus);
if (isDebug) {
console.log('[session-status] scan results:', Array.from(scanned.entries()));
}
scanned.forEach((v, k) => {
obj[k] = { status: v, timestamp: Math.floor(Date.now() / 1000) };
// Persist scanned status to file so fs.watch treats all statuses uniformly
writeStatusFile(k, v as string);
});
}
} catch {}
} catch (err) {
console.error('[session-status] Error during status scan:', err);
}

return obj;
});
Expand All @@ -2009,6 +2031,14 @@ ipcMain.handle('get-app-mode', async () => {
return appMode;
});

ipcMain.handle('get-banner-seen', async () => {
return await settings.get('normal-mode-banner-seen');
});

ipcMain.on('set-banner-seen', async () => {
await settings.set('normal-mode-banner-seen', true);
});

ipcMain.on('set-app-mode', async (_event, mode: string) => {
const newMode = mode === 'menubar' ? 'menubar' : 'normal';
await settings.set('app-mode', newMode);
Expand Down
13 changes: 13 additions & 0 deletions src/popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ const PopupDefaultExample = ({
saveCallback,
openCallback,
switcherMode,
openToTab,
onOpenToTabConsumed,
}: {
workingFolderPath?: string;
saveCallback?: (key: string, value: string) => void;
openCallback?: any;
switcherMode?: string;
openToTab?: 'general' | 'sessions' | 'shortcuts' | null;
onOpenToTabConsumed?: () => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [launchAtLogin, setLaunchAtLogin] = useState(false);
Expand Down Expand Up @@ -142,6 +146,15 @@ const PopupDefaultExample = ({
}
}, [isOpen]);

// Allow parent to open Settings on a specific tab
useEffect(() => {
if (openToTab) {
setSettingsTab(openToTab);
setIsOpen(true);
onOpenToTabConsumed?.();
}
}, [openToTab]);

const handleLaunchAtLoginChange = (checked: boolean) => {
setLaunchAtLogin(checked);
window.electronAPI.setLoginItemSettings(checked);
Expand Down
3 changes: 3 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
getHomeDir: () => ipcRenderer.invoke('get-home-dir'),
getBannerSeen: () => ipcRenderer.invoke('get-banner-seen'),
setBannerSeen: () => ipcRenderer.send('set-banner-seen'),
invokeVSCode: (path: string, option: string) =>
ipcRenderer.send('invoke-vscode', path, option),

Expand Down
23 changes: 17 additions & 6 deletions src/session-status-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export const watchStatusDir = (
/**
* Scan active sessions' JSONL files to determine initial status
* (for sessions started before CodeV or before hooks were installed).
* Reads last ~50 lines of each session's JSONL to check:
* Reads last ~15 lines of each session's JSONL to check:
* - Pending AskUserQuestion tool use → needs-attention
* - Last assistant message with stop_reason "end_turn" → idle
* - Otherwise → working
Expand All @@ -270,7 +270,11 @@ export const scanInitialStatuses = async (
const { execFile } = require('child_process');
const tailFile = (filePath: string): Promise<string> =>
new Promise((resolve) => {
execFile('tail', ['-n', '50', filePath], { encoding: 'utf-8', timeout: 3000 }, (err: any, stdout: string) => {
// Use 15 lines (not 50) — large assistant messages can make 50 lines > 1MB,
// exceeding execFile's maxBuffer and silently failing.
// The last ~5-10 lines are usually system entries; assistant entry is typically within 15.
execFile('tail', ['-n', '15', filePath], { encoding: 'utf-8', timeout: 3000, maxBuffer: 5 * 1024 * 1024 }, (err: any, stdout: string) => {
if (err) console.error(`[session-status] tailFile error for ${path.basename(filePath)}:`, err.message || err);
resolve(err ? '' : stdout);
});
});
Expand All @@ -290,7 +294,7 @@ export const scanInitialStatuses = async (
const jsonlPath = path.join(claudeDir, encodedProject, `${session.sessionId}.jsonl`);
if (!fs.existsSync(jsonlPath)) return;

// Read last 50 lines
// Read last 15 lines
const tail = await tailFile(jsonlPath);
if (!tail.trim()) return;

Expand Down Expand Up @@ -359,10 +363,15 @@ export const cleanupStaleStatuses = (activeSessionIds: Set<string>): void => {
if (!file.endsWith('.json')) continue;
const sessionId = file.replace('.json', '');
if (!activeSessionIds.has(sessionId)) {
try { fs.unlinkSync(path.join(STATUS_DIR, file)); } catch {}
console.log(`[session-status] cleanup: removing stale status file ${file}`);
try { fs.unlinkSync(path.join(STATUS_DIR, file)); } catch (err) {
console.error(`[session-status] cleanup: failed to delete ${file}:`, err);
}
}
}
} catch {}
} catch (err) {
console.error('[session-status] cleanup error:', err);
}
};

/**
Expand All @@ -375,7 +384,9 @@ export const writeStatusFile = (sessionId: string, status: string): void => {
const targetFile = path.join(STATUS_DIR, `${sessionId}.json`);
fs.writeFileSync(tmpFile, JSON.stringify({ status, timestamp: Math.floor(Date.now() / 1000), cwd: '' }));
fs.renameSync(tmpFile, targetFile);
} catch {}
} catch (err) {
console.error(`[session-status] writeStatusFile failed for ${sessionId}:`, err);
}
};

export { STATUS_DIR, HOOK_SCRIPT_PATH };
Loading