mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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:
@@ -31,6 +31,10 @@ export type UnifiedSessionItem = {
|
|||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
remote?: boolean;
|
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[];
|
sources: string[];
|
||||||
stats?: { memoryMB: number; cpuPercent: number };
|
stats?: { memoryMB: number; cpuPercent: number };
|
||||||
};
|
};
|
||||||
@@ -46,6 +50,8 @@ export type LiveSessionInput = {
|
|||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinnedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Persisted session view (subset of `SessionState`). */
|
/** Persisted session view (subset of `SessionState`). */
|
||||||
@@ -59,6 +65,8 @@ export type PersistedSessionInput = {
|
|||||||
lastActivityAt?: number;
|
lastActivityAt?: number;
|
||||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinnedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
/** 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, 'workingDir', p.workingDir);
|
||||||
overwrite(item, 'createdAt', p.createdAt);
|
overwrite(item, 'createdAt', p.createdAt);
|
||||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||||
|
overwrite(item, 'pinned', p.pinned);
|
||||||
|
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) live (highest precedence)
|
// 4) live (highest precedence)
|
||||||
@@ -189,6 +199,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
|||||||
overwrite(item, 'createdAt', v.createdAt);
|
overwrite(item, 'createdAt', v.createdAt);
|
||||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
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)
|
// 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);
|
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) => {
|
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 la = a.lastActivityAt;
|
||||||
const lb = b.lastActivityAt;
|
const lb = b.lastActivityAt;
|
||||||
if (la !== lb) {
|
if (la !== lb) {
|
||||||
|
|||||||
@@ -336,6 +336,11 @@ export class Session extends EventEmitter {
|
|||||||
// Image watcher setting (per-session toggle)
|
// Image watcher setting (per-session toggle)
|
||||||
private _imageWatcherEnabled: boolean = false;
|
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)
|
// Flicker filter setting (per-session toggle, applied on frontend)
|
||||||
private _flickerFilterEnabled: boolean = false;
|
private _flickerFilterEnabled: boolean = false;
|
||||||
|
|
||||||
@@ -957,6 +962,26 @@ export class Session extends EventEmitter {
|
|||||||
this._imageWatcherEnabled = enabled;
|
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 {
|
get flickerFilterEnabled(): boolean {
|
||||||
return this._flickerFilterEnabled;
|
return this._flickerFilterEnabled;
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1046,8 @@ export class Session extends EventEmitter {
|
|||||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||||
|
pinned: this._pinned || undefined,
|
||||||
|
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||||
totalCost: this._totalCost,
|
totalCost: this._totalCost,
|
||||||
inputTokens: this._totalInputTokens,
|
inputTokens: this._totalInputTokens,
|
||||||
outputTokens: this._totalOutputTokens,
|
outputTokens: this._totalOutputTokens,
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ export interface SessionState {
|
|||||||
autoResumeEnabled?: boolean;
|
autoResumeEnabled?: boolean;
|
||||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||||
autoResumeAt?: number;
|
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 */
|
/** Image watcher enabled for this session */
|
||||||
imageWatcherEnabled?: boolean;
|
imageWatcherEnabled?: boolean;
|
||||||
/** Total cost in USD */
|
/** Total cost in USD */
|
||||||
|
|||||||
@@ -1436,6 +1436,26 @@ class CodemanApp {
|
|||||||
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
|
||||||
addListener(event, () => this._onSessionListMaybeChanged());
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
|
|||||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||||
SESSION_CLI_INFO: 'session:cliInfo',
|
SESSION_CLI_INFO: 'session:cliInfo',
|
||||||
|
SESSION_PINNED: 'session:pinned',
|
||||||
SESSION_MESSAGE: 'session:message',
|
SESSION_MESSAGE: 'session:message',
|
||||||
SESSION_INTERACTIVE: 'session:interactive',
|
SESSION_INTERACTIVE: 'session:interactive',
|
||||||
SESSION_RUNNING: 'session:running',
|
SESSION_RUNNING: 'session:running',
|
||||||
|
|||||||
@@ -2794,6 +2794,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
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 {
|
.history-item-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
||||||
|
|
||||||
|
const isPinned = s.pinned === true;
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'history-item';
|
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
|
||||||
item.title = s.workingDir || '';
|
item.title = s.workingDir || '';
|
||||||
|
|
||||||
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
||||||
@@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const titleSpan = document.createElement('span');
|
const titleSpan = document.createElement('span');
|
||||||
titleSpan.className = 'history-item-title';
|
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.
|
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
|
||||||
const badgeRow = document.createElement('div');
|
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).
|
// Open folder (only for a live+open session — file browser is session-scoped).
|
||||||
if (isLiveOpen) {
|
if (isLiveOpen) {
|
||||||
addItem('Open folder', () => {
|
addItem('Open folder', () => {
|
||||||
@@ -1536,6 +1561,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._openRowMenuClose = closeMenu;
|
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<boolean>}
|
||||||
|
*/
|
||||||
|
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" */
|
/** Number of history items shown before "Show More" */
|
||||||
_HISTORY_INITIAL_COUNT: 4,
|
_HISTORY_INITIAL_COUNT: 4,
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
AutoClearSchema,
|
AutoClearSchema,
|
||||||
AutoCompactSchema,
|
AutoCompactSchema,
|
||||||
AutoResumeSchema,
|
AutoResumeSchema,
|
||||||
|
PinSessionSchema,
|
||||||
ImageWatcherSchema,
|
ImageWatcherSchema,
|
||||||
FlickerFilterSchema,
|
FlickerFilterSchema,
|
||||||
QuickRunSchema,
|
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 ==========
|
// ========== Image Watcher ==========
|
||||||
|
|
||||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||||
@@ -2252,6 +2279,8 @@ export function registerSessionRoutes(
|
|||||||
createdAt: st.createdAt,
|
createdAt: st.createdAt,
|
||||||
lastActivityAt: st.lastActivityAt,
|
lastActivityAt: st.lastActivityAt,
|
||||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||||
|
pinned: st.pinned,
|
||||||
|
pinnedAt: st.pinnedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2267,6 +2296,8 @@ export function registerSessionRoutes(
|
|||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
lastActivityAt: p.lastActivityAt,
|
lastActivityAt: p.lastActivityAt,
|
||||||
claudeSessionId: p.resumeSessionId,
|
claudeSessionId: p.resumeSessionId,
|
||||||
|
pinned: p.pinned,
|
||||||
|
pinnedAt: p.pinnedAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Lifecycle audit log (newest-first, capped).
|
// Lifecycle audit log (newest-first, capped).
|
||||||
|
|||||||
@@ -667,6 +667,11 @@ export const AutoResumeSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
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 */
|
/** POST /api/sessions/:id/image-watcher */
|
||||||
export const ImageWatcherSchema = z.object({
|
export const ImageWatcherSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
|
|||||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||||
/** CLI version/model info detected from session output. */
|
/** CLI version/model info detected from session output. */
|
||||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
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). */
|
/** General session message (e.g. status text). */
|
||||||
export const SessionMessage = 'session:message' as const;
|
export const SessionMessage = 'session:message' as const;
|
||||||
/** Session entered interactive mode (claude or shell). */
|
/** Session entered interactive mode (claude or shell). */
|
||||||
@@ -402,6 +404,7 @@ export const SseEvent = {
|
|||||||
SessionLimitResumeCancelled,
|
SessionLimitResumeCancelled,
|
||||||
SessionRespawnBreakerTripped,
|
SessionRespawnBreakerTripped,
|
||||||
SessionCliInfo,
|
SessionCliInfo,
|
||||||
|
SessionPinned,
|
||||||
SessionMessage,
|
SessionMessage,
|
||||||
SessionInteractive,
|
SessionInteractive,
|
||||||
SessionRunning,
|
SessionRunning,
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
|
|||||||
color: this.color,
|
color: this.color,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
muxName: this._muxName,
|
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;
|
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 */
|
/** Check if session is busy */
|
||||||
isBusy = vi.fn(() => false);
|
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