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 = '
'; + html += '' + escHtml(toolLabel) + ''; + html += '' + escHtml((s.first_message || '').slice(0, 160)) + ''; + html += ''; + html += '' + s.messages + ' msgs'; + if (costStr) html += '' + costStr + ''; + html += '' + timeAgo(s.last_ts) + ''; + html += ''; + html += ''; + html += '
'; + return html; +} + function renderProjects(container, sessions) { - var byProject = {}; + var byGit = {}; sessions.forEach(function(s) { - var p = getProjectName(s.project); - if (!byProject[p]) byProject[p] = { sessions: [], project: s.project }; - byProject[p].sessions.push(s); + var name = getGitProjectName(s.project, s.git_root); + if (!byGit[name]) byGit[name] = []; + byGit[name].push(s); }); - var sorted = Object.entries(byProject).sort(function(a, b) { - return b[1].sessions.length - a[1].sessions.length; + var sorted = Object.entries(byGit).sort(function(a, b) { + return b[1][0].last_ts - a[1][0].last_ts; }); if (sorted.length === 0) { @@ -1144,26 +1179,26 @@ function renderProjects(container, sessions) { return; } - var html = '
'; + var globalIdx = 0; + var html = '
'; sorted.forEach(function(entry) { var name = entry[0]; - var info = entry[1]; + var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); var color = getProjectColor(name); - var totalMsgs = info.sessions.reduce(function(sum, s) { return sum + (s.messages || 0); }, 0); - var totalSize = info.sessions.reduce(function(sum, s) { return sum + (s.file_size || 0); }, 0); - var latest = info.sessions[0]; + var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); + var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0); + var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; - html += '
'; - html += '
'; + html += '
'; + html += '
'; html += ''; - html += '' + escHtml(name) + ''; + html += '' + escHtml(name) + ''; + html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; + html += ''; html += '
'; - html += '
'; - html += '' + info.sessions.length + ' sessions'; - html += '' + totalMsgs + ' msgs'; - html += '' + formatBytes(totalSize) + ''; + html += '
'; + list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); html += '
'; - html += '
Last: ' + timeAgo(latest.last_ts) + '
'; html += '
'; }); html += '
'; diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 8dfa8a0..fc54fe1 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -1470,7 +1470,98 @@ body { white-space: nowrap; } -/* ── Projects grid ──────────────────────────────────────────── */ +/* ── Git Projects accordion ─────────────────────────────────── */ + +.git-projects { + display: flex; + flex-direction: column; + gap: 8px; +} + +.git-project-group { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.git-project-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + cursor: pointer; + transition: background 0.12s; + user-select: none; +} +.git-project-header:hover { background: var(--bg-card-hover); } + +.git-project-name { + font-size: 14px; + font-weight: 700; + flex: 0 0 auto; +} + +.git-project-stats { + font-size: 12px; + color: var(--text-muted); + flex: 1; +} + +.git-project-group .group-chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; +} +.git-project-group.collapsed .group-chevron { transform: rotate(-90deg); } + +/* ── QA session list ────────────────────────────────────────── */ + +.qa-list { + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; +} +.git-project-group.collapsed .qa-list { display: none; } + +.qa-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.12s; + min-width: 0; +} +.qa-item:last-child { border-bottom: none; } +.qa-item:hover { background: var(--bg-card-hover); } +.qa-item.selected { background: rgba(96, 165, 250, 0.08); } + +.qa-question { + flex: 1; + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.qa-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.qa-msgs, .qa-time { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +/* ── Projects grid (kept for reference) ─────────────────────── */ .projects-grid { display: grid;