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; claudeSessionId?: string;
firstPrompt?: string; firstPrompt?: string;
sizeBytes?: number; sizeBytes?: number;
projectKey?: string;
remote?: boolean; remote?: boolean;
sources: string[]; sources: string[];
stats?: { memoryMB: number; cpuPercent: number }; stats?: { memoryMB: number; cpuPercent: number };
@@ -76,6 +77,7 @@ export type HistoryInput = {
sizeBytes: number; sizeBytes: number;
lastModified: string; lastModified: string;
firstPrompt?: string; firstPrompt?: string;
projectKey?: string;
}; };
/** Mux process-stat view. */ /** Mux process-stat view. */
@@ -147,6 +149,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', h.workingDir); overwrite(item, 'workingDir', h.workingDir);
overwrite(item, 'sizeBytes', h.sizeBytes); overwrite(item, 'sizeBytes', h.sizeBytes);
overwrite(item, 'firstPrompt', h.firstPrompt); overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'projectKey', h.projectKey);
const ms = Date.parse(h.lastModified); const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; 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) { for (const [event] of _SSE_HANDLER_MAP) {
addListener(event, this._sseHandlerWrappers.get(event)); 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-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-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-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"> <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> <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> <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; height: 12px;
} }
/* Hide header settings gear, lifecycle log, and away digest on mobile - settings /* Hide header settings gear, lifecycle log, away digest, and session manager on
moved to toolbar; away digest is a secondary informational control that doesn't mobile - settings moved to toolbar; away digest and the session manager are
belong on the cramped phone header. 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 (The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */ --hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings, .btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log, .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; display: none !important;
} }
+22
View File
@@ -671,6 +671,28 @@ Object.assign(CodemanApp.prototype, {
if (modal) modal.classList.remove('active'); 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) { setAwayDigestRange(range) {
this.awayDigestRange = range; this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false; this._awayDigestLoadedSuccessfully = false;
+71
View File
@@ -2831,6 +2831,32 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
font-weight: 500; 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 { .history-item-meta {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-muted); color: var(--text-muted);
@@ -5713,6 +5739,51 @@ kbd {
overflow-y: auto; 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 settings rows (App Settings → Shortcuts, COD-157) */
.shortcut-setting-row { .shortcut-setting-row {
display: grid; display: grid;
+259 -19
View File
@@ -1143,6 +1143,25 @@ Object.assign(CodemanApp.prototype, {
return items; 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. * Resolve workingDir to a case-aware short label.
* - Exact case path match → "#caseName" * - Exact case path match → "#caseName"
@@ -1185,31 +1204,63 @@ Object.assign(CodemanApp.prototype, {
*/ */
_buildHistoryItem(s, cases, options) { _buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false; 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}B`
: s.sizeBytes < 1048576 : s.sizeBytes < 1048576
? `${(s.sizeBytes / 1024).toFixed(0)}K` ? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`; : `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr = // Timestamp: unified shape carries lastActivityAt (ms epoch); the older
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + // folder-modal/history shape carries an ISO lastModified string. Prefer ms,
' ' + // fall back to parsing the string, and omit entirely when neither is valid.
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); 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 shortDir = this._shortenHomePath(s.workingDir);
const caseLabel = this._resolveCaseLabel(s.workingDir, cases); const caseLabel = this._resolveCaseLabel(s.workingDir, cases);
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'history-item'; item.className = 'history-item';
item.title = s.workingDir; item.title = s.workingDir || '';
// Main row: clickable surface that triggers resume (or a caller-supplied // Main row: clickable surface. A caller-supplied onActivate wins (the
// activation — the Session Manager switches to live sessions instead) // 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'); const mainRow = document.createElement('div');
mainRow.className = 'history-item-main'; mainRow.className = 'history-item-main';
mainRow.addEventListener( mainRow.addEventListener(
'click', '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'); const textCol = document.createElement('div');
@@ -1217,14 +1268,32 @@ 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.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'); const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle'; subtitleSpan.className = 'history-item-subtitle';
if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case'); if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case');
subtitleSpan.textContent = caseLabel; 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'); const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta'; metaSpan.className = 'history-item-meta';
@@ -1233,7 +1302,11 @@ Object.assign(CodemanApp.prototype, {
const expandBtn = document.createElement('button'); const expandBtn = document.createElement('button');
expandBtn.className = 'history-item-expand'; expandBtn.className = 'history-item-expand';
expandBtn.type = 'button'; 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.setAttribute('aria-expanded', 'false');
expandBtn.textContent = '⋯'; // ⋯ expandBtn.textContent = '⋯'; // ⋯
@@ -1266,7 +1339,11 @@ Object.assign(CodemanApp.prototype, {
const metaRow = document.createElement('div'); const metaRow = document.createElement('div');
metaRow.className = 'history-detail-row history-detail-meta'; 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); detail.append(promptRow, pathRow, metaRow);
@@ -1286,16 +1363,179 @@ Object.assign(CodemanApp.prototype, {
} }
expandBtn.addEventListener('click', (ev) => { 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(); ev.stopPropagation();
const expanded = item.classList.toggle('expanded'); ev.preventDefault();
detail.hidden = !expanded; this._openSessionRowMenu(ev.currentTarget, s, cases, item, detail);
expandBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}); });
item.append(mainRow, detail); item.append(mainRow, detail);
return item; 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" */ /** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4, _HISTORY_INITIAL_COUNT: 4,
@@ -1311,7 +1551,7 @@ Object.assign(CodemanApp.prototype, {
? Promise.resolve(this.cases) ? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []); : fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
const [allSessions, cases] = await Promise.all([ const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30), this._fetchUnifiedSessions(60),
casesPromise, casesPromise,
]); ]);
if (allSessions.length === 0) { if (allSessions.length === 0) {
+1
View File
@@ -2286,6 +2286,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes, sizeBytes: h.sizeBytes,
lastModified: h.lastModified, lastModified: h.lastModified,
firstPrompt: h.firstPrompt, 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 // 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 // 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. // 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 { function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`)); const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
@@ -147,6 +147,22 @@ describe('mergeUnifiedSessions', () => {
expect(merged).toHaveLength(1); expect(merged).toHaveLength(1);
expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime()); 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', () => { describe('filterAndPaginate', () => {