Session Manager: pinning, cross-device ordering, name/prompt retention#157
Conversation
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.
- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
pushing device's order wins; ids the device hadn't loaded fall to the end
in their existing relative order, never dropped — graceful for
closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
added to buildPartialJson() (the incremental serializer whitelists fields,
so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
broadcast session:orderChanged; getLightState() init snapshot now carries
sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
remote order and re-renders (no-op-guarded to avoid echo flicker).
Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
…manager list)
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)
resumeHistorySession ignored the row's name and always synthesized a fresh w<N>-<dir> name from the working dir, so resuming a custom-named session lost its name. Thread the name through resumeHistorySession(sessionId, workingDir, name) and extract the choice into a pure _resolveResumeName helper: prefer a non-empty existing name, else generate the next free w<N>-<dir>. Forward s.name at all three call sites (terminal-ui.js history-item + session-manager menu, session-ui.js run-mode history); sessions without a name fall back to the generated name (unchanged behavior). The unified session rows already carry name, so session-manager rows resume with it. TDD: test/resume-name.test.ts drives the real _resolveResumeName via vm-harness. (cherry picked from commit 56c7906a48d8b453ed55810a02ec70f72d34ed32)
A killed session was full-deleted from state.json (removeSession), dropping the COD-139 pinned/pinnedAt fields, so the session vanished from the session-manager pinned group. cleanupStaleSessions also reaped any persisted record with no live session on boot, which would have wiped a preserved pin on the next restart. Fix (state-store): - demoteOrRemoveSession(id): on kill, demote a *pinned* record to a lightweight stopped record (status=stopped, pid=null, pin retained) instead of deleting; unpinned records are removed as before. - cleanupStaleSessions skips pinned records so the pin survives restart. - server _doCleanupSession calls demoteOrRemoveSession on the killMux path (shutdown path unchanged). Restoration iterates live mux sessions, not state.json, so a stopped+ pinned record is never auto-revived. Unit-tested on the real StateStore path (state-store.test.ts +4); session-cleanup/session-pin regress green. (cherry picked from commit 86f183eacfc3f2f6ac28499fb1ae2d21eef2bbed)
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)
…ager 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)
Resolutions (sse-events.ts / constants.js / app.js): unions of the docker/ multi-user event registrations from master with the session-order/pin events from this branch. Additions on top of the merge: - POST /api/sessions/:id/pin now falls back to the persisted store record when no live session exists: COD-142 deliberately preserves pinned records after kill (and cleanupStaleSessions skips them), so without this a pinned-then- killed session could never be unpinned. Owner-scoped in multi-user mode. - SessionOrderUpdateSchema bounds (id <= 100 chars, <= 500 entries) so a buggy client can't persist megabytes into state.json; empty strings still flow to normalizeSessionOrder which drops them. - Route tests for the persisted-record pin fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merged. Thanks for the contribution! Pinning, cross-device tab order, and the name/prompt retention round out the Session Manager really nicely, and the pure session-order helpers plus the backfill-never-overwrites discipline made this easy to verify. Two maintainer-side additions while resolving the master conflicts: POST /api/sessions/:id/pin now falls back to the persisted store record when there is no live session. COD-142 deliberately keeps a pinned record after kill (and cleanupStaleSessions skips pinned records), so without the fallback a pinned-then-killed session could never be unpinned. Also added modest bounds to SessionOrderUpdateSchema (id length + entry count) so a buggy client can't grow state.json unboundedly; empty strings still flow to normalizeSessionOrder as designed. |
…ns); fix installer prompts under curl|bash README.md + README.zh-CN.md: - New "Remote SSH Sessions" section (durable remote tmux, auto-reconnect, discover/attach with detach-not-kill, shared sessions, injection-safe ssh) - New "Session Manager & Command Palette" subsection (pinning survives kill, name retention on resume, cross-device tab order sync) - Multi-user quick start right after installation (users add + --multiuser), and the zh-CN README gains the full Multi-User Mode section it was missing - Security: document the configurable startup permission mode (skip/auto/ normal/allowedTools) and the multi-user auto downgrade - Cron header button noted as opt-in (Header Displays); API section counts refreshed (~190 handlers / 20 route modules) with pin, session-order and unified endpoints; Development now recommends npm run test:ci CLAUDE.md (/init audit): session-order.ts in the Session row, PR #157 session-manager polish appended to the unified-list pattern, opt-in Cron button documented, route/SSE counts refreshed (20 modules, ~188 handlers, ~146 events) install.sh: the post-install "How would you like to run Codeman?" menu (and the CLI picker + yes/no prompts) read from stdin, which under curl | bash is the pipe, so choices were impossible and the script silently fell through to the default. New has_tty()/read_reply() helpers prompt via /dev/tty whenever a real terminal exists (same approach the sudo path already used) and only fall back to defaults when there is genuinely none, now with an info line saying so. Verified both paths with a pty harness (script(1)) and setsid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Polish layer for the unified Session Manager, building on the backend service (#139) and the welcome/frontend list. Adds pinning, cross-device tab ordering, and name/prompt retention.
Scope:
src/session-order.ts).Pinning, ordering, and retention are layered onto the existing unified-session-service and welcome list. The alias-map fold that collapses a history row keyed by the conversation UUID into its owning live session is preserved.
Testing
tsc --noEmit, lint, frontend-syntax, build: all clean.