Skip to content

Commit e043416

Browse files
NovakPAaiPawel
andauthored
feat: группировка по git-репо с Q&A-списком сессий в разделе Projects (#58)
* feat: git-aware project grouping with Q&A session list in Projects view * fix: резолвить git root через git rev-parse вместо паттерн-матчинга пути * fix: использовать worktree-state.originalCwd как основной источник git root Добавлена поддержка записи 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, строковый матчинг) --------- Co-authored-by: Pawel <pwlnvk@gmail.com>
1 parent 99da060 commit e043416

3 files changed

Lines changed: 194 additions & 20 deletions

File tree

src/data.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ function parseClaudeSessionFile(sessionFile) {
9090
let firstTs = stat.mtimeMs;
9191
let lastTs = stat.mtimeMs;
9292
let entrypointFound = false;
93+
let worktreeOriginalCwd = '';
9394

9495
for (const line of lines) {
9596
try {
@@ -102,6 +103,11 @@ function parseClaudeSessionFile(sessionFile) {
102103
if (!projectPath && entry.type === 'user' && entry.cwd) {
103104
projectPath = entry.cwd;
104105
}
106+
// worktree-state is written by Claude Code when a session runs inside a git worktree.
107+
// originalCwd is the main checkout directory — safe to use in containers (no git needed).
108+
if (!worktreeOriginalCwd && entry.type === 'worktree-state' && entry.worktreeSession && entry.worktreeSession.originalCwd) {
109+
worktreeOriginalCwd = entry.worktreeSession.originalCwd;
110+
}
105111
if (!entrypointFound && entry.type === 'user' && entry.entrypoint) {
106112
entrypointFound = true;
107113
if (entry.entrypoint !== 'cli') tool = 'claude-ext';
@@ -126,6 +132,7 @@ function parseClaudeSessionFile(sessionFile) {
126132
firstTs,
127133
lastTs,
128134
fileSize: stat.size,
135+
worktreeOriginalCwd,
129136
};
130137
}
131138

@@ -143,6 +150,10 @@ function mergeClaudeSessionDetail(session, summary, sessionFile) {
143150
session.project_short = summary.projectPath.replace(os.homedir(), '~');
144151
}
145152

153+
if (summary.worktreeOriginalCwd) {
154+
session.worktree_original_cwd = summary.worktreeOriginalCwd;
155+
}
156+
146157
if (summary.customTitle) {
147158
session.first_message = summary.customTitle;
148159
}
@@ -767,6 +778,35 @@ function scanCodexSessions() {
767778
return sessions;
768779
}
769780

781+
// ── Git root resolver ───────────────────────────────────────
782+
//
783+
// Priority order for determining the git root of a session:
784+
// 1. worktree-state.originalCwd — written by Claude Code into the JSONL when
785+
// the session runs inside a git worktree. Container-safe: no git required.
786+
// 2. git rev-parse --show-toplevel — resolves the root at runtime. Fails
787+
// gracefully (returns '') in containerized setups where git repos are not
788+
// mounted; the try/catch ensures it never crashes the server.
789+
// 3. Path heuristic in the frontend (getGitProjectName) — parses /.claude/worktrees/
790+
// from the session cwd string. Works without git for standard worktree layouts.
791+
792+
const _gitRootCache = {};
793+
794+
function resolveGitRoot(projectPath) {
795+
if (!projectPath) return '';
796+
if (_gitRootCache[projectPath] !== undefined) return _gitRootCache[projectPath];
797+
try {
798+
const root = execSync(`git -C "${projectPath}" rev-parse --show-toplevel 2>/dev/null`, {
799+
encoding: 'utf8', timeout: 2000
800+
}).trim();
801+
_gitRootCache[projectPath] = root;
802+
return root;
803+
} catch {
804+
// git not available or project path not mounted (e.g. containerised env) — fall back gracefully
805+
_gitRootCache[projectPath] = '';
806+
return '';
807+
}
808+
}
809+
770810
// ── Public API ─────────────────────────────────────────────
771811

772812
let _sessionsCache = null;
@@ -912,6 +952,7 @@ function loadSessions() {
912952
detail_messages: summary.msgCount,
913953
_claude_dir: extraClaudeDir,
914954
_session_file: fp,
955+
worktree_original_cwd: summary.worktreeOriginalCwd || '',
915956
};
916957
}
917958
}
@@ -983,6 +1024,7 @@ function loadSessions() {
9831024
detail_messages: summary.msgCount,
9841025
_claude_dir: CLAUDE_DIR,
9851026
_session_file: filePath,
1027+
worktree_original_cwd: summary.worktreeOriginalCwd || '',
9861028
};
9871029
}
9881030
}
@@ -991,11 +1033,17 @@ function loadSessions() {
9911033

9921034
const result = Object.values(sessions).sort((a, b) => b.last_ts - a.last_ts);
9931035

1036+
// Collect unique project paths and resolve git roots in one pass
1037+
const uniquePaths = [...new Set(result.map(s => s.project).filter(Boolean))];
1038+
for (const p of uniquePaths) resolveGitRoot(p);
1039+
9941040
for (const s of result) {
9951041
s.first_time = new Date(s.first_ts).toLocaleString('sv-SE').slice(0, 16);
9961042
s.last_time = new Date(s.last_ts).toLocaleString('sv-SE').slice(0, 16);
9971043
const dt = new Date(s.last_ts);
9981044
s.date = dt.getFullYear() + '-' + String(dt.getMonth()+1).padStart(2,'0') + '-' + String(dt.getDate()).padStart(2,'0');
1045+
// Priority: worktree-state.originalCwd (container-safe) > git rev-parse > path heuristic (frontend)
1046+
s.git_root = s.worktree_original_cwd || (s.project ? (_gitRootCache[s.project] || '') : '');
9991047
}
10001048

10011049
_sessionsCache = result;

src/frontend/app.js

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ function getProjectName(fullPath) {
5555
return parts[parts.length - 1] || 'unknown';
5656
}
5757

58+
// Returns the git repo name from session data.
59+
// Prefers s.git_root resolved by the backend (git rev-parse --show-toplevel),
60+
// falls back to path-based heuristic for sessions without it.
61+
function getGitProjectName(fullPath, gitRoot) {
62+
if (gitRoot) return gitRoot.replace(/\/+$/, '').split('/').pop() || 'unknown';
63+
if (!fullPath) return 'unknown';
64+
var cleaned = fullPath.replace(/\/+$/, '');
65+
var wt = cleaned.match(/^(.*?)\/.claude\/worktrees\//);
66+
if (wt) return wt[1].split('/').pop() || 'unknown';
67+
var codex = cleaned.match(/^(.*?)\/.codex\//);
68+
if (codex) return codex[1].split('/').pop() || 'unknown';
69+
return cleaned.split('/').pop() || 'unknown';
70+
}
71+
5872
// ── Utilities ──────────────────────────────────────────────────
5973

6074
function timeAgo(dateStr) {
@@ -1184,43 +1198,64 @@ function renderTimeline(container, sessions) {
11841198
container.innerHTML = html;
11851199
}
11861200

1201+
function renderQACard(s, idx) {
1202+
var isStarred = stars.indexOf(s.id) >= 0;
1203+
var toolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool;
1204+
var toolClass = 'tool-' + s.tool;
1205+
var cost = estimateCost(s.file_size);
1206+
var costStr = cost > 0 ? '~$' + cost.toFixed(2) : '';
1207+
var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : '');
1208+
1209+
var html = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
1210+
html += '<span class="tool-badge ' + toolClass + '">' + escHtml(toolLabel) + '</span>';
1211+
html += '<span class="qa-question">' + escHtml((s.first_message || '').slice(0, 160)) + '</span>';
1212+
html += '<span class="qa-meta">';
1213+
html += '<span class="qa-msgs">' + s.messages + ' msgs</span>';
1214+
if (costStr) html += '<span class="cost-badge">' + costStr + '</span>';
1215+
html += '<span class="qa-time">' + timeAgo(s.last_ts) + '</span>';
1216+
html += '</span>';
1217+
html += '<button class="star-btn' + (isStarred ? ' active' : '') + '" onclick="event.stopPropagation();toggleStar(\'' + s.id + '\')" title="Star">&#9733;</button>';
1218+
html += '</div>';
1219+
return html;
1220+
}
1221+
11871222
function renderProjects(container, sessions) {
1188-
var byProject = {};
1223+
var byGit = {};
11891224
sessions.forEach(function(s) {
1190-
var p = getProjectName(s.project);
1191-
if (!byProject[p]) byProject[p] = { sessions: [], project: s.project };
1192-
byProject[p].sessions.push(s);
1225+
var name = getGitProjectName(s.project, s.git_root);
1226+
if (!byGit[name]) byGit[name] = [];
1227+
byGit[name].push(s);
11931228
});
11941229

1195-
var sorted = Object.entries(byProject).sort(function(a, b) {
1196-
return b[1].sessions.length - a[1].sessions.length;
1230+
var sorted = Object.entries(byGit).sort(function(a, b) {
1231+
return b[1][0].last_ts - a[1][0].last_ts;
11971232
});
11981233

11991234
if (sorted.length === 0) {
12001235
container.innerHTML = '<div class="empty-state">No projects found.</div>';
12011236
return;
12021237
}
12031238

1204-
var html = '<div class="projects-grid">';
1239+
var globalIdx = 0;
1240+
var html = '<div class="git-projects">';
12051241
sorted.forEach(function(entry) {
12061242
var name = entry[0];
1207-
var info = entry[1];
1243+
var list = entry[1].slice().sort(function(a, b) { return b.last_ts - a.last_ts; });
12081244
var color = getProjectColor(name);
1209-
var totalMsgs = info.sessions.reduce(function(sum, s) { return sum + (s.messages || 0); }, 0);
1210-
var totalSize = info.sessions.reduce(function(sum, s) { return sum + (s.file_size || 0); }, 0);
1211-
var latest = info.sessions[0];
1245+
var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0);
1246+
var totalCost = list.reduce(function(s, e) { return s + estimateCost(e.file_size); }, 0);
1247+
var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : '';
12121248

1213-
html += '<div class="project-card" onclick="openProject(\'' + escHtml(name).replace(/'/g, "\\'") + '\')">';
1214-
html += '<div class="project-card-header">';
1249+
html += '<div class="git-project-group">';
1250+
html += '<div class="git-project-header" onclick="this.parentElement.classList.toggle(\'collapsed\')">';
12151251
html += '<span class="group-dot" style="background:' + color + '"></span>';
1216-
html += '<span class="project-card-name">' + escHtml(name) + '</span>';
1252+
html += '<span class="git-project-name">' + escHtml(name) + '</span>';
1253+
html += '<span class="git-project-stats">' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + '</span>';
1254+
html += '<span class="group-chevron">&#9660;</span>';
12171255
html += '</div>';
1218-
html += '<div class="project-card-stats">';
1219-
html += '<span>' + info.sessions.length + ' sessions</span>';
1220-
html += '<span>' + totalMsgs + ' msgs</span>';
1221-
html += '<span>' + formatBytes(totalSize) + '</span>';
1256+
html += '<div class="qa-list">';
1257+
list.forEach(function(s) { html += renderQACard(s, globalIdx++); });
12221258
html += '</div>';
1223-
html += '<div class="project-card-time">Last: ' + timeAgo(latest.last_ts) + '</div>';
12241259
html += '</div>';
12251260
});
12261261
html += '</div>';

src/frontend/styles.css

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1470,7 +1470,98 @@ body {
14701470
white-space: nowrap;
14711471
}
14721472

1473-
/* ── Projects grid ──────────────────────────────────────────── */
1473+
/* ── Git Projects accordion ─────────────────────────────────── */
1474+
1475+
.git-projects {
1476+
display: flex;
1477+
flex-direction: column;
1478+
gap: 8px;
1479+
}
1480+
1481+
.git-project-group {
1482+
background: var(--bg-card);
1483+
border: 1px solid var(--border);
1484+
border-radius: 10px;
1485+
overflow: hidden;
1486+
}
1487+
1488+
.git-project-header {
1489+
display: flex;
1490+
align-items: center;
1491+
gap: 10px;
1492+
padding: 12px 16px;
1493+
cursor: pointer;
1494+
transition: background 0.12s;
1495+
user-select: none;
1496+
}
1497+
.git-project-header:hover { background: var(--bg-card-hover); }
1498+
1499+
.git-project-name {
1500+
font-size: 14px;
1501+
font-weight: 700;
1502+
flex: 0 0 auto;
1503+
}
1504+
1505+
.git-project-stats {
1506+
font-size: 12px;
1507+
color: var(--text-muted);
1508+
flex: 1;
1509+
}
1510+
1511+
.git-project-group .group-chevron {
1512+
font-size: 10px;
1513+
color: var(--text-muted);
1514+
transition: transform 0.2s;
1515+
}
1516+
.git-project-group.collapsed .group-chevron { transform: rotate(-90deg); }
1517+
1518+
/* ── QA session list ────────────────────────────────────────── */
1519+
1520+
.qa-list {
1521+
border-top: 1px solid var(--border);
1522+
display: flex;
1523+
flex-direction: column;
1524+
}
1525+
.git-project-group.collapsed .qa-list { display: none; }
1526+
1527+
.qa-item {
1528+
display: flex;
1529+
align-items: center;
1530+
gap: 10px;
1531+
padding: 9px 16px;
1532+
cursor: pointer;
1533+
border-bottom: 1px solid var(--border);
1534+
transition: background 0.12s;
1535+
min-width: 0;
1536+
}
1537+
.qa-item:last-child { border-bottom: none; }
1538+
.qa-item:hover { background: var(--bg-card-hover); }
1539+
.qa-item.selected { background: rgba(96, 165, 250, 0.08); }
1540+
1541+
.qa-question {
1542+
flex: 1;
1543+
font-size: 13px;
1544+
color: var(--text);
1545+
white-space: nowrap;
1546+
overflow: hidden;
1547+
text-overflow: ellipsis;
1548+
min-width: 0;
1549+
}
1550+
1551+
.qa-meta {
1552+
display: flex;
1553+
align-items: center;
1554+
gap: 8px;
1555+
flex-shrink: 0;
1556+
}
1557+
1558+
.qa-msgs, .qa-time {
1559+
font-size: 12px;
1560+
color: var(--text-muted);
1561+
white-space: nowrap;
1562+
}
1563+
1564+
/* ── Projects grid (kept for reference) ─────────────────────── */
14741565

14751566
.projects-grid {
14761567
display: grid;

0 commit comments

Comments
 (0)