From 67eb8e40668cf6015a8dd60bb37dc130d94cf9f9 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 02:19:11 +0800 Subject: [PATCH 01/23] feat(multi-account): new-session account picker (Batch 2c-lite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alt+Cmd+Enter on a project opens a small account picker (arrow keys + Enter, Esc cancels, click works too); the chosen account launches with an explicit CLAUDE_CONFIG_DIR + `command claude`, bypassing the shell dispatcher — mirroring buildResumeCommand. - Plain Cmd+Enter is unchanged: instant launch under the global default (bare `claude` -> dispatcher). Single-account or registry-less setups never see the picker (falls through to a normal launch). - launchNewClaudeSession gains an optional accountLabel; the codev embedded terminal and VS Code paths ignore it (logged) — the picker targets external terminals. - The overlay reuses the data-settings-panel escape hatch so the focus-stealing click handler leaves it alone. - Cross-account RESUME stays deferred to Batch 3 (copy-fork design). tsc 0 errors; 17/17 tests. Bump 1.0.80 + CHANGELOG + design doc. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- CHANGELOG.md | 7 ++ docs/multi-account-support-design.md | 8 +- package.json | 2 +- src/claude-session-utility.ts | 27 +++++- src/electron-api.d.ts | 2 +- src/main.ts | 4 +- src/preload.ts | 4 +- src/switcher-ui.tsx | 132 ++++++++++++++++++++++++++- 8 files changed, 174 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 169a805..f9ae388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.0.80 + +- Feat: multi-account Batch 2c-lite — pick the account when launching a new session + - `⌥⌘+Enter` on a project opens a small account picker (↑↓ / Enter / Esc, or click); the chosen account launches via explicit `CLAUDE_CONFIG_DIR` + `command claude` + - Plain `⌘+Enter` unchanged: opens instantly under the global default; single-account (or registry-less) setups never see the picker + - Cross-account *resume* stays deferred to Batch 3 (copy-fork design; a session's transcript lives in its own account) + ## 1.0.79 - Feat: multi-account Batch 2b (part 2) — `codev` command in your shell diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index a92f38f..fda3acf 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -461,7 +461,13 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a real `.app` location in the registry (`appPath`) on every launch and refreshes accounts.sh, so app moves and generator updates self-heal. CLI is tsc-compiled into `resources/cli` (extraResource) at build time. - - *2c:* account picker / per-launch override (§6.G, 4.2). + - *2c-lite (✅, branch `feat/new-session-account-picker`):* NEW-session account + picker — ⌥⌘+Enter on a project opens a picker (⌘+Enter unchanged → global + default; single-account setups never see it); launches with explicit + `CLAUDE_CONFIG_DIR` + `command claude`. Cross-account *resume* (the other half + of §6.G) stays deferred to Batch 3 as explicit copy-fork — a transcript lives in + its own account's projects dir, so "resume under another account" must copy, never + link. "This repo always opens under X" belongs to 2d's projectMap. - *2d:* pyenv-style folder auto-switch + terminal-side resume-to-right-account (§6.H). - *2e:* configurable global-default (bare `claude` → chosen account) — last, since it also needs the CLI/UI to manage. diff --git a/package.json b/package.json index d7b2758..c13a498 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.79", + "version": "1.0.80", "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 8c351c5..f75b928 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -8,7 +8,11 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { getCurrentIDEBundleId } from './vscode-based-ide-utility'; -import { getScannableAccounts, getProjectsDir } from './accounts'; +import { + getScannableAccounts, + getProjectsDir, + getAccountByLabel, +} from './accounts'; export interface ClaudeSession { sessionId: string; @@ -1389,6 +1393,7 @@ export const launchNewClaudeSession = ( projectPath: string, terminalApp: string = 'iterm2', terminalMode: string = 'tab', + accountLabel?: string, ): void => { if (terminalApp === 'vscode') { const { execFile } = require('child_process'); @@ -1411,12 +1416,30 @@ export const launchNewClaudeSession = ( return; } if (terminalApp === 'codev') { + // Embedded terminal spawns the user's shell; the account override isn't + // threaded through it yet (the 2c-lite picker targets external terminals). + if (accountLabel) { + console.log( + '[launchNewClaudeSession] account override ignored for codev terminal:', + accountLabel, + ); + } if (launchInCodevTerminalCallback) { launchInCodevTerminalCallback(projectPath); } return; } - runCommandInTerminal(`cd "${projectPath}" && claude`, 'claude', projectPath, terminalApp, terminalMode); + // No accountLabel: bare `claude` → the accounts.sh dispatcher → the user's + // global default (2e). With an explicit account (2c-lite picker): bypass the + // dispatcher via `command claude` + explicit env, like buildResumeCommand. + let claudeCmd = 'claude'; + if (accountLabel) { + const account = getAccountByLabel(accountLabel); + claudeCmd = account?.configDirEnv + ? `CLAUDE_CONFIG_DIR='${account.configDirEnv.replace(/'/g, "'\\''")}' command claude` + : 'command claude'; + } + runCommandInTerminal(`cd "${projectPath}" && ${claudeCmd}`, 'claude', projectPath, terminalApp, terminalMode); }; /** diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 3fa2c34..aa8958d 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -98,7 +98,7 @@ interface IElectronAPI { scanClosedVSCodeSessions: (activeSessionIds: string[]) => Promise; refreshSessionPreview: (sessions: any[]) => Promise>; openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void; - launchNewClaudeSession: (projectPath: string) => void; + launchNewClaudeSession: (projectPath: string, accountLabel?: string) => void; launchNewClaudeSessionInCodev: (projectPath: string) => void; copyClaudeSessionCommand: (sessionId: string, projectPath: string) => void; loadSessionEnrichment: (sessions: any[]) => Promise<{ titles: Record; branches: Record; prLinks: Record }>; diff --git a/src/main.ts b/src/main.ts index 243216c..6f5d925 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2276,14 +2276,14 @@ ipcMain.on('open-claude-session', async (_event, sessionId: string, projectPath: openSession(sessionId, projectPath, isActive, activePid, terminalApp, terminalMode, customTitle); }); -ipcMain.on('launch-new-claude-session', async (_event, projectPath: string) => { +ipcMain.on('launch-new-claude-session', async (_event, projectPath: string, accountLabel?: string) => { if (!existsSync(projectPath)) { console.log('[launch-new-claude-session] path does not exist:', projectPath); return; } const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; - launchNewClaudeSession(projectPath, terminalApp, terminalMode); + launchNewClaudeSession(projectPath, terminalApp, terminalMode, accountLabel); }); ipcMain.on('launch-new-claude-session-in-codev', (_event, projectPath: string) => { diff --git a/src/preload.ts b/src/preload.ts index 9a68408..739bd75 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -71,8 +71,8 @@ contextBridge.exposeInMainWorld('electronAPI', { refreshSessionPreview: (sessions: any[]) => ipcRenderer.invoke('refresh-session-preview', sessions), openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => ipcRenderer.send('open-claude-session', sessionId, projectPath, isActive, activePid, customTitle), - launchNewClaudeSession: (projectPath: string) => - ipcRenderer.send('launch-new-claude-session', projectPath), + launchNewClaudeSession: (projectPath: string, accountLabel?: string) => + ipcRenderer.send('launch-new-claude-session', projectPath, accountLabel), launchNewClaudeSessionInCodev: (projectPath: string) => ipcRenderer.send('launch-new-claude-session-in-codev', projectPath), copyClaudeSessionCommand: (sessionId: string, projectPath: string) => diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index a795d93..b780cfc 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -269,7 +269,42 @@ const formatRelativeTime = (timestamp: string): string => { let loadTimes = 0; function SwitcherApp() { const optionPress = useRef(false); - const launchClaudeRef = useRef<'external' | 'codev' | null>(null); + const launchClaudeRef = useRef<'external' | 'codev' | 'external-pick' | null>(null); + + // 2c-lite: ⌥⌘+Enter — pick the account for a new-session launch. + type LaunchAccount = { + label: string; + email?: string; + loggedIn?: boolean; + isCurrentDefault: boolean; + }; + const [launchPicker, setLaunchPicker] = useState<{ + path: string; + accounts: LaunchAccount[]; + } | null>(null); + const [launchPickerIndex, setLaunchPickerIndex] = useState(0); + + const openAccountPickerForLaunch = async (projectPath: string) => { + try { + const r = await window.electronAPI.getAccounts(); + const accounts = ((r.ok && r.accounts) || []) as LaunchAccount[]; + if (accounts.length <= 1) { + // Single account (or no registry): nothing to pick — launch as usual. + window.electronAPI.launchNewClaudeSession(projectPath); + return; + } + setLaunchPickerIndex(0); + setLaunchPicker({ path: projectPath, accounts }); + } catch { + window.electronAPI.launchNewClaudeSession(projectPath); + } + }; + + const pickLaunchAccount = (account: LaunchAccount) => { + if (!launchPicker) return; + window.electronAPI.launchNewClaudeSession(launchPicker.path, account.label); + setLaunchPicker(null); + }; const ref = useRef(null); const sessionSearchRef = useRef(null); @@ -1496,7 +1531,13 @@ function SwitcherApp() { // Clear on every keypress to prevent stale ref if onChange didn't fire launchClaudeRef.current = null; if (evt.key === 'Enter' && (evt.metaKey || evt.shiftKey)) { - launchClaudeRef.current = evt.shiftKey ? 'codev' : 'external'; + // ⌥⌘+Enter: choose the account first (multi-account, 2c-lite) + launchClaudeRef.current = + evt.metaKey && evt.altKey + ? 'external-pick' + : evt.shiftKey + ? 'codev' + : 'external'; } }} onInputChange={(evt) => { @@ -1507,6 +1548,8 @@ function SwitcherApp() { launchClaudeRef.current = null; if (launchMode === 'codev') { window.electronAPI.launchNewClaudeSessionInCodev(evt.value); + } else if (launchMode === 'external-pick') { + openAccountPickerForLaunch(evt.value); } else if (launchMode === 'external') { window.electronAPI.launchNewClaudeSession(evt.value); } else { @@ -1520,7 +1563,10 @@ function SwitcherApp() { // Custom components components={{ DropdownIndicator: () => ( -
+
{'\u2318+Enter: New Claude'}
), @@ -1679,6 +1725,86 @@ function SwitcherApp() { }), }} /> + {launchPicker && ( +
setLaunchPicker(null)} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.45)', + zIndex: 1000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + }} + > +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Escape') { + setLaunchPicker(null); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setLaunchPickerIndex((i) => (i + 1) % launchPicker.accounts.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setLaunchPickerIndex( + (i) => (i - 1 + launchPicker.accounts.length) % launchPicker.accounts.length, + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + pickLaunchAccount(launchPicker.accounts[launchPickerIndex]); + } + }} + tabIndex={0} + ref={(el) => el?.focus()} + style={{ + marginTop: '120px', + minWidth: '320px', + background: '#252526', + border: '1px solid #454545', + borderRadius: '8px', + padding: '8px', + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)', + outline: 'none', + }} + > +
+ New Claude in {launchPicker.path.split('/').pop()} as… (↑↓ · Enter · Esc) +
+ {launchPicker.accounts.map((a, i) => ( +
pickLaunchAccount(a)} + onMouseEnter={() => setLaunchPickerIndex(i)} + style={{ + padding: '6px 8px', + borderRadius: '4px', + cursor: 'pointer', + backgroundColor: i === launchPickerIndex ? '#094771' : 'transparent', + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + }} + > + + {a.label} + {a.isCurrentDefault && ( + + default + + )} + + + {a.loggedIn ? a.email || '' : 'not logged in'} + +
+ ))} +
+
+ )}
))} From f7cf217713b4485cbc1b5a8e2f159da67cee7a06 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 02:32:27 +0800 Subject: [PATCH 02/23] feat(multi-account): show the picker hint only for multi-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search-bar hint becomes '⌘+Enter: New Claude · ⌥⌘+Enter: pick account' when more than one account is registered; single-account users keep the unchanged short hint (no clutter — user's concern). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- src/switcher-ui.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index b780cfc..c909c55 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -283,6 +283,9 @@ function SwitcherApp() { accounts: LaunchAccount[]; } | null>(null); const [launchPickerIndex, setLaunchPickerIndex] = useState(0); + // Multi-account? → advertise ⌥⌘+Enter in the search-bar hint (single-account + // users see the unchanged short hint — no extra clutter). + const [isMultiAccountUI, setIsMultiAccountUI] = useState(false); const openAccountPickerForLaunch = async (projectPath: string) => { try { @@ -594,6 +597,14 @@ function SwitcherApp() { setMode('terminal'); }); + // Account count decides whether the ⌥⌘+Enter picker hint is shown. + window.electronAPI + .getAccounts() + .then((r) => { + if (r.ok) setIsMultiAccountUI(((r.accounts || []) as unknown[]).length > 1); + }) + .catch(() => {}); + // If initial mode is sessions (from URL hash), fetch sessions immediately if (initialMode === 'sessions') { fetchClaudeSessions(); @@ -1567,7 +1578,9 @@ function SwitcherApp() { title="\u2325\u2318+Enter: choose the account first" style={{ fontSize: '11px', color: '#666', paddingRight: '8px', whiteSpace: 'nowrap' }} > - {'\u2318+Enter: New Claude'} + {isMultiAccountUI + ? '\u2318+Enter: New Claude \u00b7 \u2325\u2318+Enter: pick account' + : '\u2318+Enter: New Claude'} ), Option: (props) => OptionUI(props, onDeleteClick, (path: string) => { From 2cac70f1fc93c995c9e552bf295f6dbb1a00afb6 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 02:33:48 +0800 Subject: [PATCH 03/23] docs(multi-account): drop 2d (folder auto-switch) One folder routinely hosts sessions from multiple accounts (the real testing pattern), so a folder->account default would guess wrong where the choice matters most; 2e global default + 2c-lite picker cover the need. Feasible (a default, not a constraint) but dropped on value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- docs/multi-account-support-design.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index fda3acf..c4776ed 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -468,7 +468,14 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a of §6.G) stays deferred to Batch 3 as explicit copy-fork — a transcript lives in its own account's projects dir, so "resume under another account" must copy, never link. "This repo always opens under X" belongs to 2d's projectMap. - - *2d:* pyenv-style folder auto-switch + terminal-side resume-to-right-account (§6.H). + - *2d — ❌ dropped (2026-07-10):* pyenv-style folder auto-switch (§6.H). Rationale: + in practice one folder hosts sessions from MULTIPLE accounts (we test exactly this + way), so a folder→account default would guess wrong precisely where the account + choice matters; stacking a third decision layer (global default → folder default → + picker) adds "why did bare claude open work here?" confusion for thin value. The + need is covered by 2e (global default) + 2c-lite (explicit per-launch picker). + Technically it was feasible (a default, not a constraint — resume always follows + the session's own account); dropped on value, not feasibility. - *2e:* configurable global-default (bare `claude` → chosen account) — last, since it also needs the CLI/UI to manage. - **Batch 3 — later:** cross-account symlink reuse of global files From cf1c9dc8461ce7dd80c6dca8553941a34b9b1f63 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Fri, 10 Jul 2026 02:44:13 +0800 Subject: [PATCH 04/23] =?UTF-8?q?fix(multi-account):=20picker=20fixes=20?= =?UTF-8?q?=E2=80=94=20ghostty/cmux=20account,=20live=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: pass claudeCmd (not literal 'claude') as the 2nd arg to runCommandInTerminal — Ghostty/cmux build their launch scripts from it, so the picker's account env prefix was silently dropped there (iTerm2/Terminal.app were unaffected; resume already passed it) - picker: on getAccounts failure, don't guess an identity — log and abort instead of silently launching the global default - hint updates live: re-check the account count on a same-window codev-accounts-changed event (fired by the Settings popup after mutations) and on window focus (covers CLI-side changes) — no app restart needed (user-reported) - hide react-select's default IndicatorSeparator (the stray '|' that looks like a cursor before the hint; user-reported) - docs: mark 2e as shipped in §7 (review nit) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017kCK4t5JSVynVZxGcrTGGp --- docs/multi-account-support-design.md | 6 +++-- src/claude-session-utility.ts | 5 +++- src/popup.tsx | 3 +++ src/switcher-ui.tsx | 35 +++++++++++++++++++++------- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/multi-account-support-design.md b/docs/multi-account-support-design.md index c4776ed..0a3d56e 100644 --- a/docs/multi-account-support-design.md +++ b/docs/multi-account-support-design.md @@ -476,8 +476,10 @@ and **(d)** for CodeV, backed by the same `projectMap` in the registry so both a need is covered by 2e (global default) + 2c-lite (explicit per-launch picker). Technically it was feasible (a default, not a constraint — resume always follows the session's own account); dropped on value, not feasibility. - - *2e:* configurable global-default (bare `claude` → chosen account) — last, since it - also needs the CLI/UI to manage. + - *2e — ✅ shipped with 2a (PR #123, v1.0.77):* configurable global-default — + `codev account default