From aa35c1a0c43df8be7eecbcdfd354d375d0a2a24c Mon Sep 17 00:00:00 2001 From: Jordan Ryan Date: Sun, 9 Aug 2026 21:32:04 -0400 Subject: [PATCH] feat(sessions): show the git worktree on session rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #266. Sessions from different worktrees of the same repo were indistinguishable in the Resume list, Cmd+K and search — the row showed a session name and a case label, nothing about which worktree it ran in. Claude Code already stamps "cwd" and "gitBranch" on every user/assistant record, and writes a worktree-state record naming the worktree when the session was started through its own worktree feature. scanProjectDir() already buffers the head of every transcript for prompt extraction, so extractTranscriptGitInfo() parses buffers that are already in memory: no extra file reads, no git subprocess. (Measured on this machine: a git rev-parse per directory costs 482ms for 35 rows; parsing the existing buffers costs nothing.) cwd is taken from the first record that carries it, since a session's cwd does not move. gitBranch is taken from the last, since a branch genuinely changes mid-session. The badge requires a worktree NAME. An earlier revision rendered whenever a branch was known, which put a badge on all 35 rows of a real history -- "master" on every ordinary session, burying the ten rows the badge exists to distinguish. A hand-made `git worktree add` therefore gets no badge rather than a guessed name; Claude's own /.claude/worktrees/ layout is recognised from the path when no worktree-state record is present. worktreeName and gitBranch join the filterAndPaginate haystack so the session manager can search by them. panels-ui re-projects the unified item into a 5-field record before rendering, so the new fields are carried there explicitly -- omitting that silently drops them from Cmd+K only. Also prefers the transcript cwd over decodeProjectKey()'s stat-walked guess, which falls back to $HOME when nothing resolves (#265). Note that path is currently LATENT, not active: on the install this was developed against, every project key whose directory is gone has zero transcripts and so produces no row at all. The transcript value is used because it is authoritative and non-lossy, not because a live bug was reproduced. Verified against a real 35-session history on an isolated CODEMAN_INSTANCE: 10 of 36 rows badged, history row count unchanged at 35 (nothing dropped), no page errors. 129 tests pass across the new suite plus the unified service, unified route and session route suites. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016uTqt8ttmsBLXbm5JFHis3 --- src/services/unified-session-service.ts | 14 ++- src/web/public/panels-ui.js | 5 ++ src/web/public/styles.css | 11 +++ src/web/public/terminal-ui.js | 30 +++++++ src/web/routes/session-routes.ts | 115 +++++++++++++++++++++++- test/session-worktree-label.test.ts | 102 +++++++++++++++++++++ 6 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 test/session-worktree-label.test.ts 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); + }); +});