Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -126,6 +132,7 @@ function parseClaudeSessionFile(sessionFile) {
firstTs,
lastTs,
fileSize: stat.size,
worktreeOriginalCwd,
};
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -848,6 +888,7 @@ function loadSessions() {
detail_messages: summary.msgCount,
_claude_dir: extraClaudeDir,
_session_file: fp,
worktree_original_cwd: summary.worktreeOriginalCwd || '',
};
}
}
Expand Down Expand Up @@ -919,6 +960,7 @@ function loadSessions() {
detail_messages: summary.msgCount,
_claude_dir: CLAUDE_DIR,
_session_file: filePath,
worktree_original_cwd: summary.worktreeOriginalCwd || '',
};
}
}
Expand All @@ -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;
Expand Down
73 changes: 54 additions & 19 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1127,43 +1141,64 @@ 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 = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
html += '<span class="tool-badge ' + toolClass + '">' + escHtml(toolLabel) + '</span>';
html += '<span class="qa-question">' + escHtml((s.first_message || '').slice(0, 160)) + '</span>';
html += '<span class="qa-meta">';
html += '<span class="qa-msgs">' + s.messages + ' msgs</span>';
if (costStr) html += '<span class="cost-badge">' + costStr + '</span>';
html += '<span class="qa-time">' + timeAgo(s.last_ts) + '</span>';
html += '</span>';
html += '<button class="star-btn' + (isStarred ? ' active' : '') + '" onclick="event.stopPropagation();toggleStar(\'' + s.id + '\')" title="Star">&#9733;</button>';
html += '</div>';
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) {
container.innerHTML = '<div class="empty-state">No projects found.</div>';
return;
}

var html = '<div class="projects-grid">';
var globalIdx = 0;
var html = '<div class="git-projects">';
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 += '<div class="project-card" onclick="openProject(\'' + escHtml(name).replace(/'/g, "\\'") + '\')">';
html += '<div class="project-card-header">';
html += '<div class="git-project-group">';
html += '<div class="git-project-header" onclick="this.parentElement.classList.toggle(\'collapsed\')">';
html += '<span class="group-dot" style="background:' + color + '"></span>';
html += '<span class="project-card-name">' + escHtml(name) + '</span>';
html += '<span class="git-project-name">' + escHtml(name) + '</span>';
html += '<span class="git-project-stats">' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + '</span>';
html += '<span class="group-chevron">&#9660;</span>';
html += '</div>';
html += '<div class="project-card-stats">';
html += '<span>' + info.sessions.length + ' sessions</span>';
html += '<span>' + totalMsgs + ' msgs</span>';
html += '<span>' + formatBytes(totalSize) + '</span>';
html += '<div class="qa-list">';
list.forEach(function(s) { html += renderQACard(s, globalIdx++); });
html += '</div>';
html += '<div class="project-card-time">Last: ' + timeAgo(latest.last_ts) + '</div>';
html += '</div>';
});
html += '</div>';
Expand Down
93 changes: 92 additions & 1 deletion src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down