mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
COD-139 add session pinning (float pinned sessions to top of session manager list)
Pin/unpin a session via POST /api/sessions/:id/pin {pinned}; pinned sessions
sort above unpinned in the unified session manager list (COD-121), ordered by
pinnedAt descending. Pin state lives on SessionState, persists to state.json,
and survives reload/reconnect/restart (persisted-input carries pinned; the
merge skips undefined so a recovered live session can't clobber it). New SSE
event session:pinned re-sorts the open list live across clients. Pin/Unpin
affordance in the session-row kebab menu with a 📌 glyph + amber highlight.
(cherry picked from commit 82749747039afcd4a3104f6a97ce7d3c2ddd048d)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<LocalHarness> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user