mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -2286,6 +2286,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
projectKey: h.projectKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user