Merge pull request #269 from jordan8037310/feat/session-worktree-label

feat(sessions): show the git worktree (name + branch) on session rows
This commit is contained in:
Ark0N
2026-08-10 04:26:23 +02:00
committed by GitHub
6 changed files with 275 additions and 2 deletions
+13 -1
View File
@@ -32,6 +32,12 @@ export type UnifiedSessionItem = {
lastPrompt?: string;
sizeBytes?: number;
projectKey?: string;
/** Git branch recorded in the transcript (#266). */
gitBranch?: string;
/** Linked-worktree name, when the session ran in one (#266). */
worktreeName?: string;
/** Main repo root a worktree belongs to (#266). */
worktreeRepo?: string;
remote?: boolean;
/** Pinned to the top of the session manager list (COD-139). */
pinned?: boolean;
@@ -90,6 +96,9 @@ export type HistoryInput = {
/** Most recent user prompt from the transcript (COD-145). */
lastPrompt?: string;
projectKey?: string;
gitBranch?: string;
worktreeName?: string;
worktreeRepo?: string;
};
/** Mux process-stat view. */
@@ -163,6 +172,9 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'lastPrompt', h.lastPrompt);
overwrite(item, 'projectKey', h.projectKey);
overwrite(item, 'gitBranch', h.gitBranch);
overwrite(item, 'worktreeName', h.worktreeName);
overwrite(item, 'worktreeRepo', h.worktreeRepo);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}
@@ -346,7 +358,7 @@ export function filterAndPaginate(
const q = (opts.q ?? '').trim().toLowerCase();
const filtered = q
? items.filter((it) => {
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId, it.worktreeName, it.gitBranch]
.filter((v): v is string => typeof v === 'string')
.join(' ')
.toLowerCase();
+5
View File
@@ -649,6 +649,11 @@ Object.assign(CodemanApp.prototype, {
sizeBytes: s.sizeBytes ?? 0,
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
firstPrompt: s.firstPrompt || s.name || '',
// Must be carried explicitly: this record is a re-projection, so any
// field omitted here silently vanishes from the Cmd+K list (#266).
gitBranch: s.gitBranch,
worktreeName: s.worktreeName,
worktreeRepo: s.worktreeRepo,
};
const isLive = !!this.sessions?.has?.(s.sessionId);
const item = this._buildHistoryItem(record, this.cases, {
+11
View File
@@ -3840,6 +3840,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: var(--green);
}
/* Worktree pill (#266). Tinted off the accent so it reads as metadata rather
than status — LIVE is the only badge that should look like state. */
.history-item-badge-worktree {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
+30
View File
@@ -1517,6 +1517,24 @@ Object.assign(CodemanApp.prototype, {
* - workingDir under a case dir → "#caseName/subdir"
* - Otherwise → basename (e.g. "Claudeman")
*/
/**
* Badge text for a session's git worktree, or '' when it isn't on one.
* `⑂ <name> · <branch>`, either half alone if that's all we know.
* Branch is truncated: the badge row is a single nowrap line.
*/
_worktreeLabel(s) {
// Worktree name is REQUIRED. gitBranch alone is not worktree information —
// every ordinary repo session has one, and badging all of them with `⑂ master`
// is noise that buries the rows this badge exists to distinguish.
const name = s && s.worktreeName;
if (!name) return '';
let branch = s.gitBranch || '';
// A worktree's branch often just restates its name; don't print it twice.
if (branch === name || branch === `worktree-${name}`) branch = '';
if (branch.length > 24) branch = branch.slice(0, 23) + '\u2026';
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
},
_resolveCaseLabel(workingDir, cases) {
if (!workingDir) return '';
let best = null;
@@ -1639,6 +1657,18 @@ Object.assign(CodemanApp.prototype, {
modeBadge.textContent = s.mode;
badgeRow.appendChild(modeBadge);
}
// Worktree pill (#266): distinguishes sessions from different worktrees of the
// same repo, which are otherwise identical in this list. Name AND branch when
// both are known; a hand-made `git worktree add` yields no recoverable name,
// so it degrades to branch-only rather than guessing one.
const wtLabel = this._worktreeLabel(s);
if (wtLabel) {
const wtBadge = document.createElement('span');
wtBadge.className = 'history-item-badge history-item-badge-worktree';
wtBadge.textContent = wtLabel;
wtBadge.title = s.worktreeRepo ? `worktree of ${s.worktreeRepo}` : wtLabel;
badgeRow.appendChild(wtBadge);
}
if (isLive) {
const liveBadge = document.createElement('span');
liveBadge.className = 'history-item-badge history-item-badge-live';
+114 -1
View File
@@ -3116,6 +3116,91 @@ export function registerSessionRoutes(
return sawNonCli;
}
/** Git/worktree facts recovered from a transcript. Every field is optional —
* "unknown" must stay distinguishable from "not a worktree" (#265/#266). */
type TranscriptGitInfo = {
/** The literal `cwd` Claude Code stamped on its own records. */
cwd?: string;
gitBranch?: string;
worktreeName?: string;
/** Main repo root the worktree belongs to. */
worktreeRepo?: string;
};
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* Recover cwd / branch / worktree from a transcript chunk.
*
* Claude Code stamps `"cwd"` and `"gitBranch"` on every user/assistant record,
* and writes a dedicated `worktree-state` record when the session was started
* through its own worktree feature. This reads buffers `scanProjectDir` has
* ALREADY loaded, so it costs no extra file I/O.
*
* Why this matters beyond a label: `decodeProjectKey()` reconstructs a path by
* stat-walking the filesystem and falls back to `$HOME` when nothing resolves.
* A deleted worktree is the normal end of a worktree's life, so every past
* worktree session used to collapse onto `$HOME` (#265). The transcript value
* is the literal cwd — non-lossy, and it survives the directory being removed.
*
* cwd is taken from the FIRST record that carries it (a session's cwd does not
* move); gitBranch from the LAST (a branch genuinely changes mid-session, and
* the newest value in the scanned chunk is the closest to current).
*/
function extractTranscriptGitInfo(text: string): TranscriptGitInfo {
const info: TranscriptGitInfo = {};
let start = 0;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
// Highest-confidence source: Claude's own worktree record. Names the
// worktree explicitly, so it beats anything inferred from the path.
if (line.includes('"worktree-state"')) {
try {
const rec = JSON.parse(line) as {
worktreeSession?: { worktreeName?: unknown; worktreePath?: unknown; originalCwd?: unknown };
};
const ws = rec.worktreeSession;
if (ws) {
if (typeof ws.worktreeName === 'string') info.worktreeName ||= ws.worktreeName;
if (typeof ws.originalCwd === 'string') info.worktreeRepo ||= ws.originalCwd;
if (typeof ws.worktreePath === 'string') info.cwd ||= ws.worktreePath;
}
} catch {
// Malformed/truncated line — skip
}
continue;
}
if (!line.includes('"cwd"') && !line.includes('"gitBranch"')) continue;
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
try {
const rec = JSON.parse(line) as { cwd?: unknown; gitBranch?: unknown };
if (!info.cwd && typeof rec.cwd === 'string' && rec.cwd) info.cwd = rec.cwd;
// Last one wins — closest to the session's current branch.
if (typeof rec.gitBranch === 'string' && rec.gitBranch) info.gitBranch = rec.gitBranch;
} catch {
// Malformed/truncated line — skip
}
}
// No explicit worktree record: infer from Claude's own worktree path layout.
// A worktree created by hand (`git worktree add` anywhere) has no recoverable
// NAME here — it still gets a branch, and the badge degrades to branch-only
// rather than guessing.
if (!info.worktreeName && info.cwd) {
const m = CLAUDE_WORKTREE_PATH.exec(info.cwd);
if (m) {
info.worktreeName = m[2];
info.worktreeRepo ||= m[1];
}
}
return info;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
@@ -3367,6 +3452,11 @@ export function registerSessionRoutes(
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
/** True when workingDir came from the transcript rather than decodeProjectKey's guess. */
workingDirExact?: boolean;
gitBranch?: string;
worktreeName?: string;
worktreeRepo?: string;
};
// Scan a single project directory and return all valid history sessions in it.
@@ -3473,14 +3563,34 @@ export function registerSessionRoutes(
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
// Git/worktree facts from the buffers already read above — no extra I/O.
// head first (cwd is stamped near the top; median offset ~1KB), tail as the
// fallback for transcripts whose head read failed or came up empty.
const headGit = head ? extractTranscriptGitInfo(head) : {};
const tailGit = tail ? extractTranscriptGitInfo(tail) : {};
const git: TranscriptGitInfo = {
cwd: headGit.cwd ?? tailGit.cwd,
// Last-wins within a chunk; across chunks the tail is the newer one.
gitBranch: tailGit.gitBranch ?? headGit.gitBranch,
worktreeName: headGit.worktreeName ?? tailGit.worktreeName,
worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo,
};
out.push({
sessionId,
workingDir,
// The transcript's literal cwd beats decodeProjectKey's stat-walked guess,
// which silently collapses to $HOME once the directory is gone (#265).
// Absent cwd falls back to the old behaviour rather than inventing a path.
workingDir: git.cwd ?? workingDir,
workingDirExact: git.cwd !== undefined,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
lastPrompt,
gitBranch: git.gitBranch,
worktreeName: git.worktreeName,
worktreeRepo: git.worktreeRepo,
});
}
return out;
@@ -3618,6 +3728,9 @@ export function registerSessionRoutes(
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
projectKey: h.projectKey,
gitBranch: h.gitBranch,
worktreeName: h.worktreeName,
worktreeRepo: h.worktreeRepo,
});
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Worktree/branch identity on session rows (#265, #266).
*
* Two behaviours are pinned here:
* - the unified merge carries gitBranch/worktreeName/worktreeRepo through from
* the history source, and filterAndPaginate can search them;
* - the client-side badge helper renders `⑂ name · branch`, and stays SILENT
* when only a branch is known (a branch is not a worktree — badging those
* would put `⑂ master` on every ordinary session).
*
* The transcript extractor itself lives inside a closure in session-routes.ts
* and is covered by the route tests; what matters at this level is that the
* fields survive the merge and reach a label.
*/
import { describe, it, expect } from 'vitest';
import {
mergeUnifiedSessions,
filterAndPaginate,
type UnifiedSessionItem,
} from '../src/services/unified-session-service.js';
const historyRow = (over: Record<string, unknown> = {}) => ({
sessionId: 's1',
workingDir: '/repo/.claude/worktrees/autodev',
sizeBytes: 9000,
lastModified: '2026-01-01T00:00:00.000Z',
...over,
});
describe('worktree fields through the unified merge (#266)', () => {
it('carries gitBranch / worktreeName / worktreeRepo from the history source', () => {
const merged = mergeUnifiedSessions({
history: [historyRow({ gitBranch: 'feat/CF-195', worktreeName: 'autodev', worktreeRepo: '/repo' })],
});
expect(merged).toHaveLength(1);
expect(merged[0].worktreeName).toBe('autodev');
expect(merged[0].gitBranch).toBe('feat/CF-195');
expect(merged[0].worktreeRepo).toBe('/repo');
});
it('leaves the fields undefined for a non-worktree session rather than inventing them', () => {
const merged = mergeUnifiedSessions({ history: [historyRow({ workingDir: '/plain/repo' })] });
expect(merged[0].worktreeName).toBeUndefined();
expect(merged[0].gitBranch).toBeUndefined();
});
it('finds a session by worktree name and by branch', () => {
const items = [
{ sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },
{ sessionId: 'b', gitBranch: 'feat/CF-195', sources: ['history'] },
{ sessionId: 'c', sources: ['history'] },
] as unknown as UnifiedSessionItem[];
expect(filterAndPaginate(items, { q: 'autodev' }).sessions.map((s) => s.sessionId)).toEqual(['a']);
expect(filterAndPaginate(items, { q: 'cf-195' }).sessions.map((s) => s.sessionId)).toEqual(['b']);
expect(filterAndPaginate(items, { q: 'nothing' }).sessions).toHaveLength(0);
});
});
/**
* Mirrors `_worktreeLabel` in terminal-ui.js. The frontend is plain browser JS
* with no module exports, so the logic is restated here; the rule it encodes —
* never print a branch that merely restates the worktree name — is the part
* worth pinning.
*/
function worktreeLabel(s: { worktreeName?: string; gitBranch?: string }): string {
const name = s.worktreeName;
if (!name) return '';
let branch = s.gitBranch || '';
if (branch === name || branch === `worktree-${name}`) branch = '';
if (branch.length > 24) branch = branch.slice(0, 23) + '…';
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
}
describe('worktree badge label', () => {
it('renders name and branch together', () => {
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'feat/CF-195' })).toBe('⑂ autodev · feat/CF-195');
});
it('renders NOTHING when only a branch is known — a branch is not a worktree', () => {
// Every ordinary repo session carries gitBranch. Badging those would put
// `⑂ master` on every row and bury the worktree rows this badge is for.
expect(worktreeLabel({ gitBranch: 'master' })).toBe('');
expect(worktreeLabel({ gitBranch: 'feat/CF-200' })).toBe('');
});
it('does not repeat the name when the branch just restates it', () => {
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'autodev' })).toBe('⑂ autodev');
expect(worktreeLabel({ worktreeName: 'autodev', gitBranch: 'worktree-autodev' })).toBe('⑂ autodev');
});
it('is empty for a session that is not on a worktree', () => {
expect(worktreeLabel({})).toBe('');
});
it('truncates a long branch so the single-line badge row cannot blow out', () => {
const label = worktreeLabel({ worktreeName: 'wt', gitBranch: 'feature/VERY-LONG-BRANCH-NAME-THAT-KEEPS-GOING' });
expect(label.length).toBeLessThanOrEqual(2 + 2 + 3 + 24);
expect(label.endsWith('…')).toBe(true);
});
});