diff --git a/src/data.js b/src/data.js index 50f3569..b25ada8 100644 --- a/src/data.js +++ b/src/data.js @@ -90,6 +90,7 @@ function parseClaudeSessionFile(sessionFile) { let firstTs = stat.mtimeMs; let lastTs = stat.mtimeMs; let entrypointFound = false; + let worktreeOriginalCwd = ''; for (const line of lines) { try { @@ -102,6 +103,11 @@ function parseClaudeSessionFile(sessionFile) { if (!projectPath && entry.type === 'user' && entry.cwd) { projectPath = entry.cwd; } + // worktree-state is written by Claude Code when a session runs inside a git worktree. + // originalCwd is the main checkout directory — safe to use in containers (no git needed). + if (!worktreeOriginalCwd && entry.type === 'worktree-state' && entry.worktreeSession && entry.worktreeSession.originalCwd) { + worktreeOriginalCwd = entry.worktreeSession.originalCwd; + } if (!entrypointFound && entry.type === 'user' && entry.entrypoint) { entrypointFound = true; if (entry.entrypoint !== 'cli') tool = 'claude-ext'; @@ -126,6 +132,7 @@ function parseClaudeSessionFile(sessionFile) { firstTs, lastTs, fileSize: stat.size, + worktreeOriginalCwd, }; } @@ -143,6 +150,10 @@ function mergeClaudeSessionDetail(session, summary, sessionFile) { session.project_short = summary.projectPath.replace(os.homedir(), '~'); } + if (summary.worktreeOriginalCwd) { + session.worktree_original_cwd = summary.worktreeOriginalCwd; + } + if (summary.customTitle) { session.first_message = summary.customTitle; } @@ -703,6 +714,35 @@ function scanCodexSessions() { return sessions; } +// ── Git root resolver ─────────────────────────────────────── +// +// Priority order for determining the git root of a session: +// 1. worktree-state.originalCwd — written by Claude Code into the JSONL when +// the session runs inside a git worktree. Container-safe: no git required. +// 2. git rev-parse --show-toplevel — resolves the root at runtime. Fails +// gracefully (returns '') in containerized setups where git repos are not +// mounted; the try/catch ensures it never crashes the server. +// 3. Path heuristic in the frontend (getGitProjectName) — parses /.claude/worktrees/ +// from the session cwd string. Works without git for standard worktree layouts. + +const _gitRootCache = {}; + +function resolveGitRoot(projectPath) { + if (!projectPath) return ''; + if (_gitRootCache[projectPath] !== undefined) return _gitRootCache[projectPath]; + try { + const root = execSync(`git -C "${projectPath}" rev-parse --show-toplevel 2>/dev/null`, { + encoding: 'utf8', timeout: 2000 + }).trim(); + _gitRootCache[projectPath] = root; + return root; + } catch { + // git not available or project path not mounted (e.g. containerised env) — fall back gracefully + _gitRootCache[projectPath] = ''; + return ''; + } +} + // ── Public API ───────────────────────────────────────────── let _sessionsCache = null; @@ -848,6 +888,7 @@ function loadSessions() { detail_messages: summary.msgCount, _claude_dir: extraClaudeDir, _session_file: fp, + worktree_original_cwd: summary.worktreeOriginalCwd || '', }; } } @@ -919,6 +960,7 @@ function loadSessions() { detail_messages: summary.msgCount, _claude_dir: CLAUDE_DIR, _session_file: filePath, + worktree_original_cwd: summary.worktreeOriginalCwd || '', }; } } @@ -927,11 +969,17 @@ function loadSessions() { const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts); + // Collect unique project paths and resolve git roots in one pass + const uniquePaths = [...new Set(result.map(s => s.project).filter(Boolean))]; + for (const p of uniquePaths) resolveGitRoot(p); + for (const s of result) { s.first_time = new Date(s.first_ts).toLocaleString('sv-SE').slice(0, 16); s.last_time = new Date(s.last_ts).toLocaleString('sv-SE').slice(0, 16); const dt = new Date(s.last_ts); s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0'); + // Priority: worktree-state.originalCwd (container-safe) > git rev-parse > path heuristic (frontend) + s.git_root = s.worktree_original_cwd || (s.project ? (_gitRootCache[s.project] || '') : ''); } _sessionsCache = result; diff --git a/src/frontend/app.js b/src/frontend/app.js index a352ef3..c211145 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -55,6 +55,20 @@ function getProjectName(fullPath) { return parts[parts.length - 1] || 'unknown'; } +// Returns the git repo name from session data. +// Prefers s.git_root resolved by the backend (git rev-parse --show-toplevel), +// falls back to path-based heuristic for sessions without it. +function getGitProjectName(fullPath, gitRoot) { + if (gitRoot) return gitRoot.replace(/\/+$/, '').split('/').pop() || 'unknown'; + if (!fullPath) return 'unknown'; + var cleaned = fullPath.replace(/\/+$/, ''); + var wt = cleaned.match(/^(.*?)\/.claude\/worktrees\//); + if (wt) return wt[1].split('/').pop() || 'unknown'; + var codex = cleaned.match(/^(.*?)\/.codex\//); + if (codex) return codex[1].split('/').pop() || 'unknown'; + return cleaned.split('/').pop() || 'unknown'; +} + // ── Utilities ────────────────────────────────────────────────── function timeAgo(dateStr) { @@ -1127,16 +1141,37 @@ function renderTimeline(container, sessions) { container.innerHTML = html; } +function renderQACard(s, idx) { + var isStarred = stars.indexOf(s.id) >= 0; + var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; + var toolClass = 'tool-' + s.tool; + var cost = estimateCost(s.file_size); + var costStr = cost > 0 ? '~$' + cost.toFixed(2) : ''; + var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : ''); + + var html = '