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:
Aamer Akhter
2026-07-17 16:25:29 -04:00
parent 8807b3ff6d
commit 5792c2d62e
13 changed files with 413 additions and 3 deletions
+20
View File
@@ -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();
});
}
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -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',
+19
View File
@@ -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;
+53 -2
View File
@@ -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<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" */
_HISTORY_INITIAL_COUNT: 4,
+31
View File
@@ -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).
+5
View File
@@ -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(),
+3
View File
@@ -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,