Merge PR #153 from aakhter/cod-161-session-manager-frontend: unified Session Manager — welcome list + searchable modal + live SSE refresh

Rebuilt on the merged #146 Session Manager: kept master's Command Palette + fixed
Session Manager implementation, dropped the PR's stale duplicate block (last-key-wins
regression), rebased _buildHistoryItem on master's onActivate contract, kept the new
projectKey plumbing + SSE live refresh + kebab menu/badges, phone-hid the header button.
This commit is contained in:
Codeman maintainer
2026-07-13 00:43:18 +02:00
10 changed files with 392 additions and 24 deletions
+3
View File
@@ -29,6 +29,7 @@ export type UnifiedSessionItem = {
claudeSessionId?: string;
firstPrompt?: string;
sizeBytes?: number;
projectKey?: string;
remote?: boolean;
sources: string[];
stats?: { memoryMB: number; cpuPercent: number };
@@ -76,6 +77,7 @@ export type HistoryInput = {
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
projectKey?: string;
};
/** Mux process-stat view. */
@@ -147,6 +149,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', h.workingDir);
overwrite(item, 'sizeBytes', h.sizeBytes);
overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'projectKey', h.projectKey);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}
+12
View File
@@ -1421,6 +1421,18 @@ class CodemanApp {
for (const [event] of _SSE_HANDLER_MAP) {
addListener(event, this._sseHandlerWrappers.get(event));
}
// COD-121: live-refresh the unified session list (Session Manager modal +
// visible welcome list) on session structural changes. Extra listeners on the
// same EventSource — EventSource supports multiple listeners per event — so the
// existing handlers above are untouched. Registered through addListener so they
// are torn down with the rest on reconnect. Only structural events (created /
// deleted) trigger a refetch: session:updated is batch-broadcast every ~500ms
// per active session, which would otherwise turn an open modal / visible welcome
// list into a sustained ~1 Hz full ~/.claude/projects rescan loop.
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
addListener(event, () => this._onSessionListMaybeChanged());
}
}
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -119,6 +119,7 @@
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-away-digest" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
+6 -4
View File
@@ -459,14 +459,16 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear, lifecycle log, and away digest on mobile - settings
moved to toolbar; away digest is a secondary informational control that doesn't
belong on the cramped phone header.
/* Hide header settings gear, lifecycle log, away digest, and session manager on
mobile - settings moved to toolbar; away digest and the session manager are
secondary controls that don't belong on the cramped phone header (the session
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest {
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager {
display: none !important;
}
+22
View File
@@ -671,6 +671,28 @@ Object.assign(CodemanApp.prototype, {
if (modal) modal.classList.remove('active');
},
/**
* COD-121: live-refresh the unified session list when sessions change
* (created/updated/deleted via SSE). Only touches surfaces that are currently
* showing — the open Session Manager modal and/or the visible welcome list —
* and is debounced so an event burst collapses into one re-fetch. The current
* search query is preserved.
*/
_onSessionListMaybeChanged() {
const modal = document.getElementById('sessionManagerModal');
if (modal && modal.classList.contains('active')) {
this._debouncedCall(
'sessionManagerRefresh',
() => this._loadSessionManagerList(this._sessionManagerQuery || ''),
400
);
}
const welcome = document.getElementById('welcomeOverlay');
if (welcome && welcome.classList.contains('visible')) {
this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600);
}
},
setAwayDigestRange(range) {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
+71
View File
@@ -2831,6 +2831,32 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
font-weight: 500;
}
/* Badge row (mode + LIVE pills) sits between the title and subtitle (COD-121). */
.history-item-badges {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
.history-item-badge {
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1;
padding: 0.12rem 0.35rem;
border-radius: 0.35rem;
background: var(--bg-hover);
color: var(--text-muted);
white-space: nowrap;
}
.history-item-badge-live {
background: color-mix(in srgb, var(--green) 22%, transparent);
color: var(--green);
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
@@ -5713,6 +5739,51 @@ kbd {
overflow-y: auto;
}
/* COD-130: per-row ⋯ kebab context menu (appended to <body>, fixed-positioned).
z-index must clear the Session Manager modal (.modal is z-index: 1000). */
.session-row-menu {
position: fixed;
z-index: 10010;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
background: rgba(22, 22, 28, 0.97);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.session-row-menu-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
width: 100%;
padding: 7px 12px;
background: none;
border: none;
border-radius: var(--btn-radius);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
text-align: left;
white-space: nowrap;
transition: background var(--transition-smooth);
}
.session-row-menu-item:hover {
background: var(--bg-hover);
}
.session-row-menu-sublabel {
color: var(--text-muted);
font-size: 0.72rem;
}
/* Shortcut settings rows (App Settings → Shortcuts, COD-157) */
.shortcut-setting-row {
display: grid;
+259 -19
View File
@@ -1143,6 +1143,25 @@ Object.assign(CodemanApp.prototype, {
return items;
},
/**
* Fetch the unified session list (live + persisted + non-Claude + closed
* history), already de-duplicated and sorted newest-first by the backend
* (`GET /api/sessions/unified`, COD-121). No client-side grouping needed.
* @param {number} [limit=60] max sessions to request
* @returns {Promise<Array>} unified session items, most recent first
*/
async _fetchUnifiedSessions(limit = 60) {
const res = await fetch('/api/sessions/unified?limit=' + limit);
// ApiResponse envelope: { success, data: { sessions } }. Throw on failure so
// callers (loadHistorySessions) hit their catch instead of rendering a 5xx as
// an empty history.
const data = await res.json().catch(() => null);
if (!res.ok || !data || data.success === false || !data.data) {
throw new Error(data?.error || `unified sessions request failed (HTTP ${res.status})`);
}
return data.data.sessions || [];
},
/**
* Resolve workingDir to a case-aware short label.
* - Exact case path match → "#caseName"
@@ -1185,31 +1204,63 @@ Object.assign(CodemanApp.prototype, {
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
const size =
s.sizeBytes < 1024
// Size: only render when a numeric byte count is present (unified items
// backed solely by a live/persisted source may omit it).
const hasSize = typeof s.sizeBytes === 'number';
const size = !hasSize
? ''
: s.sizeBytes < 1024
? `${s.sizeBytes}B`
: s.sizeBytes < 1048576
? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr =
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
// Timestamp: unified shape carries lastActivityAt (ms epoch); the older
// folder-modal/history shape carries an ISO lastModified string. Prefer ms,
// fall back to parsing the string, and omit entirely when neither is valid.
const tsMs =
typeof s.lastActivityAt === 'number'
? s.lastActivityAt
: s.lastModified
? Date.parse(s.lastModified)
: NaN;
let timeStr = '';
if (!Number.isNaN(tsMs)) {
const date = new Date(tsMs);
timeStr =
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
}
const shortDir = this._shortenHomePath(s.workingDir);
const caseLabel = this._resolveCaseLabel(s.workingDir, cases);
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.title = s.workingDir || '';
// Main row: clickable surface that triggers resume (or a caller-supplied
// activation — the Session Manager switches to live sessions instead)
// Main row: clickable surface. A caller-supplied onActivate wins (the
// Session Manager routes live rows to selectSession and history rows to
// resume). Otherwise the default focuses the live tab when the row is a
// still-running session, else resumes the conversation — keyed by the Claude
// conversation UUID (claudeSessionId) when present, since resumed sessions
// carry theirs separately from their Codeman id.
const mainRow = document.createElement('div');
mainRow.className = 'history-item-main';
mainRow.addEventListener(
'click',
options?.onActivate || (() => this.resumeHistorySession(s.sessionId, s.workingDir))
options?.onActivate ||
(() => {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
}
})
);
const textCol = document.createElement('div');
@@ -1217,14 +1268,32 @@ Object.assign(CodemanApp.prototype, {
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.firstPrompt || shortDir;
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
const modeBadge = document.createElement('span');
modeBadge.className = 'history-item-badge history-item-badge-mode';
modeBadge.textContent = s.mode;
badgeRow.appendChild(modeBadge);
}
if (isLive) {
const liveBadge = document.createElement('span');
liveBadge.className = 'history-item-badge history-item-badge-live';
liveBadge.textContent = 'LIVE';
badgeRow.appendChild(liveBadge);
}
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case');
subtitleSpan.textContent = caseLabel;
textCol.append(titleSpan, subtitleSpan);
textCol.append(titleSpan);
if (badgeRow.childElementCount > 0) textCol.append(badgeRow);
textCol.append(subtitleSpan);
const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta';
@@ -1233,7 +1302,11 @@ Object.assign(CodemanApp.prototype, {
const expandBtn = document.createElement('button');
expandBtn.className = 'history-item-expand';
expandBtn.type = 'button';
expandBtn.setAttribute('aria-label', 'Show details');
// COD-130: the ⋯ button now opens a context (kebab) menu rather than
// toggling the inline detail panel directly. aria-expanded still tracks
// the detail panel (toggled via the menu's "Show details" item).
expandBtn.setAttribute('aria-haspopup', 'menu');
expandBtn.setAttribute('aria-label', 'Session actions');
expandBtn.setAttribute('aria-expanded', 'false');
expandBtn.textContent = '⋯'; // ⋯
@@ -1266,7 +1339,11 @@ Object.assign(CodemanApp.prototype, {
const metaRow = document.createElement('div');
metaRow.className = 'history-detail-row history-detail-meta';
metaRow.textContent = `${timeStr} · ${size} · ${s.sessionId.slice(0, 8)}`;
const metaParts = [];
if (timeStr) metaParts.push(timeStr);
if (hasSize) metaParts.push(size);
metaParts.push(s.sessionId.slice(0, 8));
metaRow.textContent = metaParts.join(' · ');
detail.append(promptRow, pathRow, metaRow);
@@ -1286,16 +1363,179 @@ Object.assign(CodemanApp.prototype, {
}
expandBtn.addEventListener('click', (ev) => {
// COD-130: stop both the row resume handler and the Session Manager
// modal's main-row close listener from firing, then open the kebab menu.
ev.stopPropagation();
const expanded = item.classList.toggle('expanded');
detail.hidden = !expanded;
expandBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
ev.preventDefault();
this._openSessionRowMenu(ev.currentTarget, s, cases, item, detail);
});
item.append(mainRow, detail);
return item;
},
/**
* COD-130: Open a context (kebab) menu anchored to a history item's ⋯
* button. Replaces the old inline detail-toggle so the same control works
* both in the history list and inside the Session Manager modal (where a
* capture-phase close listener previously swallowed the click).
*
* The menu is appended to <body> with fixed positioning so it escapes the
* modal's overflow/stacking context, and flips above the anchor when it
* would overflow the viewport bottom.
*
* @param {HTMLElement} anchorEl the ⋯ button the menu anchors to
* @param {object} s session record
* @param {Array} cases linked cases (unused but kept for parity/future)
* @param {HTMLElement} item the .history-item element (for detail toggle)
* @param {HTMLElement} detail the inline detail panel element
*/
_openSessionRowMenu(anchorEl, s, cases, item, detail) {
// Close any already-open row menu first — call its own close fn so the
// previous menu's document/window listeners are detached (a raw .remove()
// would leave them dangling until the next event self-cleans).
if (this._openRowMenuClose) {
try {
this._openRowMenuClose();
} catch {
/* noop */
}
}
const isLiveOpen =
Array.isArray(s.sources) && s.sources.includes('live') && this.sessions.has(s.sessionId);
const menu = document.createElement('div');
menu.className = 'session-row-menu';
menu.setAttribute('role', 'menu');
// closeMenu tears down the menu and all transient listeners.
let onDocMouseDown = null;
let onKeyDown = null;
let onScrollResize = null;
const closeMenu = () => {
document.removeEventListener('mousedown', onDocMouseDown, true);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('scroll', onScrollResize, true);
window.removeEventListener('resize', onScrollResize, true);
try {
menu.remove();
} catch {
/* noop */
}
if (this._openRowMenuEl === menu) {
this._openRowMenuEl = null;
this._openRowMenuClose = null;
}
};
// Helper: build one menu item button.
const addItem = (label, onActivate, opts) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'session-row-menu-item';
btn.setAttribute('role', 'menuitem');
const text = document.createElement('span');
text.className = 'session-row-menu-label';
text.textContent = label;
btn.appendChild(text);
if (opts && opts.sublabel) {
const sub = document.createElement('span');
sub.className = 'session-row-menu-sublabel';
sub.textContent = opts.sublabel;
btn.appendChild(sub);
}
btn.addEventListener('click', async (ev) => {
// Never let the click bubble to the row resume / modal close handlers.
ev.stopPropagation();
ev.preventDefault();
await onActivate();
});
menu.appendChild(btn);
};
// Resume / Switch to session (always).
addItem(
isLiveOpen ? 'Switch to session' : 'Resume session',
() => {
if (isLiveOpen) {
this.selectSession(s.sessionId);
} 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.closeSessionManager?.();
closeMenu();
}
);
// Open folder (only for a live+open session — file browser is session-scoped).
if (isLiveOpen) {
addItem('Open folder', () => {
this.selectSession(s.sessionId);
this.loadFileBrowser?.(s.sessionId);
this.closeSessionManager?.();
closeMenu();
});
}
// Copy path (only when a workingDir is known).
if (s.workingDir) {
addItem('Copy path', async () => {
const ok = await this._copyText(s.workingDir);
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
closeMenu();
});
}
// Show details (always) — toggles the inline detail panel; keeps modal open.
addItem('Show details', () => {
const expanded = item.classList.toggle('expanded');
detail.hidden = !expanded;
anchorEl.setAttribute('aria-expanded', expanded ? 'true' : 'false');
closeMenu();
});
// Position: fixed, anchored under/over the button; flip up on overflow.
document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const gap = 4;
let top = rect.bottom + gap;
if (top + menuRect.height > window.innerHeight && rect.top - gap - menuRect.height >= 0) {
top = rect.top - gap - menuRect.height; // flip above the anchor
}
// Right-align the menu to the button, clamped into the viewport.
let left = rect.right - menuRect.width;
if (left < gap) left = gap;
if (left + menuRect.width > window.innerWidth - gap) {
left = Math.max(gap, window.innerWidth - gap - menuRect.width);
}
menu.style.top = `${Math.max(gap, top)}px`;
menu.style.left = `${left}px`;
// Dismissal listeners.
onDocMouseDown = (ev) => {
if (menu.contains(ev.target) || anchorEl.contains(ev.target)) return;
closeMenu();
};
onKeyDown = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
closeMenu();
}
};
onScrollResize = () => closeMenu();
document.addEventListener('mousedown', onDocMouseDown, true);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('scroll', onScrollResize, true);
window.addEventListener('resize', onScrollResize, true);
this._openRowMenuEl = menu;
this._openRowMenuClose = closeMenu;
},
/** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4,
@@ -1311,7 +1551,7 @@ Object.assign(CodemanApp.prototype, {
? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30),
this._fetchUnifiedSessions(60),
casesPromise,
]);
if (allSessions.length === 0) {
+1
View File
@@ -2286,6 +2286,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
projectKey: h.projectKey,
});
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log'];
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
@@ -147,6 +147,22 @@ describe('mergeUnifiedSessions', () => {
expect(merged).toHaveLength(1);
expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime());
});
it('surfaces projectKey from a history input onto the merged item', () => {
const merged = mergeUnifiedSessions({
history: [
{
sessionId: 'h',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
projectKey: '-repo-alpha',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].projectKey).toBe('-repo-alpha');
});
});
describe('filterAndPaginate', () => {