From 5792c2d62e6f9937669b31703a6c9c465d98b168 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 20 Jun 2026 12:18:07 -0400 Subject: [PATCH] COD-139 add session pinning (float pinned sessions to top of session manager list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/services/unified-session-service.ts | 30 +++++- src/session.ts | 27 +++++ src/types/session.ts | 4 + src/web/public/app.js | 20 ++++ src/web/public/constants.js | 1 + src/web/public/styles.css | 19 ++++ src/web/public/terminal-ui.js | 55 ++++++++++- src/web/routes/session-routes.ts | 31 ++++++ src/web/schemas.ts | 5 + src/web/sse-events.ts | 3 + test/mocks/mock-session.ts | 10 ++ test/routes/session-pin-routes.test.ts | 126 ++++++++++++++++++++++++ test/session-pin.test.ts | 85 ++++++++++++++++ 13 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 test/routes/session-pin-routes.test.ts create mode 100644 test/session-pin.test.ts diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 03b7c618..3126c666 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -31,6 +31,10 @@ export type UnifiedSessionItem = { 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 +50,8 @@ export type LiveSessionInput = { createdAt?: number; lastActivityAt?: number; claudeSessionId?: string; + pinned?: boolean; + pinnedAt?: number; }; /** Persisted session view (subset of `SessionState`). */ @@ -59,6 +65,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). */ @@ -175,6 +183,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 +199,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) @@ -211,8 +223,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) { 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/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 c6017815..0bec0273 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1436,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(); + }); } // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 3826feda..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', 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..9dd9e940 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 @@ -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'); @@ -1470,6 +1481,20 @@ Object.assign(CodemanApp.prototype, { } ); + // 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 +1561,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, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 202996d7..62551974 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -30,6 +30,7 @@ import { AutoClearSchema, AutoCompactSchema, AutoResumeSchema, + PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, @@ -1565,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) => { @@ -2252,6 +2279,8 @@ export function registerSessionRoutes( createdAt: st.createdAt, lastActivityAt: st.lastActivityAt, claudeSessionId: s.claudeSessionId ?? undefined, + pinned: st.pinned, + pinnedAt: st.pinnedAt, }; }); @@ -2267,6 +2296,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). diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e1bd7c55..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(), diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 4a7d8878..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). */ @@ -402,6 +404,7 @@ export const SseEvent = { SessionLimitResumeCancelled, SessionRespawnBreakerTripped, SessionCliInfo, + SessionPinned, SessionMessage, SessionInteractive, SessionRunning, 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/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/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']); + }); +});