diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 6eaf5545..f83e89c4 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -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(); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 1aedf9df..f5cafc85 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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, { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..a08b575a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1508c32b..a205da31 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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. + * `⑂ · `, 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'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 49b80c1f..32397510 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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; + }; + + /** `/.claude/worktrees/` — 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, }); } } diff --git a/test/session-worktree-label.test.ts b/test/session-worktree-label.test.ts new file mode 100644 index 00000000..53764112 --- /dev/null +++ b/test/session-worktree-label.test.ts @@ -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 = {}) => ({ + 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); + }); +});