mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user