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 <repo>/.claude/worktrees/<name> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uTqt8ttmsBLXbm5JFHis3
COD-140's backfill was meant to cover live/persisted rows whose Codeman
id doesn't match an on-disk transcript UUID, guessing from the newest
transcript in the same workingDir as a last resort. It was also firing
for pure history rows whose OWN transcript scan already ran (and
genuinely found nothing, e.g. an oversized first message) -- those got
silently backfilled with the newest OTHER session's opening line from
the same directory. Not a blank row, but actively wrong: old sessions
displayed a completely unrelated (often today's live) conversation's
first prompt as if it were their own.
Skip the workingDir guess for any item that already has its own
'history' source -- it already had a real, direct attempt. Rows with
no history source at all (their transcript isn't linked/scanned under
their own id yet) still get the guess, matching the original intent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Building on COD-140's firstPrompt backfill, surface each session's most
recent user prompt too, so a long-running session is identifiable by both
where it started and where it is now.
- session-routes: add extractLastUserPrompt() (mirrors extractFirstUserPrompt
with last-match semantics + same noise/secret/slash-command filters + 120
cap); scanProjectDir computes lastPrompt from the file tail (reads a tail for
large files; small files scan head); thread lastPrompt through HistorySession
and the /api/sessions/unified history rows.
- unified-session-service: add lastPrompt to UnifiedSessionItem + HistoryInput,
set it from history in the merge, and extend the backfill with parallel
by-uuid / newest-by-workingDir indexes (never overwrites); add lastPrompt to
the filterAndPaginate search haystack.
- terminal-ui: render a 'Last prompt' detail row, omitted when absent or equal
to the first prompt (single-prompt sessions show one line).
Tests: unified-session-service.test.ts +5 (uuid-join, workingDir fallback,
newest-wins, no-overwrite, search). Beta-verified: /api/sessions/unified
populated firstPrompt+lastPrompt on all 200 rows (12 distinct); Playwright on
the session-manager modal rendered 12 'Last prompt' rows, 0 console errors.
(cherry picked from commit 115f4d397e91decc1a6381b47a99d74922e9055b)
The unified session list only set firstPrompt from the transcript-history
view, keyed by the Claude transcript file's UUID. A live/persisted row
keyed by its Codeman id only inherited a prompt when that id happened to
equal an on-disk transcript filename; when it didn't (stale/wrong
claudeSessionId, post-/clear new uuid, resumed/attached/worktree session),
the session manager showed "(no prompt captured)" even though a real
transcript for that working dir existed under a different UUID.
Add a pure firstPrompt backfill pass in mergeUnifiedSessions (after the
merge loops, using the already-passed history source): for any row with no
firstPrompt, join by claudeSessionId first, then fall back to the newest
transcript in the same workingDir. Never overwrites a non-empty prompt, so
rows keyed to their own transcript are untouched; rows with genuinely no
transcript still show the placeholder. Pure, unit-tested (+5).
(cherry picked from commit 1f9f53ec64a61c9fa7f77d29efcbdd1d2794ec38)
Pin/unpin a session via POST /api/sessions/:id/pin {pinned}; pinned sessions
sort above unpinned in the unified session manager list (COD-121), ordered by
pinnedAt descending. Pin state lives on SessionState, persists to state.json,
and survives reload/reconnect/restart (persisted-input carries pinned; the
merge skips undefined so a recovered live session can't clobber it). New SSE
event session:pinned re-sorts the open list live across clients. Pin/Unpin
affordance in the session-row kebab menu with a 📌 glyph + amber highlight.
(cherry picked from commit 82749747039afcd4a3104f6a97ce7d3c2ddd048d)
Backs the welcome-screen "Resume Conversation" list with the new
GET /api/sessions/unified endpoint instead of /api/history/sessions, so it
shows the COMPLETE set (live + persisted + non-Claude + closed history)
newest-first with richer context, rather than only Claude transcripts.
- terminal-ui.js: new _fetchUnifiedSessions(); loadHistorySessions() now uses
it. _buildHistoryItem upgraded to the unified shape (kept backward-compatible
with the folder-modal's old shape): title = name || firstPrompt || dir; a
mode badge + a LIVE badge (sources includes 'live'); timestamp from
lastActivityAt (falls back to lastModified); size only when present; detail
panel + "View all in this folder" preserved (gated on projectKey). Resume
branches: an open live session selects its tab, a closed one resumes.
- unified-session-service.ts + endpoint: pass projectKey through the history
source so the folder drill-down survives.
- styles.css: .history-item-badges / -badge / -badge-live pills.
Verified: tsc 0, lint 0, frontend-syntax + public-asset format clean, service
tests 13/13 (+projectKey), route tests 4/4. Playwright on an isolated beta:
the welcome list renders real items from /api/sessions/unified, and the
renderer produces the tab-name title + codex mode badge + visible LIVE badge,
omits LIVE on closed items, keeps "View all in folder", and routes resume
correctly (open->select tab, closed->resume). Persistent panel + live SSE
status are later units.
- Duplicate rows: transcript-history rows are keyed by the Claude
conversation UUID (.jsonl filename stem), which diverges from the Codeman
session id for resumed (claudeSessionId = resumeSessionId != id) and
/clear-respawned sessions, so one conversation surfaced as both a live row
and a history-only row. mergeUnifiedSessions now builds an alias map
(claudeSessionId -> Codeman id) from the live + persisted views and
resolves history/lifecycle keys through it; the route feeds
SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
NEWEST-first, but the merge loop unconditionally overwrote name/mode so
the OLDEST entry in the window won (stale rename/mode). First-seen now
wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
with a real transcript fixture); renamed-then-deleted session surfaces
the NEWEST name/mode. All 4 new tests fail against the pre-fix code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First increment of the read-only "complete + searchable session list".
- New src/services/unified-session-service.ts: mergeUnifiedSessions() combines
live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux
stats into one list de-duped by sessionId, with precedence
history < lifecycle < persisted < live, a meaningfulness floor that drops bare
lifecycle/mux-only noise, and a stable newest-first sort. Plus
filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/
sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable.
- New GET /api/sessions/unified in session-routes.ts: gathers the five sources
from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds
the pure service, returns { sessions, total } (ApiResponse envelope). testMode
short-circuits to empty.
Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts
(4, app.inject).