diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 03b7c618..5d4fa134 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -28,9 +28,15 @@ export type UnifiedSessionItem = { lastActivityAt?: number; claudeSessionId?: string; firstPrompt?: string; + /** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */ + lastPrompt?: string; sizeBytes?: number; projectKey?: string; remote?: boolean; + /** Pinned to the top of the session manager list (COD-139). */ + pinned?: boolean; + /** When the session was pinned (epoch ms) — orders the pinned group desc. */ + pinnedAt?: number; sources: string[]; stats?: { memoryMB: number; cpuPercent: number }; }; @@ -46,6 +52,8 @@ export type LiveSessionInput = { createdAt?: number; lastActivityAt?: number; claudeSessionId?: string; + pinned?: boolean; + pinnedAt?: number; }; /** Persisted session view (subset of `SessionState`). */ @@ -59,6 +67,8 @@ export type PersistedSessionInput = { lastActivityAt?: number; /** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */ claudeSessionId?: string; + pinned?: boolean; + pinnedAt?: number; }; /** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */ @@ -77,6 +87,8 @@ export type HistoryInput = { sizeBytes: number; lastModified: string; firstPrompt?: string; + /** Most recent user prompt from the transcript (COD-145). */ + lastPrompt?: string; projectKey?: string; }; @@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'workingDir', h.workingDir); overwrite(item, 'sizeBytes', h.sizeBytes); overwrite(item, 'firstPrompt', h.firstPrompt); + overwrite(item, 'lastPrompt', h.lastPrompt); overwrite(item, 'projectKey', h.projectKey); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; @@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'workingDir', p.workingDir); overwrite(item, 'createdAt', p.createdAt); overwrite(item, 'lastActivityAt', p.lastActivityAt); + overwrite(item, 'pinned', p.pinned); + overwrite(item, 'pinnedAt', p.pinnedAt); } // 4) live (highest precedence) @@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'createdAt', v.createdAt); overwrite(item, 'lastActivityAt', v.lastActivityAt); overwrite(item, 'claudeSessionId', v.claudeSessionId); + overwrite(item, 'pinned', v.pinned); + overwrite(item, 'pinnedAt', v.pinnedAt); } // 5) mux stats + remote flag (create item if mux-only) @@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte if (m.remote !== undefined) item.remote = m.remote; } + // firstPrompt backfill (COD-140): the only source that sets firstPrompt is the + // transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted + // row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an + // on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new + // uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows + // "(no prompt captured)" even though a real transcript for that working dir exists under a + // different UUID. Backfill from the already-passed history: first try the claudeSessionId + // join, then the newest transcript in the same workingDir. Never overwrite a non-empty + // firstPrompt (so rows keyed to their own transcript are untouched). + const firstPromptByUuid = new Map(); + const firstPromptByWorkingDir = new Map(); + // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). + const lastPromptByUuid = new Map(); + const lastPromptByWorkingDir = new Map(); + for (const h of sources.history ?? []) { + const ms = Date.parse(h.lastModified); + const ts = Number.isNaN(ms) ? -Infinity : ms; + if (h.firstPrompt) { + firstPromptByUuid.set(h.sessionId, h.firstPrompt); + if (h.workingDir) { + const existing = firstPromptByWorkingDir.get(h.workingDir); + if (!existing || ts > existing.ms) { + firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts }); + } + } + } + if (h.lastPrompt) { + lastPromptByUuid.set(h.sessionId, h.lastPrompt); + if (h.workingDir) { + const existing = lastPromptByWorkingDir.get(h.workingDir); + if (!existing || ts > existing.ms) { + lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts }); + } + } + } + } + for (const item of map.values()) { + if (!item.firstPrompt) { + // never overwrite an existing non-empty prompt + const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined; + if (byUuid) { + item.firstPrompt = byUuid; + } else if (item.workingDir) { + const byDir = firstPromptByWorkingDir.get(item.workingDir); + if (byDir) item.firstPrompt = byDir.prompt; + } + } + if (!item.lastPrompt) { + const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined; + if (byUuid) { + item.lastPrompt = byUuid; + } else if (item.workingDir) { + const byDir = lastPromptByWorkingDir.get(item.workingDir); + if (byDir) item.lastPrompt = byDir.prompt; + } + } + } + // Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise. const kept: UnifiedSessionItem[] = []; for (const item of map.values()) { @@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte if (isReal) kept.push(item); } - // Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc. + // Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned + // first), then unpinned by lastActivityAt desc (undefined last), createdAt desc, + // sessionId asc. kept.sort((a, b) => { + const pa = a.pinned === true; + const pb = b.pinned === true; + if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned + if (pa && pb) { + // Both pinned: most-recently-pinned first (undefined pinnedAt sorts last). + const ta = a.pinnedAt; + const tb = b.pinnedAt; + if (ta !== tb) { + if (ta === undefined) return 1; + if (tb === undefined) return -1; + return tb - ta; + } + // tie-break falls through to the activity/createdAt/id rules below. + } const la = a.lastActivityAt; const lb = b.lastActivityAt; if (la !== lb) { @@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte } /** - * Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId) + * Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId) * with offset/limit paging. `total` is the filtered count BEFORE paging. */ export function filterAndPaginate( @@ -244,7 +335,7 @@ export function filterAndPaginate( const q = (opts.q ?? '').trim().toLowerCase(); const filtered = q ? items.filter((it) => { - const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId] + const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId] .filter((v): v is string => typeof v === 'string') .join(' ') .toLowerCase(); diff --git a/src/session-order.ts b/src/session-order.ts new file mode 100644 index 00000000..21a2ba62 --- /dev/null +++ b/src/session-order.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview Pure helpers for the global session tab-order (COD-131). + * + * Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side + * so it follows the user across devices. The server is authoritative; the + * browser's localStorage (`codeman-session-order`) is the offline fallback. + * + * These helpers are pure (no IO) so they can be unit-tested in isolation and + * reused by both the PUT /api/session-order route and the StateStore accessor. + * + * - `normalizeSessionOrder` coerces arbitrary input into a clean string[] + * (non-empty strings only, deduped with first occurrence winning). + * - `mergeSessionOrder` lets the pushing device's order win, while preserving + * any server-only ids the pushing device didn't know about — they fall to the + * END in their existing relative order, never dropped. + */ + +/** + * Coerce arbitrary input into a clean ordered list of session ids: + * keep only non-empty strings and dedup (first occurrence wins). + * + * @param order - unknown input (expected to be a string[], but defensive) + * @returns a normalized string[] (empty array for non-array / all-junk input) + */ +export function normalizeSessionOrder(order: unknown): string[] { + if (!Array.isArray(order)) { + return []; + } + const seen = new Set(); + const result: string[] = []; + for (const entry of order) { + if (typeof entry !== 'string' || entry.length === 0) { + continue; + } + if (seen.has(entry)) { + continue; + } + seen.add(entry); + result.push(entry); + } + return result; +} + +/** + * Merge an incoming order from a pushing device with the existing server order. + * + * The incoming order wins; any ids present in `existing` but NOT in `incoming` + * are appended at the END, preserving their relative order. This is the + * "server-only ids the pushing device didn't know about fall to the end, never + * dropped" rule. + * + * Both arguments are normalized first, so callers may pass raw input safely. + * + * @param incoming - the order the pushing device wants + * @param existing - the current server-side order + * @returns the merged, normalized order + */ +export function mergeSessionOrder(incoming: string[], existing: string[]): string[] { + const normalizedIncoming = normalizeSessionOrder(incoming); + const incomingSet = new Set(normalizedIncoming); + const merged = [...normalizedIncoming]; + for (const id of normalizeSessionOrder(existing)) { + if (!incomingSet.has(id)) { + merged.push(id); + } + } + return merged; +} diff --git a/src/session.ts b/src/session.ts index a509040a..a15ce76a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -336,6 +336,11 @@ export class Session extends EventEmitter { // Image watcher setting (per-session toggle) private _imageWatcherEnabled: boolean = false; + // Pin state (COD-139) — pinned sessions float to the top of the session + // manager list, ordered by pinnedAt descending (most-recently-pinned first). + private _pinned: boolean = false; + private _pinnedAt: number | null = null; + // Flicker filter setting (per-session toggle, applied on frontend) private _flickerFilterEnabled: boolean = false; @@ -957,6 +962,26 @@ export class Session extends EventEmitter { this._imageWatcherEnabled = enabled; } + /** Whether this session is pinned to the top of the session manager (COD-139). */ + get pinned(): boolean { + return this._pinned; + } + + /** When the session was pinned (epoch ms), or null when unpinned. */ + get pinnedAt(): number | null { + return this._pinnedAt; + } + + /** + * Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned + * group orders most-recently-pinned first; unpinning clears it. Idempotent: + * re-pinning an already-pinned session refreshes its pinnedAt. + */ + setPinned(pinned: boolean): void { + this._pinned = pinned; + this._pinnedAt = pinned ? Date.now() : null; + } + get flickerFilterEnabled(): boolean { return this._flickerFilterEnabled; } @@ -1021,6 +1046,8 @@ export class Session extends EventEmitter { autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, imageWatcherEnabled: this._imageWatcherEnabled, + pinned: this._pinned || undefined, + pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, totalCost: this._totalCost, inputTokens: this._totalInputTokens, outputTokens: this._totalOutputTokens, diff --git a/src/state-store.ts b/src/state-store.ts index 1cb6378e..444e4ea3 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -278,6 +278,9 @@ export class StateStore { if (this.state.cronJobRuns) { parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`); } + if (this.state.sessionOrder) { + parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`); + } return `{${parts.join(',')}}`; } @@ -485,6 +488,25 @@ export class StateStore { this.save(); } + /** + * COD-142: Remove a session's persisted record on kill UNLESS it is pinned. + * A pinned session is demoted to a lightweight `stopped` record (pin retained) + * so it stays visible in the session-manager pinned group and survives restart. + * Unpinned sessions are fully removed (unchanged behavior). + * @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed. + */ + demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' { + const existing = this.state.sessions[id]; + if (!existing) return 'absent'; + if (existing.pinned === true) { + // Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime. + this.setSession(id, { ...existing, status: 'stopped', pid: null }); + return 'preserved'; + } + this.removeSession(id); + return 'removed'; + } + /** * Cleans up stale sessions from state that don't have corresponding active sessions. * @param activeSessionIds - Set of currently active session IDs @@ -499,6 +521,7 @@ export class StateStore { for (const sessionId of allSessionIds) { if (!activeSessionIds.has(sessionId)) { + if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session const name = this.state.sessions[sessionId]?.name; cleaned.push({ id: sessionId, name }); delete this.state.sessions[sessionId]; @@ -630,6 +653,17 @@ export class StateStore { this.save(); } + /** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */ + getSessionOrder(): string[] { + return this.state.sessionOrder ?? []; + } + + /** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */ + setSessionOrder(order: string[]): void { + this.state.sessionOrder = order; + this.save(); + } + /** Resets all state to initial values and saves immediately. */ reset(): void { this.state = createInitialState(); diff --git a/src/types/app-state.ts b/src/types/app-state.ts index e0b39838..4d16ddcd 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -116,6 +116,8 @@ export interface AppState { cronJobs?: Record; /** Scheduled job run history, keyed by run ID. */ cronJobRuns?: Record; + /** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */ + sessionOrder?: string[]; } // ========== Default Configuration ========== diff --git a/src/types/session.ts b/src/types/session.ts index f74d711d..33facf72 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -241,6 +241,10 @@ export interface SessionState { autoResumeEnabled?: boolean; /** Pending usage-limit auto-resume fire time (epoch ms), if armed */ autoResumeAt?: number; + /** Pinned to the top of the session manager list (COD-139) */ + pinned?: boolean; + /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ + pinnedAt?: number; /** Image watcher enabled for this session */ imageWatcherEnabled?: boolean; /** Total cost in USD */ diff --git a/src/web/public/app.js b/src/web/public/app.js index c27c7f48..0bec0273 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -287,6 +287,9 @@ const _SSE_HANDLER_MAP = [ // Clipboard [SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'], + + // Session order (global tab order sync, COD-131) + [SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'], ]; @@ -1433,6 +1436,26 @@ class CodemanApp { for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) { addListener(event, () => this._onSessionListMaybeChanged()); } + + // COD-139: a session:pinned event updates the local live-session pin flag (so + // a subsequent render is consistent) and re-sorts the open session manager / + // welcome list so pinned sessions float to the top. + addListener(SSE_EVENTS.SESSION_PINNED, (e) => { + let data = null; + try { + data = JSON.parse(e.data); + } catch { + /* ignore malformed payload */ + } + if (data && data.id) { + const live = this.sessions.get(data.id); + if (live) { + live.pinned = data.pinned === true; + live.pinnedAt = data.pinned ? data.pinnedAt : undefined; + } + } + this._onSessionListMaybeChanged(); + }); } // ═══════════════════════════════════════════════════════════════ @@ -2956,6 +2979,13 @@ class CodemanApp { // on another device). try { localStorage.removeItem('codeman-tab-meta'); } catch {} + // COD-131: server is authoritative for global tab order. Seed localStorage + // from the server snapshot (if present) so syncSessionOrder() reconciles + // against the cross-device order rather than this device's stale local copy. + if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) { + try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {} + } + // Sync sessionOrder with current sessions (preserve order, add new, remove stale) this.syncSessionOrder(); @@ -3520,13 +3550,42 @@ class CodemanApp { } } - // Save session order to localStorage + // Save session order to localStorage and (debounced) sync to the server so it + // follows the user across devices (COD-131). localStorage stays the offline + // fallback; the server is authoritative and echoes back via SSE. saveSessionOrder() { try { localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder)); } catch { // Ignore storage errors } + const order = [...this.sessionOrder]; + this._debouncedCall('saveSessionOrderServer', () => { + fetch('/api/session-order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + }).catch(() => {}); + }, 400); + } + + // COD-131: another device (or our own debounced push) reordered tabs. Adopt the + // server order as the new base and reconcile to our currently-open sessions. + // Guard against no-op churn so an echo of our own push doesn't flicker the tabs. + _onSessionOrderChanged(data) { + if (!data || !Array.isArray(data.order)) return; + try { + localStorage.setItem('codeman-session-order', JSON.stringify(data.order)); + } catch { + // Ignore storage errors + } + const before = JSON.stringify(this.sessionOrder); + this.syncSessionOrder(); + // Only re-render when the reconciled order actually changed (avoids flicker + // when the broadcast is just an echo of the order we already have). + if (JSON.stringify(this.sessionOrder) !== before) { + this._fullRenderSessionTabs(); + } } // Set up drag-and-drop handlers on tab elements diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1751bfe3..eef371d0 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -322,6 +322,7 @@ const SSE_EVENTS = { SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled', SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped', SESSION_CLI_INFO: 'session:cliInfo', + SESSION_PINNED: 'session:pinned', SESSION_MESSAGE: 'session:message', SESSION_INTERACTIVE: 'session:interactive', SESSION_RUNNING: 'session:running', @@ -474,6 +475,9 @@ const SSE_EVENTS = { CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', CASE_ORDER_CHANGED: 'case:order-changed', + + // Session order (global tab order sync) + SESSION_ORDER_CHANGED: 'session:orderChanged', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 14b30693..6ccee3cd 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -424,7 +424,7 @@ Object.assign(CodemanApp.prototype, { btn.append(dirSpan, metaSpan); btn.addEventListener('click', (e) => { e.stopPropagation(); - this.resumeHistorySession(s.sessionId, s.workingDir); + this.resumeHistorySession(s.sessionId, s.workingDir, s.name); }); container.appendChild(btn); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bb334dfc..fbe60a3c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { background: rgba(255, 255, 255, 0.05); } +/* COD-139: pinned sessions float to the top with a subtle accent highlight. */ +.history-item.is-pinned { + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.08); + box-shadow: inset 3px 0 0 rgba(245, 158, 11, 0.7); +} + +.history-item.is-pinned:hover { + border-color: rgba(245, 158, 11, 0.5); + background: rgba(245, 158, 11, 0.12); +} + +.history-item-pin { + margin-right: 0.35rem; + font-size: 0.75rem; + line-height: 1; + vertical-align: baseline; +} + .history-item-main { display: flex; align-items: center; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 05d2fead..f309a31b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, { const isLive = Array.isArray(s.sources) && s.sources.includes('live'); + const isPinned = s.pinned === true; + const item = document.createElement('div'); - item.className = 'history-item'; + item.className = 'history-item' + (isPinned ? ' is-pinned' : ''); item.title = s.workingDir || ''; // Main row: clickable surface. A caller-supplied onActivate wins (the @@ -1258,7 +1260,7 @@ Object.assign(CodemanApp.prototype, { if (isLive && this.sessions.has(s.sessionId)) { this.selectSession(s.sessionId); } else { - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || ''); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); } }) ); @@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, { const titleSpan = document.createElement('span'); titleSpan.className = 'history-item-title'; - titleSpan.textContent = s.name || s.firstPrompt || shortDir; + if (isPinned) { + // Filled pin glyph indicating the session is pinned to the top (COD-139). + const pin = document.createElement('span'); + pin.className = 'history-item-pin'; + pin.textContent = '📌'; + pin.setAttribute('aria-label', 'Pinned'); + pin.title = 'Pinned'; + titleSpan.appendChild(pin); + } + titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir)); // Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill. const badgeRow = document.createElement('div'); @@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, { promptText.textContent = s.firstPrompt || '(no prompt captured)'; promptRow.append(promptLabel, promptText); + // COD-145: show the most recent user prompt too, but collapse single-prompt + // sessions (omit when there's no last prompt or it duplicates the first). + let lastPromptRow = null; + if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) { + lastPromptRow = document.createElement('div'); + lastPromptRow.className = 'history-detail-row'; + const lastPromptLabel = document.createElement('span'); + lastPromptLabel.className = 'history-detail-label'; + lastPromptLabel.textContent = 'Last prompt'; + const lastPromptText = document.createElement('span'); + lastPromptText.className = 'history-detail-value history-detail-prompt'; + lastPromptText.textContent = s.lastPrompt; + lastPromptRow.append(lastPromptLabel, lastPromptText); + } + const pathRow = document.createElement('div'); pathRow.className = 'history-detail-row'; const pathLabel = document.createElement('span'); @@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, { metaParts.push(s.sessionId.slice(0, 8)); metaRow.textContent = metaParts.join(' · '); - detail.append(promptRow, pathRow, metaRow); + detail.append(promptRow); + if (lastPromptRow) detail.append(lastPromptRow); + detail.append(pathRow, metaRow); if (showViewAll && s.projectKey) { const actionRow = document.createElement('div'); @@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, { } else { // Resume by the Claude conversation UUID when present (resumed sessions // carry theirs separately from their Codeman id). - this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || ''); + this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name); } this.closeSessionManager?.(); closeMenu(); } ); + // Pin / Unpin (COD-139) — floats the session to the top of the list. + const isPinned = s.pinned === true; + addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => { + const ok = await this._setSessionPinned(s.sessionId, !isPinned); + if (ok) { + // Optimistic local flip so a re-render before the SSE event is consistent. + s.pinned = !isPinned; + this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success'); + } else { + this.showToast('Pin failed', 'error'); + } + closeMenu(); + }); + // Open folder (only for a live+open session — file browser is session-scoped). if (isLiveOpen) { addItem('Open folder', () => { @@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, { this._openRowMenuClose = closeMenu; }, + /** + * COD-139: Toggle a session's pin via POST /api/sessions/:id/pin. + * Pinned sessions float to the top of the session manager list. Returns true + * on success. The live re-sort happens when the session:pinned SSE event + * fires (handled in app.js), so callers don't need to re-render themselves. + * @param {string} sessionId + * @param {boolean} pinned explicit desired pin state (idempotent) + * @returns {Promise} + */ + async _setSessionPinned(sessionId, pinned) { + try { + const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ pinned }), + }); + if (!res.ok) return false; + const data = await res.json(); + return data?.success === true; + } catch (err) { + console.error('[_setSessionPinned]', err); + return false; + } + }, + /** Number of history items shown before "Show More" */ _HISTORY_INITIAL_COUNT: 4, @@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, { this._folderHistoryState = null; }, - async resumeHistorySession(sessionId, workingDir) { + // Choose the name for a resumed session: keep the session's own name when it + // has one, otherwise synthesize a fresh w- name (next free w-number + // across open sessions). COD-143 — resume used to always generate a new name. + _resolveResumeName(existingName, workingDir) { + if (typeof existingName === 'string' && existingName.trim()) return existingName; + const dirName = (workingDir || '').split('/').pop() || 'session'; + let startNumber = 1; + for (const [, session] of this.sessions) { + const match = session.name && session.name.match(/^w(\d+)-/); + if (match) { + const num = parseInt(match[1]); + if (num >= startNumber) startNumber = num + 1; + } + } + return `w${startNumber}-${dirName}`; + }, + + async resumeHistorySession(sessionId, workingDir, existingName) { // Close the run mode menu if open document.getElementById('runModeMenu')?.classList.remove('active'); // Close folder history modal if open @@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); - // Generate a session name from the working dir - const dirName = workingDir.split('/').pop() || 'session'; - let startNumber = 1; - for (const [, session] of this.sessions) { - const match = session.name && session.name.match(/^w(\d+)-/); - if (match) { - const num = parseInt(match[1]); - if (num >= startNumber) startNumber = num + 1; - } - } - const name = `w${startNumber}-${dirName}`; + // Keep the session's own name when resuming; only synthesize a w- + // name when the source row had none (COD-143). + const name = this._resolveResumeName(existingName, workingDir); // Create session with resumeSessionId — include envOverrides so resumed // conversations inherit current UI settings (effort, agent teams, etc.). diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6da2cf52..23eb0f00 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -30,12 +30,15 @@ import { AutoClearSchema, AutoCompactSchema, AutoResumeSchema, + PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, QuickStartSchema, InteractiveStartSchema, + SessionOrderUpdateSchema, } from '../schemas.js'; +import { mergeSessionOrder } from '../../session-order.js'; import { autoConfigureRalph, CASES_DIR, @@ -280,6 +283,18 @@ export function registerSessionRoutes( return ctx.getLightSessionsState(); }); + // ========== Session Tab Order (global sync, COD-131) ========== + + app.put('/api/session-order', async (req): Promise> => { + const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order'); + // Server is authoritative but never drops ids it knows about that the + // pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder). + const merged = mergeSessionOrder(order, ctx.store.getSessionOrder()); + ctx.store.setSessionOrder(merged); + ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged }); + return { success: true, data: { order: merged } }; + }); + // ========== Session Creation ========== app.post('/api/sessions', async (req) => { @@ -1551,6 +1566,32 @@ export function registerSessionRoutes( }; }); + // ========== Pin (float to top of the session manager list, COD-139) ========== + + app.post('/api/sessions/:id/pin', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(PinSessionSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id); + + session.setPinned(body.pinned); + // Persist + broadcast session:updated (keeps tabs/state consistent), then a + // dedicated session:pinned event so the session manager list re-sorts live. + persistAndBroadcastSession(ctx, session); + ctx.broadcast(SseEvent.SessionPinned, { + id, + pinned: session.pinned, + pinnedAt: session.pinnedAt ?? undefined, + }); + + return { + success: true, + data: { + pinned: session.pinned, + pinnedAt: session.pinnedAt ?? undefined, + }, + }; + }); + // ========== Image Watcher ========== app.post('/api/sessions/:id/image-watcher', async (req) => { @@ -1983,6 +2024,59 @@ export function registerSessionRoutes( return undefined; } + /** + * Extract the text of the LAST user message from a JSONL transcript chunk + * (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message + * detection, same noise/secret/slash-command filters, same 120-char cap — but + * keeps the last qualifying match instead of returning on the first. Scan the + * file tail for this (the most recent prompt lives near the end). + */ + function extractLastUserPrompt(text: string): string | undefined { + const MAX_PROMPT_LEN = 120; + let result: string | undefined; + 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; + if (!line.includes('"type":"user"')) continue; + try { + const entry = JSON.parse(line); + if (entry.type !== 'user' || !entry.message) continue; + const content = entry.message.content; + let msgText: string | undefined; + if (typeof content === 'string') { + msgText = content; + } else if (Array.isArray(content)) { + const textBlock = content.find((b: { type: string }) => b.type === 'text'); + if (textBlock) msgText = textBlock.text; + } + if (!msgText) continue; + msgText = msgText + .replace(/<[^>]+>/g, '') + .replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '') + .trim() + .replace(/\s+/g, ' '); + if (!msgText) continue; + if ( + /^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) || + /^(Please )?(analyze|review) this codebase/i.test(msgText) || + /^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) || + /^\d+ vulnerabilit/i.test(msgText) || + /\btoolu_/.test(msgText) || + /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) || + /\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) || + msgText.length < 8 + ) + continue; + result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText; + } catch { + // Malformed line — skip + } + } + return result; + } + /** * Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror") * back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror"). @@ -2121,6 +2215,7 @@ export function registerSessionRoutes( sizeBytes: number; lastModified: string; firstPrompt?: string; + lastPrompt?: string; }; // Scan a single project directory and return all valid history sessions in it. @@ -2166,6 +2261,17 @@ export function registerSessionRoutes( if (tail) firstPrompt = extractFirstUserPrompt(tail); } + // COD-145: last (most recent) user prompt lives near the END of the file, so + // prefer the tail. For large files where no tail was read yet, read one + // (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which + // then contains the whole transcript — scan it for the last match instead. + if (!tail && fileStat.size > 65536) { + const tailBuf = Buffer.alloc(32768); + tail = await readFileTail(filePath, tailBuf, fileStat.size); + } + const lastPrompt = + (tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined); + out.push({ sessionId, workingDir, @@ -2173,6 +2279,7 @@ export function registerSessionRoutes( sizeBytes: fileStat.size, lastModified: fileStat.mtime.toISOString(), firstPrompt, + lastPrompt, }); } return out; @@ -2238,6 +2345,8 @@ export function registerSessionRoutes( createdAt: st.createdAt, lastActivityAt: st.lastActivityAt, claudeSessionId: s.claudeSessionId ?? undefined, + pinned: st.pinned, + pinnedAt: st.pinnedAt, }; }); @@ -2253,6 +2362,8 @@ export function registerSessionRoutes( createdAt: p.createdAt, lastActivityAt: p.lastActivityAt, claudeSessionId: p.resumeSessionId, + pinned: p.pinned, + pinnedAt: p.pinnedAt, })); // Lifecycle audit log (newest-first, capped). @@ -2286,6 +2397,7 @@ export function registerSessionRoutes( sizeBytes: h.sizeBytes, lastModified: h.lastModified, firstPrompt: h.firstPrompt, + lastPrompt: h.lastPrompt, projectKey: h.projectKey, }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 91485a45..c6393d42 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -667,6 +667,11 @@ export const AutoResumeSchema = z.object({ enabled: z.boolean(), }); +/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ +export const PinSessionSchema = z.object({ + pinned: z.boolean(), +}); + /** POST /api/sessions/:id/image-watcher */ export const ImageWatcherSchema = z.object({ enabled: z.boolean(), @@ -769,6 +774,11 @@ export const CaseOrderSchema = z.object({ order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), }); +/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ +export const SessionOrderUpdateSchema = z.object({ + order: z.array(z.string()), +}); + /** POST /api/auth/revoke */ export const RevokeSessionSchema = z.object({ sessionToken: z.string().min(1).max(200).optional(), diff --git a/src/web/server.ts b/src/web/server.ts index 393b5a35..acb0b4cd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1185,7 +1185,7 @@ export class WebServer extends EventEmitter { // Only remove from state.json if we're also killing the mux session. // When killMux=false (server shutdown), preserve state for recovery. if (killMux) { - this.store.removeSession(sessionId); + this.store.demoteOrRemoveSession(sessionId); } } @@ -1769,6 +1769,7 @@ export class WebServer extends EventEmitter { timestamp: now, inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON', planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load + sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131) }; this.cachedLightState = { data: result, timestamp: now }; diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 6fc419d6..a6442f6b 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const; /** CLI version/model info detected from session output. */ export const SessionCliInfo = 'session:cliInfo' as const; +/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */ +export const SessionPinned = 'session:pinned' as const; /** General session message (e.g. status text). */ export const SessionMessage = 'session:message' as const; /** Session entered interactive mode (claude or shell). */ @@ -370,6 +372,9 @@ export const CaseDeleted = 'case:deleted' as const; /** Case ordering changed. */ export const CaseOrderChanged = 'case:order-changed' as const; +/** Global session tab order changed (synced across devices). COD-131. */ +export const SessionOrderChanged = 'session:orderChanged' as const; + // ─── Namespace Re-export ───────────────────────────────────────────────────── /** @@ -399,6 +404,7 @@ export const SseEvent = { SessionLimitResumeCancelled, SessionRespawnBreakerTripped, SessionCliInfo, + SessionPinned, SessionMessage, SessionInteractive, SessionRunning, @@ -551,4 +557,7 @@ export const SseEvent = { CaseLinked, CaseDeleted, CaseOrderChanged, + + // Session order (global tab order sync) + SessionOrderChanged, } as const; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index e7dedc62..14aa1ea1 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) { const sessions = new Map(); sessions.set(sessionId, session); + // Stateful backing for the global tab order (COD-131) so route tests can + // assert that setSessionOrder() actually persists what the handler computed. + let sessionOrder: string[] = []; + return { // -- SessionPort -- sessions, @@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) { load: vi.fn(), incrementSessionsCreated: vi.fn(), setConfig: vi.fn(), + getSessionOrder: vi.fn(() => sessionOrder), + setSessionOrder: vi.fn((order: string[]) => { + sessionOrder = order; + }), getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })), getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })), getDailyStats: vi.fn(() => []), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index cc61df12..bda2077b 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -229,6 +229,8 @@ export class MockSession extends EventEmitter { color: this.color, mode: this.mode, muxName: this._muxName, + pinned: this.pinned || undefined, + pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined, }; } @@ -241,6 +243,14 @@ export class MockSession extends EventEmitter { if (!enabled) this.autoResumeAt = null; }); + /** Pin state (COD-139) */ + pinned: boolean = false; + pinnedAt: number | null = null; + setPinned = vi.fn((pinned: boolean) => { + this.pinned = pinned; + this.pinnedAt = pinned ? Date.now() : null; + }); + /** Check if session is busy */ isBusy = vi.fn(() => false); diff --git a/test/resume-name.test.ts b/test/resume-name.test.ts new file mode 100644 index 00000000..bf20eb34 --- /dev/null +++ b/test/resume-name.test.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview COD-143 — resuming a session from the Session Manager must retain its + * original tab name, not synthesize a fresh `w-` name every time. + * + * Root cause: `resumeHistorySession(sessionId, workingDir)` ignored the row's `name` and + * always built `w-` from the working dir. The fix threads the name through and + * extracts the choice into a pure `_resolveResumeName(existingName, workingDir)` helper: + * prefer a non-empty existing name; otherwise generate the next free `w-` by + * scanning open sessions' names. + * + * This pins the helper's contract: + * 1. a non-empty existing name is returned verbatim (custom name retained), + * 2. a missing/empty/whitespace name falls back to `w-`, + * 3. the generated number is the next free w-index across `this.sessions`, + * 4. the generated dir segment is the basename of workingDir (or `session` when empty). + * + * Loaded via `vm` against a stub `CodemanApp` (no jsdom — same harness as + * file-browser-reveal.test.ts / connection-indicator.test.ts). terminal-ui.js does + * `Object.assign(CodemanApp.prototype, {...})` at module-eval, so we capture the real + * `_resolveResumeName` off the prototype and invoke it against a minimal host whose + * `sessions` is a Map. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +/** Eval the shipping terminal-ui.js into a vm with a stub CodemanApp, return its prototype. */ +function loadTerminalUiPrototype(): Record unknown> { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + document: { addEventListener: vi.fn(), getElementById: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context); + return (context as { __proto: Record unknown> }).__proto; +} + +const proto = loadTerminalUiPrototype(); + +/** Minimal host carrying the real `_resolveResumeName` + a sessions Map. */ +function makeApp(sessionNames: string[] = []) { + const sessions = new Map(); + sessionNames.forEach((name, i) => sessions.set(`s${i}`, { name })); + return { + sessions, + _resolveResumeName: proto._resolveResumeName as (existingName: unknown, workingDir: unknown) => string, + }; +} + +describe('COD-143 _resolveResumeName', () => { + it('returns a non-empty existing name verbatim (custom name retained)', () => { + const app = makeApp(['w1-foo', 'w2-bar']); + expect(app._resolveResumeName.call(app, 'my-custom-tab', '/home/me/proj')).toBe('my-custom-tab'); + }); + + it('falls back to w- when no name is given', () => { + const app = makeApp([]); + expect(app._resolveResumeName.call(app, undefined, '/home/me/proj')).toBe('w1-proj'); + }); + + it('treats empty / whitespace names as no-name (falls back)', () => { + const app = makeApp([]); + expect(app._resolveResumeName.call(app, '', '/a/b/widgets')).toBe('w1-widgets'); + expect(app._resolveResumeName.call(app, ' ', '/a/b/widgets')).toBe('w1-widgets'); + }); + + it('generated w-number is the next free index across open sessions', () => { + const app = makeApp(['w1-foo', 'w3-bar', 'plain-name']); + // highest w is 3 → next is 4 + expect(app._resolveResumeName.call(app, null, '/x/y/svc')).toBe('w4-svc'); + }); + + it('uses "session" as the dir segment when workingDir is empty', () => { + const app = makeApp([]); + expect(app._resolveResumeName.call(app, '', '')).toBe('w1-session'); + }); + + it('does not let a generated fallback clobber an explicit name even when sessions exist', () => { + const app = makeApp(['w1-foo', 'w2-bar']); + expect(app._resolveResumeName.call(app, 'keepme', '/p/q')).toBe('keepme'); + }); +}); diff --git a/test/routes/session-order-routes.test.ts b/test/routes/session-order-routes.test.ts new file mode 100644 index 00000000..f7dbba7f --- /dev/null +++ b/test/routes/session-order-routes.test.ts @@ -0,0 +1,129 @@ +/** + * @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131). + * + * Uses app.inject() — no real HTTP ports needed. + * Asserts the uniform envelope contract: + * SUCCESS -> 2xx, { success: true, data: { order } } + * ERROR -> 4xx/5xx, { success: false, error, errorCode } + * and that the order is persisted to the (mock) StateStore + broadcast over SSE. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; + +// registerSessionRoutes pulls in session.js which can shell out; stub the bits +// that would touch the OS at import/registration time. None are needed by the +// session-order handler itself, but the module imports them. +vi.mock('node:child_process', async (orig) => { + const actual = await orig(); + return { ...actual, execFile: vi.fn(), spawn: vi.fn() }; +}); + +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function buildHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx as unknown as Parameters[1]); + + // Mirror production's uniform-envelope preSerialization hook (server.ts). + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('PUT /api/session-order', () => { + let harness: LocalHarness; + + beforeEach(async () => { + harness = await buildHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('persists the order and returns it in the envelope', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/session-order', + payload: { order: ['a', 'b', 'c'] }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } }); + // Persisted to the store. + expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']); + expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']); + // Broadcast over SSE. + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] }); + }); + + it('preserves a server-only id (unknown to the pushing device) at the end', async () => { + // Seed the store with an order containing a server-only id "z". + harness.ctx.store.setSessionOrder(['a', 'z', 'b']); + (harness.ctx.broadcast as ReturnType).mockClear(); + + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/session-order', + payload: { order: ['b', 'a'] }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + // Incoming order wins, server-only "z" falls to the end. + expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } }); + expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] }); + }); + + it('normalizes junk input (dedup + drop empties) before persisting', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/session-order', + payload: { order: ['a', 'a', '', 'b'] }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } }); + }); + + it('rejects a non-array order with a 4xx envelope', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/session-order', + payload: { order: 'nope' }, + }); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + const body = res.json(); + expect(body.success).toBe(false); + expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled(); + }); +}); diff --git a/test/routes/session-pin-routes.test.ts b/test/routes/session-pin-routes.test.ts new file mode 100644 index 00000000..04f1f297 --- /dev/null +++ b/test/routes/session-pin-routes.test.ts @@ -0,0 +1,126 @@ +/** + * @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139). + * + * Pinning floats a session to the top of the unified session list. The route + * sets the session's pin flag, persists it, and broadcasts session:pinned. + * Uses app.inject() with the production-mirroring envelope harness. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface LocalHarness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createEnvelopeHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx as never); + + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions/:id/pin', () => { + let harness: LocalHarness; + + beforeEach(async () => { + harness = await createEnvelopeHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('pins a session: sets state, persists, and broadcasts session:pinned', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/pin', + payload: { pinned: true }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.pinned).toBe(true); + expect(typeof body.data.pinnedAt).toBe('number'); + + expect(harness.ctx._session.pinned).toBe(true); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]); + expect(broadcastCalls).toContain('session:pinned'); + }); + + it('unpins a session and clears pinnedAt', async () => { + await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/pin', + payload: { pinned: true }, + }); + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/pin', + payload: { pinned: false }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.pinned).toBe(false); + expect(body.data.pinnedAt).toBeUndefined(); + expect(harness.ctx._session.pinned).toBe(false); + }); + + it('is idempotent for an explicit pinned value', async () => { + for (let i = 0; i < 3; i++) { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/pin', + payload: { pinned: true }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data.pinned).toBe(true); + } + expect(harness.ctx._session.pinned).toBe(true); + }); + + it('returns 404 for an unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/does-not-exist/pin', + payload: { pinned: true }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().success).toBe(false); + }); + + it('rejects a missing/invalid body with 400', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/pin', + payload: { pinned: 'yes' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().success).toBe(false); + }); +}); diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index e08880c5..f25faff7 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -163,6 +163,211 @@ describe('mergeUnifiedSessions', () => { expect(merged).toHaveLength(1); expect(merged[0].projectKey).toBe('-repo-alpha'); }); + + // COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an + // on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then + // by newest transcript in the same workingDir). + it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }], + history: [ + { + sessionId: 'uuid-A', + workingDir: '/w', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'fix the bug', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-1'); + expect(live).toBeDefined(); + expect(live!.firstPrompt).toBe('fix the bug'); + // The upstream unified-service alias map (COD-160/161) folds a history row keyed + // by the Claude conversation UUID into the owning live session (claudeSessionId + // join), so it does NOT surface as a separate item — the firstPrompt reaches the + // live row above rather than a duplicate uuid-A entry. + const hist = merged.find((m) => m.sessionId === 'uuid-A'); + expect(hist).toBeUndefined(); + }); + + it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }], + history: [ + { + sessionId: 'uuid-other', + workingDir: '/w2', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'borrowed prompt', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-2'); + expect(live).toBeDefined(); + expect(live!.firstPrompt).toBe('borrowed prompt'); + }); + + it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }], + history: [ + { + sessionId: 'uuid-old', + workingDir: '/w3', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'older prompt', + }, + { + sessionId: 'uuid-new', + workingDir: '/w3', + sizeBytes: 6000, + lastModified: '2026-02-01T00:00:00.000Z', + firstPrompt: 'newer prompt', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-3'); + expect(live).toBeDefined(); + expect(live!.firstPrompt).toBe('newer prompt'); + }); + + it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }], + history: [ + // the session's own transcript (keyed by its id) — provides the real prompt + { + sessionId: 'self-uuid', + workingDir: '/w4', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'own prompt', + }, + // a newer sibling transcript in the same dir that must NOT clobber it + { + sessionId: 'sibling-uuid', + workingDir: '/w4', + sizeBytes: 6000, + lastModified: '2026-03-01T00:00:00.000Z', + firstPrompt: 'sibling prompt', + }, + ], + }); + const self = merged.find((m) => m.sessionId === 'self-uuid'); + expect(self).toBeDefined(); + expect(self!.firstPrompt).toBe('own prompt'); + }); + + it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }], + }); + const live = merged.find((m) => m.sessionId === 'codeman-5'); + expect(live).toBeDefined(); + expect(live!.firstPrompt).toBeUndefined(); + }); + + // COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the + // most-recent user prompt also reaches live rows whose id ≠ transcript UUID. + it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }], + history: [ + { + sessionId: 'uuid-LA', + workingDir: '/wl', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'fix the bug', + lastPrompt: 'now ship it', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-l1'); + expect(live).toBeDefined(); + expect(live!.lastPrompt).toBe('now ship it'); + // The upstream unified-service alias map (COD-160/161) folds the UUID-keyed + // history row into the owning live session, so lastPrompt reaches the live row + // above rather than surfacing as a separate uuid-LA entry. + const hist = merged.find((m) => m.sessionId === 'uuid-LA'); + expect(hist).toBeUndefined(); + }); + + it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }], + history: [ + { + sessionId: 'uuid-other', + workingDir: '/wl2', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'borrowed first', + lastPrompt: 'borrowed last', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-l2'); + expect(live).toBeDefined(); + expect(live!.lastPrompt).toBe('borrowed last'); + }); + + it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }], + history: [ + { + sessionId: 'uuid-old', + workingDir: '/wl3', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'older first', + lastPrompt: 'older last', + }, + { + sessionId: 'uuid-new', + workingDir: '/wl3', + sizeBytes: 6000, + lastModified: '2026-02-01T00:00:00.000Z', + firstPrompt: 'newer first', + lastPrompt: 'newer last', + }, + ], + }); + const live = merged.find((m) => m.sessionId === 'codeman-l3'); + expect(live).toBeDefined(); + expect(live!.lastPrompt).toBe('newer last'); + }); + + it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }], + history: [ + { + sessionId: 'self-luuid', + workingDir: '/wl4', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: 'own first', + lastPrompt: 'own last', + }, + { + sessionId: 'sibling-uuid', + workingDir: '/wl4', + sizeBytes: 6000, + lastModified: '2026-03-01T00:00:00.000Z', + firstPrompt: 'sibling first', + lastPrompt: 'sibling last', + }, + ], + }); + const self = merged.find((m) => m.sessionId === 'self-luuid'); + expect(self).toBeDefined(); + expect(self!.lastPrompt).toBe('own last'); + }); }); describe('filterAndPaginate', () => { @@ -170,6 +375,14 @@ describe('filterAndPaginate', () => { { sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' }, { sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' }, { sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' }, + { + sessionId: 's4', + name: 'Delta', + firstPrompt: 'start the migration', + lastPrompt: 'roll back the migration', + sources: ['history'], + workingDir: '/repo/delta', + }, ]; it('filters by name (case-insensitive)', () => { @@ -183,18 +396,24 @@ describe('filterAndPaginate', () => { expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3'); }); + it('filters by lastPrompt (COD-145)', () => { + const r = filterAndPaginate(items, { q: 'roll back' }); + expect(r.total).toBe(1); + expect(r.sessions[0].sessionId).toBe('s4'); + }); + it('reports total as the pre-page filtered count', () => { const r = filterAndPaginate(items, { q: 'repo', limit: 1 }); - // both s1 and s2 have /repo/ workingDir - expect(r.total).toBe(2); + // s1, s2, and s4 all have /repo/ workingDir + expect(r.total).toBe(3); expect(r.sessions).toHaveLength(1); }); it('clamps limit to a max of 500', () => { const r = filterAndPaginate(items, { limit: 99999 }); expect(r.sessions).toHaveLength(items.length); - // clamp does not throw and returns all 3 (< 500) - expect(r.total).toBe(3); + // clamp does not throw and returns all items (< 500) + expect(r.total).toBe(items.length); }); it('clamps limit to a min of 1', () => { @@ -206,7 +425,7 @@ describe('filterAndPaginate', () => { const page1 = filterAndPaginate(items, { offset: 0, limit: 2 }); const page2 = filterAndPaginate(items, { offset: 2, limit: 2 }); expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']); - expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']); + expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']); const overlap = page1.sessions .map((s) => s.sessionId) .filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id)); diff --git a/test/session-order.test.ts b/test/session-order.test.ts new file mode 100644 index 00000000..0e8f012e --- /dev/null +++ b/test/session-order.test.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview Unit tests for the pure session-order helpers + * (normalizeSessionOrder, mergeSessionOrder) used by global tab-order sync (COD-131). + */ +import { describe, it, expect } from 'vitest'; +import { normalizeSessionOrder, mergeSessionOrder } from '../src/session-order.js'; + +describe('normalizeSessionOrder', () => { + it('keeps a clean array unchanged', () => { + expect(normalizeSessionOrder(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('dedups, first occurrence wins', () => { + expect(normalizeSessionOrder(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']); + }); + + it('drops empty strings', () => { + expect(normalizeSessionOrder(['a', '', 'b', ' '])).toEqual(['a', 'b', ' ']); + expect(normalizeSessionOrder([''])).toEqual([]); + }); + + it('drops non-string entries', () => { + expect(normalizeSessionOrder(['a', 1, null, undefined, {}, 'b', true])).toEqual(['a', 'b']); + }); + + it('returns [] for non-array input', () => { + expect(normalizeSessionOrder(undefined)).toEqual([]); + expect(normalizeSessionOrder(null)).toEqual([]); + expect(normalizeSessionOrder('abc')).toEqual([]); + expect(normalizeSessionOrder(42)).toEqual([]); + expect(normalizeSessionOrder({ 0: 'a' })).toEqual([]); + }); + + it('returns [] for empty array', () => { + expect(normalizeSessionOrder([])).toEqual([]); + }); +}); + +describe('mergeSessionOrder', () => { + it('incoming order wins', () => { + expect(mergeSessionOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']); + }); + + it('appends server-only ids (not in incoming) at the end, preserving their relative order', () => { + expect(mergeSessionOrder(['a', 'b'], ['x', 'a', 'y', 'b', 'z'])).toEqual(['a', 'b', 'x', 'y', 'z']); + }); + + it('empty incoming yields the existing order (normalized)', () => { + expect(mergeSessionOrder([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); + }); + + it('empty existing yields the incoming order (normalized)', () => { + expect(mergeSessionOrder(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']); + }); + + it('both empty yields empty', () => { + expect(mergeSessionOrder([], [])).toEqual([]); + }); + + it('normalizes both args (dedup + drop junk) before merging', () => { + expect(mergeSessionOrder(['a', 'a', '', 'b'], ['b', 'c', 'c', ''])).toEqual(['a', 'b', 'c']); + }); + + it('does not duplicate an id present in both', () => { + expect(mergeSessionOrder(['a', 'b'], ['b', 'a'])).toEqual(['a', 'b']); + }); + + it('handles non-array / junk inputs defensively', () => { + // @ts-expect-error testing runtime robustness against bad input + expect(mergeSessionOrder(null, ['a', 'b'])).toEqual(['a', 'b']); + // @ts-expect-error testing runtime robustness against bad input + expect(mergeSessionOrder(['a'], 'nope')).toEqual(['a']); + }); +}); diff --git a/test/session-pin.test.ts b/test/session-pin.test.ts new file mode 100644 index 00000000..df211c31 --- /dev/null +++ b/test/session-pin.test.ts @@ -0,0 +1,85 @@ +/** + * @fileoverview Unit tests for session pinning (COD-139) — the pure + * merge/sort layer in unified-session-service.ts. + * + * Pinned sessions float to the top of the unified session list, ordered by + * pinnedAt descending (most-recently-pinned first). Unpinned sessions keep the + * existing lastActivityAt-desc ordering. Pin state flows through both the live + * and persisted inputs so it survives a reload (live → persisted-only on boot). + */ + +import { describe, it, expect } from 'vitest'; +import { mergeUnifiedSessions } from '../src/services/unified-session-service.js'; + +describe('mergeUnifiedSessions — pinning (COD-139)', () => { + it('floats a pinned session above unpinned ones regardless of activity', () => { + const merged = mergeUnifiedSessions({ + live: [ + { id: 'a', name: 'A', lastActivityAt: 100 }, + { id: 'b', name: 'B', lastActivityAt: 5000, pinned: true, pinnedAt: 200 }, + { id: 'c', name: 'C', lastActivityAt: 9000 }, + ], + }); + // b is pinned → first, even though c is the most-recently active. + expect(merged.map((m) => m.sessionId)).toEqual(['b', 'c', 'a']); + expect(merged[0].pinned).toBe(true); + }); + + it('orders multiple pinned sessions by pinnedAt descending (most recent first)', () => { + const merged = mergeUnifiedSessions({ + live: [ + { id: 'p1', name: 'P1', lastActivityAt: 1, pinned: true, pinnedAt: 100 }, + { id: 'p2', name: 'P2', lastActivityAt: 2, pinned: true, pinnedAt: 300 }, + { id: 'p3', name: 'P3', lastActivityAt: 3, pinned: true, pinnedAt: 200 }, + { id: 'u', name: 'U', lastActivityAt: 9999 }, + ], + }); + // Pinned group sorted by pinnedAt desc: p2(300) p3(200) p1(100); then unpinned. + expect(merged.map((m) => m.sessionId)).toEqual(['p2', 'p3', 'p1', 'u']); + }); + + it('keeps the existing activity-desc order among unpinned sessions', () => { + const merged = mergeUnifiedSessions({ + live: [ + { id: 'old', name: 'Old', lastActivityAt: 100 }, + { id: 'new', name: 'New', lastActivityAt: 900 }, + { id: 'mid', name: 'Mid', lastActivityAt: 500 }, + ], + }); + expect(merged.map((m) => m.sessionId)).toEqual(['new', 'mid', 'old']); + }); + + it('surfaces pin state from a persisted-only session (survives reload)', () => { + // On boot, a live session becomes persisted-only (status stopped). The pin + // flag must come through the persisted input so it still floats to the top. + const merged = mergeUnifiedSessions({ + persisted: [ + { id: 'fresh', name: 'Fresh', lastActivityAt: 5000 }, + { id: 'pinned', name: 'Pinned', lastActivityAt: 1, pinned: true, pinnedAt: 42 }, + ], + }); + expect(merged[0].sessionId).toBe('pinned'); + expect(merged[0].pinned).toBe(true); + expect(merged[0].pinnedAt).toBe(42); + }); + + it('live pin overrides a stale persisted unpinned value (live precedence)', () => { + const merged = mergeUnifiedSessions({ + persisted: [{ id: 's', name: 'S', lastActivityAt: 10 }], + live: [{ id: 's', name: 'S', lastActivityAt: 10, pinned: true, pinnedAt: 77 }], + }); + const s = merged.find((m) => m.sessionId === 's'); + expect(s?.pinned).toBe(true); + expect(s?.pinnedAt).toBe(77); + }); + + it('treats pinned:false the same as unpinned', () => { + const merged = mergeUnifiedSessions({ + live: [ + { id: 'x', name: 'X', lastActivityAt: 100, pinned: false }, + { id: 'y', name: 'Y', lastActivityAt: 900, pinned: false }, + ], + }); + expect(merged.map((m) => m.sessionId)).toEqual(['y', 'x']); + }); +}); diff --git a/test/state-store.test.ts b/test/state-store.test.ts index 78ed87da..56b522df 100644 --- a/test/state-store.test.ts +++ b/test/state-store.test.ts @@ -141,6 +141,66 @@ describe('StateStore', () => { }); }); + describe('demoteOrRemoveSession and pinned cleanup (COD-142)', () => { + it('should preserve a pinned session as a stopped record on kill', () => { + const store = new StateStore(testFilePath); + const pinnedAt = Date.now(); + store.setSession('pinned-1', { + ...createMockSessionState('pinned-1'), + name: 'My Pinned Session', + workingDir: '/tmp/pinned', + pinned: true, + pinnedAt, + }); + + const result = store.demoteOrRemoveSession('pinned-1'); + + expect(result).toBe('preserved'); + const preserved = store.getSession('pinned-1'); + expect(preserved).not.toBeNull(); + expect(preserved?.status).toBe('stopped'); + expect(preserved?.pid).toBeNull(); + expect(preserved?.pinned).toBe(true); + expect(preserved?.pinnedAt).toBe(pinnedAt); + expect(preserved?.name).toBe('My Pinned Session'); + expect(preserved?.workingDir).toBe('/tmp/pinned'); + }); + + it('should fully remove an unpinned session on kill', () => { + const store = new StateStore(testFilePath); + store.setSession('plain-1', createMockSessionState('plain-1')); + + const result = store.demoteOrRemoveSession('plain-1'); + + expect(result).toBe('removed'); + expect(store.getSession('plain-1')).toBeNull(); + }); + + it('should report absent for an unknown session id', () => { + const store = new StateStore(testFilePath); + + expect(store.demoteOrRemoveSession('does-not-exist')).toBe('absent'); + }); + + it('should keep pinned records during cleanupStaleSessions but reap unpinned ones', () => { + const store = new StateStore(testFilePath); + store.setSession('pinned-1', { + ...createMockSessionState('pinned-1'), + pinned: true, + pinnedAt: Date.now(), + }); + store.setSession('plain-1', createMockSessionState('plain-1')); + + const result = store.cleanupStaleSessions(new Set()); + + expect(result.count).toBe(1); + expect(result.cleaned.map((c) => c.id)).toEqual(['plain-1']); + expect(result.cleaned.some((c) => c.id === 'pinned-1')).toBe(false); + expect(store.getSession('pinned-1')).not.toBeNull(); + expect(store.getSession('plain-1')).toBeNull(); + }); + }); + describe('task operations', () => { it('should set and get tasks', () => { const store = new StateStore(testFilePath);