From b18d2fc5a417b4546d0cb9e54862ba79cd121c2d Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 7 Apr 2026 16:18:33 +0300 Subject: [PATCH 1/3] feat: git-aware project grouping with Q&A session list in Projects view --- src/frontend/app.js | 70 ++++++++++++++++++++++--------- src/frontend/styles.css | 93 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/src/frontend/app.js b/src/frontend/app.js index a352ef3..4a65dee 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -55,6 +55,17 @@ function getProjectName(fullPath) { return parts[parts.length - 1] || 'unknown'; } +// Returns the git repo name, stripping /.claude/worktrees/ and /.codex/ suffixes +function getGitProjectName(fullPath) { + 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 +1138,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); + 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 +1176,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; From a9f2ddf0ad5c3525f3491871f89d8e4af4442b0f Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 7 Apr 2026 16:35:48 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20git=20root=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20git=20rev-parse=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=82=D1=82=D0=B5=D1=80=D0=BD-=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BF=D1=83=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data.js | 24 ++++++++++++++++++++++++ src/frontend/app.js | 9 ++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/data.js b/src/data.js index 50f3569..c1cc18c 100644 --- a/src/data.js +++ b/src/data.js @@ -703,6 +703,25 @@ function scanCodexSessions() { return sessions; } +// ── Git root resolver ─────────────────────────────────────── + +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 { + _gitRootCache[projectPath] = ''; + return ''; + } +} + // ── Public API ───────────────────────────────────────────── let _sessionsCache = null; @@ -927,11 +946,16 @@ 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'); + s.git_root = s.project ? (_gitRootCache[s.project] || '') : ''; } _sessionsCache = result; diff --git a/src/frontend/app.js b/src/frontend/app.js index 4a65dee..c211145 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -55,8 +55,11 @@ function getProjectName(fullPath) { return parts[parts.length - 1] || 'unknown'; } -// Returns the git repo name, stripping /.claude/worktrees/ and /.codex/ suffixes -function getGitProjectName(fullPath) { +// 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\//); @@ -1162,7 +1165,7 @@ function renderQACard(s, idx) { function renderProjects(container, sessions) { var byGit = {}; sessions.forEach(function(s) { - var name = getGitProjectName(s.project); + var name = getGitProjectName(s.project, s.git_root); if (!byGit[name]) byGit[name] = []; byGit[name].push(s); }); From c721eb28877a49c79d7af5f8028e91887d94b0f7 Mon Sep 17 00:00:00 2001 From: Pawel Date: Tue, 7 Apr 2026 17:13:22 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20worktree-state.originalC?= =?UTF-8?q?wd=20=D0=BA=D0=B0=D0=BA=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=20git=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена поддержка записи worktree-state, которую Claude Code записывает в начало JSONL-файла при работе внутри git-воркдерева. worktreeSession.originalCwd указывает на директорию основного чекаута и используется как git_root с наивысшим приоритетом. Это решение не требует доступа к git и корректно работает в контейнерных окружениях, где git-репозитории не примонтированы (см. #37). Порядок приоритетов: 1. worktree-state.originalCwd (container-safe, из JSONL) 2. git rev-parse --show-toplevel (runtime, с graceful fallback) 3. path heuristic /.claude/worktrees/ (frontend, строковый матчинг) --- src/data.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/data.js b/src/data.js index c1cc18c..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; } @@ -704,6 +715,15 @@ function scanCodexSessions() { } // ── 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 = {}; @@ -717,6 +737,7 @@ function resolveGitRoot(projectPath) { _gitRootCache[projectPath] = root; return root; } catch { + // git not available or project path not mounted (e.g. containerised env) — fall back gracefully _gitRootCache[projectPath] = ''; return ''; } @@ -867,6 +888,7 @@ function loadSessions() { detail_messages: summary.msgCount, _claude_dir: extraClaudeDir, _session_file: fp, + worktree_original_cwd: summary.worktreeOriginalCwd || '', }; } } @@ -938,6 +960,7 @@ function loadSessions() { detail_messages: summary.msgCount, _claude_dir: CLAUDE_DIR, _session_file: filePath, + worktree_original_cwd: summary.worktreeOriginalCwd || '', }; } } @@ -955,7 +978,8 @@ function loadSessions() { 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'); - s.git_root = s.project ? (_gitRootCache[s.project] || '') : ''; + // 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;