mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-130 session-row ⋯ becomes a context menu
The per-row ⋯ in the session list was a details toggle that did nothing in the Session Manager modal (swallowed by the modal's capture-phase close-on-click). Replace it with a real kebab context menu. - terminal-ui.js: ⋯ now opens _openSessionRowMenu() — a body-anchored popup (fixed-positioned, flips/clamps to viewport, z-index above the modal) with: Resume/Switch-to (live→select tab, closed→resume), Open folder in the file browser (live sessions only — the browser is session-scoped), Copy path (_copyText + toast, when workingDir present), and Show details (the old inline prompt/path panel). Closes on outside-click / Escape / scroll / resize. - panels-ui.js: _loadSessionManagerList scopes its modal-close to the .history-item-main (resume) click, so the ⋯/menu no longer closes the modal. - styles.css: .session-row-menu + .session-row-menu-item. Verified in Chromium on an isolated beta: ⋯ opens the menu with the modal still open; closed rows show Resume/Copy path/Show details, live rows add Switch-to + Open folder; Show details expands inline (modal stays open), Copy path copies the path, Resume closes the modal, Escape closes only the menu. Gates: tsc 0, lint 0, frontend-syntax + public-asset format clean.
This commit is contained in:
@@ -379,10 +379,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
for (const s of sessions) {
|
||||
const item = this._buildHistoryItem(s, this.cases, { showViewAll: false });
|
||||
// Close the modal when an item is clicked; the underlying
|
||||
// resume/select (registered on the item) still fires because this
|
||||
// is a capture-phase listener that does not stop propagation.
|
||||
item.addEventListener('click', () => this.closeSessionManager(), true);
|
||||
// COD-130: scope the modal-close to the main (resume) row only, in the
|
||||
// bubble phase. The ⋯ kebab button calls stopPropagation(), so clicking
|
||||
// it (or its menu) no longer closes the Session Manager modal.
|
||||
item.querySelector('.history-item-main')?.addEventListener('click', () => this.closeSessionManager());
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -5471,6 +5471,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;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1270,7 +1270,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 = '⋯'; // ⋯
|
||||
|
||||
@@ -1327,16 +1331,172 @@ 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.
|
||||
if (this._openRowMenuEl) {
|
||||
try {
|
||||
this._openRowMenuEl.remove();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
this._openRowMenuEl = null;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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 {
|
||||
this.resumeHistorySession(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;
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user