diff --git a/CHANGELOG.md b/CHANGELOG.md index 2043815..b7e514e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.0.75 + +- Feat: Launch new Claude session as a git worktree from the Projects tab + - Press `⌘+Shift+Enter` on a project → dialog asks for branch name (optional) + - With a name: runs `claude -w "" -n ""` in the configured terminal + - Without a name: behaves like the existing `⌘+Enter` (normal session) + - The `-n` flag also sets a custom title so Ghostty's title-match can locate + the right tab when switching back (Ghostty has no per-tab TTY exposure). +- Feat: worktree-aware session display + - Worktree session paths (`/.claude/worktrees/`) now show the + parent repo name (e.g., `codev`) with a small `WT` badge, instead of the + worktree folder name (e.g., `test-worktree-4`). + - New `parseWorktreePath()` helper + `isWorktree` / `parentRepo` fields on + `ClaudeSession`. +- Fix: Ghostty session-switching no longer "jumps to the wrong window" on + match miss + - The `activate` AppleScript call is now inside the success branch, so we + don't bring Ghostty forward when no matching tab is found. + - Benefits all session switches, not just worktrees. + ## 1.0.74 - Fix: session status dot stuck on purple for sessions with large responses (#116) diff --git a/README.md b/README.md index e5fc6d1..e462b7b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Press `⌃+⌘+R` or click the menu bar icon to launch the Quick Switcher. Searc - Supports VS Code and Cursor — switch between them in Settings → General → IDE - Remove items from the recent list by hovering and clicking "x" - **Quick-launch Claude session**: `⌘+Enter` to launch a new Claude Code session in the configured terminal, `⇧+Enter` to launch in CodeV's embedded terminal, `⌘+Click` as mouse alternative +- **Launch as git worktree**: `⌘+Shift+Enter` opens a small dialog where you enter a branch name. CodeV launches `claude -w "" -n ""` so Claude creates a fresh worktree at `/.claude/worktrees/`. Leaving the name empty falls back to a normal session. Worktree sessions show the parent project name with a `WT` badge in the Sessions list. ### Claude Code Session Switching diff --git a/docs/claude-session-integration-design.md b/docs/claude-session-integration-design.md index bad7e42..9baf3bb 100644 --- a/docs/claude-session-integration-design.md +++ b/docs/claude-session-integration-design.md @@ -542,6 +542,83 @@ Ghostty has full AppleScript support via `Ghostty.sdef`: - Custom terminal command template - Per-terminal PID/TTY matching (pending upstream: Ghostty #11592, cmux #1826) +## Git Worktree Sessions + +Claude Code's `claude -w ` creates a git worktree at `/.claude/worktrees/` and starts a session inside it. CodeV exposes this from the Projects tab via `⌘+Shift+Enter`. + +### Why we leverage `claude -w` instead of `git worktree add` ourselves + +Two approaches were considered: + +| Approach | Description | Trade-off | +|---|---|---| +| **A. `git worktree add`** (codev manages) | Create worktree at sibling path (`/-`), launch terminal there. claude-control uses this. | Full control, sibling visibility, **no IDE/git-GUI clutter inside the parent repo**. But we own all lifecycle (cleanup UI, branch conflicts, error handling). | +| **B. `claude -w -n `** (chosen) | Let Claude CLI create+manage the worktree. Pass `-n` to set a custom title. | Minimal code, leverages Claude CLI features (auto-cleanup, tmux). **Downside: nested worktree folder visible to VS Code / git GUI** — users may see worktree files in their workspace, accidentally commit them, or include them in cross-repo searches. Mitigation: `.gitignore` `.claude/worktrees/`. | + +We chose **B** because codev is Claude-Code-focused (claude-control is multi-tool, so they need their own implementation). Claude CLI's worktree lifecycle (auto-cleanup if clean) is the right default for codev users. + +**A and B are not mutually exclusive.** A future iteration could add a setting to choose between them, or fall back to A for users who hit B's limitations (e.g., the IDE-clutter concern). + +### The `-n` flag and Ghostty switch + +`claude -w ` alone breaks Ghostty session switching: + +- **Worktree terminal cwd = parent repo** (because we `cd && claude -w `; the shell stays in parent, only the claude process internally cd's into the worktree). +- **Main session terminal cwd = parent repo** too. +- Ghostty's AppleScript `working directory of term` reports the shell's cwd → both terminals report the same cwd → AppleScript "first match wins" focuses the wrong tab. + +Fix: pass `-n ` so Claude CLI sets a custom title. Codev's existing AppleScript title-match (Layer 1) finds the correct tab regardless of cwd. Other terminals (iTerm2 / Terminal.app / cmux) use TTY match and were already unaffected. + +**Future:** once Ghostty exposes per-tab TTY ([ghostty-org/ghostty#11354](https://github.com/ghostty-org/ghostty/pull/11354), merged but not released yet), Ghostty switch can use TTY match like the other terminals. The `-n` flag and the title-match layer become optional at that point — we can drop them or keep them as defense-in-depth. + +### AppleScript switch latency + +The Ghostty / Terminal.app / iTerm2 switch scripts iterate `windows × tabs × terminals` to find a match. Latency therefore scales with the user's open terminal count. With many windows/tabs (10+ Ghostty tabs, 50+ Terminal.app tabs) `osascript` invocation can be perceptibly slow (hundreds of ms). This is independent of worktree code — it predates this PR. Mitigations to investigate later: cache window IDs by sessionId, short-circuit on first match, or use TTY/PID directly once Ghostty exposes them. + +### AppleScript `activate` timing + +Old behavior: +```applescript +tell application "Ghostty" + activate -- always brings Ghostty front + ... match logic ... + return "not found" +end tell +``` + +When match failed, `activate` had already run, so Ghostty would surface its last-active window — looking like "switched to the wrong tab". Fixed by moving `activate` inside each match success branch. + +### Worktree session display + +`parseWorktreePath()` recognizes `/.claude/worktrees/` paths and exposes: +- `projectName` = parent repo name (e.g., `codev` instead of `test-worktree-4`) +- `isWorktree` = true → renders a `WT` badge in the Sessions list +- `parentRepo` = parent repo path (kept for grouping/display logic) + +The `project` field still holds the original worktree path so resume / cwd matching continue to work. + +### Resume after Claude CLI auto-cleanup + +When a `-w` session exits without uncommitted changes, Claude CLI may remove the worktree directory. The session JSONL is stored at `~/.claude/projects//.jsonl` (independent of the worktree directory) so `claude --resume ` still works — the cwd shown in the session header just reflects wherever resume was invoked from. No special handling needed in codev. + +### Detection is path-based, not launch-based + +`parseWorktreePath()` recognizes any path matching `/.claude/worktrees/` — regardless of who created the worktree. This means codev shows the `WT` badge for: + +- Sessions launched via codev's `⌘+Shift+Enter` (this PR's feature) +- Sessions where the user manually ran `claude -w ` from a terminal +- Sessions created by other tools (c9watch, older claude-control versions, etc.) that put worktrees in `.claude/worktrees/` + +If we ever switch to **Approach A** (codev-managed sibling worktrees at `/-`), the detection path can be expanded to recognize **both** patterns simultaneously — the nested pattern is independent of how new worktrees are created. So switching launch strategy doesn't break detection of pre-existing nested worktrees. + +### Future-proofing: when Ghostty exposes per-tab TTY + +The `-n` flag (and the title-match layer) is currently the only way to disambiguate Ghostty tabs that share a cwd. Once Ghostty's AppleScript surface exposes per-tab TTY ([ghostty-org/ghostty#11354](https://github.com/ghostty-org/ghostty/pull/11354), merged but unreleased as of v1.0.75), Ghostty switching can use TTY match the same way iTerm2 / Terminal.app / cmux do. At that point: + +- The `-n` flag becomes optional — tab disambiguation no longer relies on title +- Codev should add a Ghostty TTY-match layer (parallel to existing iTerm2 / Terminal.app code) +- Existing `-n`-set sessions keep working (title match still functions as a Layer 1 fast path) + ## Technical Decisions ### TypeScript vs Rust diff --git a/package.json b/package.json index 9d84019..df5dc96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.74", + "version": "1.0.75", "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 e86abde..0d114c3 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -11,8 +11,8 @@ import { getCurrentIDEBundleId } from './vscode-based-ide-utility'; export interface ClaudeSession { sessionId: string; - project: string; // full path, e.g. /Users/grimmer/git/fred-ff - projectName: string; // folder name, e.g. fred-ff + project: string; // full path, e.g. /Users/grimmer/git/fred-ff or /.claude/worktrees/ + projectName: string; // display name: parent repo name if worktree, else folder name firstUserMessage: string; lastUserMessage: string; lastAssistantMessage?: string; // only loaded for active sessions @@ -22,8 +22,56 @@ export interface ClaudeSession { activePid?: number; terminalApp?: string; // detected terminal: 'iterm2', 'cmux', 'ghostty', 'vscode', etc. entrypoint?: string; // 'cli', 'claude-vscode', etc. + isWorktree?: boolean; // true if project path is /.claude/worktrees/ + parentRepo?: string; // for worktree sessions: the parent repo path } +/** + * Parse a Claude Code worktree path into parent repo + worktree name. + * Returns null if the path is not a worktree path. + * E.g. /Users/me/git/codev/.claude/worktrees/fix-bug + * → { parentRepo: '/Users/me/git/codev', worktreeName: 'fix-bug' } + */ +export const parseWorktreePath = ( + p: string, +): { parentRepo: string; worktreeName: string } | null => { + if (!p) return null; + const match = p.match(/^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/); + return match ? { parentRepo: match[1], worktreeName: match[2] } : null; +}; + +/** + * Validate a worktree name before passing it into the launch shell command. + * Whitelist: alphanumerics + a few branch-name-safe punctuation chars. + * Rejects shell metacharacters ($ ` " ' \ ; | & space etc.) so the name + * cannot break out of the quoted argument in `claude -w "" -n ""`. + * + * Conservative subset of git's branch name rules: + * - allows: a-z A-Z 0-9 / - _ . + + * - disallows: leading/trailing dot, leading dash, double slash, + * leading/trailing slash, anything else. + */ +export const isValidWorktreeName = (name: string): boolean => { + if (!name) return false; + if (name.length > 100) return false; + if (!/^[A-Za-z0-9_./+-]+$/.test(name)) return false; + if (name.startsWith('-') || name.startsWith('.')) return false; + if (name.startsWith('/') || name.endsWith('/')) return false; + if (name.endsWith('.')) return false; + if (name.includes('//')) return false; + if (name.includes('..')) return false; + return true; +}; + +/** + * Compute the display project name. For worktree paths, returns parent repo name. + */ +export const getProjectDisplayName = (projectPath: string): string => { + const worktree = parseWorktreePath(projectPath); + const displayPath = worktree ? worktree.parentRepo : projectPath; + return path.basename(displayPath) || displayPath; +}; + export interface ActiveSessionResult { activeMap: Map; // sessionId -> pid (all active sessions) vscodeSessions: ClaudeSession[]; // VS Code sessions not in history.jsonl @@ -133,16 +181,22 @@ export const readClaudeSessions = (limit = 100): ClaudeSession[] => { const allSessions = Array.from(bySession.values()) .sort((a, b) => b.lastTimestamp - a.lastTimestamp) - .map((s) => ({ - sessionId: s.sessionId, - project: s.project, - projectName: path.basename(s.project) || s.project, - firstUserMessage: s.firstDisplay, - lastUserMessage: s.lastDisplay, - lastTimestamp: s.lastTimestamp, - messageCount: s.promptCount, - isActive: false, - })); + .map((s) => { + const worktree = parseWorktreePath(s.project); + return { + sessionId: s.sessionId, + project: s.project, + projectName: worktree + ? path.basename(worktree.parentRepo) || worktree.parentRepo + : (path.basename(s.project) || s.project), + firstUserMessage: s.firstDisplay, + lastUserMessage: s.lastDisplay, + lastTimestamp: s.lastTimestamp, + messageCount: s.promptCount, + isActive: false, + ...(worktree && { isWorktree: true, parentRepo: worktree.parentRepo }), + }; + }); cachedSessions = allSessions; cacheTimestamp = now; @@ -825,10 +879,13 @@ export const scanClosedVSCodeSessions = async ( ); // Use actual cwd from JSONL content, fall back to hooks index cwd const actualCwd = info.cwd || cwd; + const worktree = parseWorktreePath(actualCwd); sessions.push({ sessionId, project: actualCwd, - projectName: path.basename(actualCwd) || actualCwd, + projectName: worktree + ? path.basename(worktree.parentRepo) || worktree.parentRepo + : (path.basename(actualCwd) || actualCwd), firstUserMessage: info.firstUserMessage, lastUserMessage: info.lastUserMessage, lastAssistantMessage: info.lastAssistantMessage, @@ -836,6 +893,7 @@ export const scanClosedVSCodeSessions = async ( messageCount: info.messageCount, isActive: false, entrypoint: 'claude-vscode', + ...(worktree && { isWorktree: true, parentRepo: worktree.parentRepo }), }); }); @@ -903,12 +961,16 @@ export const detectActiveSessions = async (): Promise => { activeMap.set(sessionId, pid); // Queue async JSONL read (head/tail in parallel) const startedAt = data.startedAt; + const activeWorktree = parseWorktreePath(cwd); vscodeReadPromises.push( readVSCodeSessionFromJSONL(sessionId, cwd, execPromise).then((info) => { vscodeSessions.push({ sessionId, project: cwd, - projectName: path.basename(cwd) || cwd, + projectName: activeWorktree + ? path.basename(activeWorktree.parentRepo) || activeWorktree.parentRepo + : (path.basename(cwd) || cwd), + ...(activeWorktree && { isWorktree: true, parentRepo: activeWorktree.parentRepo }), firstUserMessage: info.firstUserMessage, lastUserMessage: info.lastUserMessage, lastAssistantMessage: info.lastAssistantMessage, @@ -1166,14 +1228,16 @@ export const runCommandInTerminal = ( switch (terminalApp) { case 'ghostty': { const tmpScript = '/tmp/codev-ghostty-launch.scpt'; + // Escape double quotes in claudeCmd for AppleScript string embedding + const escapedCmd = claudeCmd.replace(/"/g, '\\"'); const launchScript = terminalMode === 'window' ? `tell application "Ghostty" - set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${claudeCmd}\\n"} + set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${escapedCmd}\\n"} new window with configuration cfg activate end tell` : `tell application "Ghostty" - set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${claudeCmd}\\n"} + set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${escapedCmd}\\n"} if (count windows) > 0 then activate new tab in front window with configuration cfg @@ -1317,6 +1381,7 @@ export const launchNewClaudeSession = ( projectPath: string, terminalApp: string = 'iterm2', terminalMode: string = 'tab', + worktreeName?: string, ): void => { if (terminalApp === 'vscode') { const { execFile } = require('child_process'); @@ -1344,7 +1409,24 @@ export const launchNewClaudeSession = ( } return; } - runCommandInTerminal(`cd "${projectPath}" && claude`, 'claude', projectPath, terminalApp, terminalMode); + // For worktree sessions: also pass -n so claude sets a custom title + // (matches the worktree name). This lets AppleScript title-match find the + // correct terminal tab in Ghostty (which lacks per-tab TTY exposure). + // Validate worktreeName to prevent shell injection — it's interpolated + // into a string that ends up in `do script "..."` AppleScript and an + // interactive shell command. + if (worktreeName && !isValidWorktreeName(worktreeName)) { + console.error( + '[launchNewClaudeSession] rejected invalid worktreeName:', + JSON.stringify(worktreeName), + ); + return; + } + const shortCmd = worktreeName + ? `claude -w "${worktreeName}" -n "${worktreeName}"` + : 'claude'; + const fullCmd = `cd "${projectPath}" && ${shortCmd}`; + runCommandInTerminal(fullCmd, shortCmd, projectPath, terminalApp, terminalMode); }; /** @@ -1698,7 +1780,9 @@ export const openSessionInGhostty = ( const { exec } = require('child_process'); if (isActive) { - // Two-layer matching: title first (precise), then cwd fallback + // Two-layer matching: title first (precise), then cwd fallback. + // IMPORTANT: only `activate` after a match is found — otherwise Ghostty + // jumps to the last-active window (visually "wrong window") on miss. const titleMatch = customTitle ? ` -- Layer 1: title matching @@ -1706,6 +1790,7 @@ export const openSessionInGhostty = ( repeat with t in tabs of w repeat with term in terminals of t if name of term contains "${customTitle.replace(/"/g, '\\"')}" then + activate focus term return "found-by-title" end if @@ -1716,13 +1801,13 @@ export const openSessionInGhostty = ( const tmpScript = '/tmp/codev-ghostty-switch.scpt'; const switchScript = `tell application "Ghostty" - activate ${titleMatch} -- Layer 2: cwd matching (fallback) repeat with w in windows repeat with t in tabs of w repeat with term in terminals of t if working directory of term is "${projectPath}" then + activate focus term return "found-by-cwd" end if diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index f021ec9..d82c878 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -68,6 +68,7 @@ interface IElectronAPI { refreshSessionPreview: (sessions: any[]) => Promise>; openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void; launchNewClaudeSession: (projectPath: string) => void; + launchNewClaudeSessionWorktree: (projectPath: string, worktreeName: 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 ac136ad..ba254c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { setLaunchInCodevTerminalCallback, launchNewClaudeSession, scanClosedVSCodeSessions, + isValidWorktreeName, } from './claude-session-utility'; import { installHooks, @@ -2149,6 +2150,21 @@ ipcMain.on('launch-new-claude-session', async (_event, projectPath: string) => { launchNewClaudeSession(projectPath, terminalApp, terminalMode); }); +ipcMain.on('launch-new-claude-session-worktree', async (_event, projectPath: string, worktreeName: string) => { + if (!existsSync(projectPath)) { + console.log('[launch-new-claude-session-worktree] path does not exist:', projectPath); + return; + } + // Validate at IPC boundary (defense in depth) — the renderer also validates. + if (!isValidWorktreeName(worktreeName)) { + console.error('[launch-new-claude-session-worktree] invalid worktreeName:', JSON.stringify(worktreeName)); + 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, worktreeName); +}); + ipcMain.on('launch-new-claude-session-in-codev', (_event, projectPath: string) => { if (!existsSync(projectPath)) { console.log('[launch-new-claude-session-in-codev] path does not exist:', projectPath); diff --git a/src/preload.ts b/src/preload.ts index a3f7bfa..b46365b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -65,6 +65,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.send('open-claude-session', sessionId, projectPath, isActive, activePid, customTitle), launchNewClaudeSession: (projectPath: string) => ipcRenderer.send('launch-new-claude-session', projectPath), + launchNewClaudeSessionWorktree: (projectPath: string, worktreeName: string) => + ipcRenderer.send('launch-new-claude-session-worktree', projectPath, worktreeName), 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 2f0a28a..e773fad 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -168,6 +168,23 @@ const acceleratorToSymbols = (acc: string): string => .replace(/Shift/g, '⇧') .replace(/\+/g, ''); +/** + * Validate worktree name (mirrors src/claude-session-utility.ts isValidWorktreeName). + * Whitelist of branch-name-safe chars; rejects shell metacharacters so the name + * cannot break out of the quoted shell argument in `claude -w "" -n ""`. + */ +const isValidWorktreeName = (name: string): boolean => { + if (!name) return false; + if (name.length > 100) return false; + if (!/^[A-Za-z0-9_./+-]+$/.test(name)) return false; + if (name.startsWith('-') || name.startsWith('.')) return false; + if (name.startsWith('/') || name.endsWith('/')) return false; + if (name.endsWith('.')) return false; + if (name.includes('//')) return false; + if (name.includes('..')) return false; + return true; +}; + let _homeDir = ''; let _homePrefix = ''; // Fetch home dir async on load, cache for sync access @@ -269,7 +286,7 @@ 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' | 'worktree' | null>(null); const ref = useRef(null); const sessionSearchRef = useRef(null); @@ -312,6 +329,9 @@ function SwitcherApp() { const [terminalApps, setTerminalApps] = useState>({}); const [sessionStatuses, setSessionStatuses] = useState>({}); const modeRef = useRef(initialMode); + const [worktreeDialog, setWorktreeDialog] = useState<{ projectPath: string; projectName: string } | null>(null); + const [worktreeBranch, setWorktreeBranch] = useState(''); + const worktreeInputRef = useRef(null); const activeStateRef = useRef>({}); const allSessionsRef = useRef([]); const lastAssistantFetchRef = useRef>({}); @@ -1239,6 +1259,25 @@ function SwitcherApp() { }} /> + {session.isWorktree && ( + + WT + + )} {customTitles[session.sessionId] && ( {' '}* { const launchMode = launchClaudeRef.current; launchClaudeRef.current = null; - if (launchMode === 'codev') { + if (launchMode === 'worktree') { + const name = evt.value?.split('/').pop() || evt.value; + setWorktreeDialog({ projectPath: evt.value, projectName: name }); + setWorktreeBranch(''); + setTimeout(() => worktreeInputRef.current?.focus(), 0); + } else if (launchMode === 'codev') { window.electronAPI.launchNewClaudeSessionInCodev(evt.value); } else if (launchMode === 'external') { window.electronAPI.launchNewClaudeSession(evt.value); @@ -1489,7 +1536,7 @@ function SwitcherApp() { components={{ DropdownIndicator: () => (
- {'\u2318+Enter: New Claude'} + {'\u2318+Shift+Enter: Worktree \u2318+Enter: External Shift+Enter: Codev'}
), Option: (props) => OptionUI(props, onDeleteClick, (path: string) => { @@ -1649,6 +1696,134 @@ function SwitcherApp() { /> ))} + {/* Worktree launch dialog */} + {worktreeDialog && (() => { + const trimmed = worktreeBranch.trim(); + const nameInvalid = trimmed.length > 0 && !isValidWorktreeName(trimmed); + const submit = () => { + if (trimmed) { + if (!isValidWorktreeName(trimmed)) return; // guard against bypass + window.electronAPI.launchNewClaudeSessionWorktree( + worktreeDialog.projectPath, + trimmed, + ); + } else { + window.electronAPI.launchNewClaudeSession(worktreeDialog.projectPath); + } + setWorktreeDialog(null); + }; + return ( +
setWorktreeDialog(null)} + // Stop document-level keydown handlers (Tab, arrows, etc.) from + // firing app-level shortcuts while the modal has focus + onKeyDown={(e) => e.stopPropagation()} + > +
e.stopPropagation()} + style={{ + backgroundColor: '#2a2a2a', + borderRadius: '8px', + padding: '20px', + width: '400px', + border: '1px solid #444', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)', + }} + > +
+ New Claude Session +
+
+ {worktreeDialog.projectName} +
+ +
+ Branch name (optional — creates a worktree) +
+ setWorktreeBranch(e.target.value)} + placeholder="e.g. fix/login-bug" + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + setWorktreeDialog(null); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (!nameInvalid) submit(); + } + }} + style={{ + width: '100%', + padding: '8px 10px', + backgroundColor: '#3c3c3c', + border: `1px solid ${nameInvalid ? '#e57373' : '#555'}`, + borderRadius: '4px', + color: '#eee', + fontSize: '13px', + outline: 'none', + boxSizing: 'border-box', + }} + onFocus={(e) => { if (!nameInvalid) e.currentTarget.style.borderColor = '#00BCD4'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = nameInvalid ? '#e57373' : '#555'; }} + /> +
+ {nameInvalid + ? 'Use only letters, digits, /, -, _, ., +. No leading dash/dot/slash.' + : 'Leave empty for a normal session (no worktree)'} +
+ +
+ + +
+
+
+ ); + })()} ); }