feat(dashboard): sessions bulk select + delete (Gmail/GitHub pattern)

The sessions table previously only supported per-row deletion via the
"del" button — no way to clear many at once. Add an industry-standard
multi-select pattern.

Pattern follows Gmail / GitHub / Linear / Vercel:
- Leftmost cell on each row is a checkbox; data-id binds to session id.
- Header checkbox is tri-state (empty / indeterminate / all-on-page)
  driven by a single source of truth (selectedSessionIds: Set<string>).
- Shift-click on a row checkbox does range-select from the
  last-clicked anchor to the current row, applying the new checked
  state to every row in between (macOS Finder / Gmail convention).
- Contextual action bar slides in above the table when ≥1 row is
  selected, showing "N selected · Delete selected · Clear (Esc)".
- Confirm modal shows count + first 3 session id prefixes + "and N
  more" for sanity-check before destructive action.
- Esc key clears the selection (no-op when an input is focused).
- Selection is keyed on session id and reapplied after each diff
  render — innerHTML rewrites no longer clobber checked state.
- Vanished sessions auto-drop from selection.

Backend:
- PruneOptions gains sessionIds?: string[] alongside the existing
  single sessionId. Unknown ids are silently skipped (race-safe with
  concurrent prunes). Composes with sessionId if both are set.
- handlePrune plumbs sessionIds through; the bulk-delete action calls
  /api/sessions/prune once with all ids.
- 3 new selectSessionsToRemove tests: bulk select, unknown-id skip,
  sessionId+sessionIds composition.

Tests: 266 → 269 (+3). Typecheck + build clean.
This commit is contained in:
teamchong
2026-05-19 14:56:30 -04:00
parent 0a25aeb51d
commit 9c860f02dd
3 changed files with 223 additions and 2 deletions
+170 -2
View File
@@ -849,6 +849,7 @@ export class DashboardState {
olderThanDays: body.olderThanDays,
keepLast: body.keepLast,
sessionId: body.sessionId,
sessionIds: Array.isArray(body.sessionIds) ? body.sessionIds : undefined,
});
return jsonResponse(report);
}
@@ -1028,9 +1029,22 @@ const DASHBOARD_HTML = `<!doctype html>
<div class="panel" style="margin-bottom:22px">
<h2>sessions <span class="small" id="sess_count" style="color:#6e7681"></span></h2>
<div class="small" id="sess_status" style="margin-bottom:12px;color:#6e7681">loading...</div>
<!--
Bulk-action bar. Industry-standard pattern (Gmail / GitHub / Linear /
Vercel): hidden when selection is empty; slides in showing the count
and the destructive action when ≥1 row is checked. Replaces the
normal per-row controls so the bulk action is unmissable.
-->
<div id="sess_action_bar" style="display:none;margin-bottom:10px;padding:8px 12px;background:#1f2a37;border:1px solid #30363d;border-radius:6px;align-items:center;gap:12px">
<span class="small" id="sess_action_count" style="color:#c9d1d9">0 selected</span>
<button id="sess_action_delete" type="button" style="background:#21262d;color:#f85149;border:1px solid #30363d;padding:4px 12px;cursor:pointer">Delete selected</button>
<button id="sess_action_clear" type="button" style="background:transparent;color:#6e7681;border:1px solid #30363d;padding:4px 12px;cursor:pointer">Clear (Esc)</button>
<span class="small" style="color:#6e7681;margin-left:auto">Shift-click to range-select</span>
</div>
<table>
<thead>
<tr>
<th style="width:24px"><input type="checkbox" id="sess_select_all" title="Select all visible" /></th>
<th>session</th>
<th>project</th>
<th>claude code</th>
@@ -1205,6 +1219,66 @@ function shortPath(p) {
// order. This avoids the visible flash that an innerHTML wipe would cause.
const sessRowEls = new Map();
// ---- session selection state machine -------------------------------------
//
// Industry-standard multi-select-and-delete pattern (Gmail / GitHub /
// Linear): per-row checkbox + header 3-state checkbox + shift-click range
// + contextual action bar + Esc-clears. Selection survives diff-renders by
// being keyed on session id, not DOM nodes — when renderSessions rewrites a
// row's innerHTML we re-apply checked state from selectedSessionIds.
const selectedSessionIds = new Set();
// IDs in the order they appear in the most recent render. Needed for
// shift-click range selection (pick rows between two ids).
let sessionIdOrder = [];
// The last id the operator clicked or checked. Anchor for shift-click.
let lastClickedSessionId = null;
function updateSessActionBar() {
const bar = document.getElementById('sess_action_bar');
const countEl = document.getElementById('sess_action_count');
const n = selectedSessionIds.size;
if (n === 0) {
bar.style.display = 'none';
} else {
bar.style.display = 'flex';
countEl.textContent = n + ' selected';
}
// Header checkbox tri-state: empty / indeterminate / all.
const head = document.getElementById('sess_select_all');
if (head) {
const total = sessionIdOrder.length;
if (n === 0) { head.checked = false; head.indeterminate = false; }
else if (n >= total) { head.checked = true; head.indeterminate = false; }
else { head.checked = false; head.indeterminate = true; }
}
}
function setSessionSelected(id, on) {
if (on) selectedSessionIds.add(id);
else selectedSessionIds.delete(id);
const box = document.querySelector('input.sess_check[data-id="' + cssEscape(id) + '"]');
if (box) box.checked = on;
updateSessActionBar();
}
function cssEscape(s) {
// Sufficient for our use case (session ids are hex-ish + dashes).
return String(s).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
}
function reapplySessionSelectionState() {
// After a diff-render, re-attach checked state from the source-of-truth
// Set. Also drop selections for sessions that vanished from the table.
const seen = new Set(sessionIdOrder);
for (const id of [...selectedSessionIds]) {
if (!seen.has(id)) selectedSessionIds.delete(id);
}
document.querySelectorAll('input.sess_check').forEach((box) => {
box.checked = selectedSessionIds.has(box.dataset.id);
});
updateSessActionBar();
}
function sessRowHtml(s) {
const cc = s.claudeCode;
const ccLabel = cc
@@ -1213,7 +1287,11 @@ function sessRowHtml(s) {
: '<span style="color:#6e7681">-</span>';
const disk = fmtBytes((s.jsonlBytes||0) + (s.sidecarBytes||0));
const projShort = s.project ? escapeHtml(shortPath(s.project)) : '<span style="color:#6e7681">-</span>';
// The checkbox CHECKED state is restored from selectedSessionIds in
// renderSessions, NOT baked into this HTML. That way diff-renders
// don't clobber the operator's in-progress selection.
return ''
+ '<td><input type="checkbox" class="sess_check" data-id="' + escapeHtml(s.id) + '" /></td>'
+ '<td><a href="/sessions/' + encodeURIComponent(s.id) + '" style="color:#58a6ff">'
+ escapeHtml(s.id) + '</a></td>'
+ '<td>' + projShort + '</td>'
@@ -1260,6 +1338,10 @@ function renderSessions(payload) {
sessRowEls.delete(id);
}
}
// Selection bookkeeping: capture display order for shift-click range, and
// re-apply checked state on every render (innerHTML rewrites lose it).
sessionIdOrder = rows.map((s) => s.id);
reapplySessionSelectionState();
}
// ---- stats table ---------------------------------------------------------
@@ -1381,12 +1463,98 @@ document.getElementById('prune_btn').addEventListener('click', () => {
document.getElementById('prune_result').textContent = 'error: ' + e.message;
});
});
// One delegated listener handles every row's del button. Survives diff renders.
// One delegated listener handles every row's del button + per-row checkbox.
// Survives diff renders.
document.getElementById('sess_rows').addEventListener('click', (ev) => {
const t = ev.target;
if (t && t.dataset && t.dataset.del) deleteSession(t.dataset.del);
if (!t) return;
// Per-row delete button.
if (t.dataset && t.dataset.del) { deleteSession(t.dataset.del); return; }
// Per-row checkbox toggle. Shift-click does range select between this row
// and the last-clicked anchor (Gmail / GitHub convention).
if (t.classList && t.classList.contains('sess_check')) {
const id = t.dataset.id;
if (ev.shiftKey && lastClickedSessionId && lastClickedSessionId !== id) {
const a = sessionIdOrder.indexOf(lastClickedSessionId);
const b = sessionIdOrder.indexOf(id);
if (a >= 0 && b >= 0) {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
// Range select propagates the clicked checkbox's new state to all
// rows in [lo, hi]. Matches macOS Finder / Gmail behavior.
const on = t.checked;
for (let i = lo; i <= hi; i++) setSessionSelected(sessionIdOrder[i], on);
}
} else {
setSessionSelected(id, t.checked);
}
lastClickedSessionId = id;
}
});
// Header checkbox: select-all / clear-all on visible rows. Tri-state is
// driven from updateSessActionBar(); the operator's click always either
// selects all (when 0 or some) or clears all (when all selected).
document.getElementById('sess_select_all').addEventListener('click', (ev) => {
const target = ev.currentTarget;
const turnOn = !!target.checked;
for (const id of sessionIdOrder) {
if (turnOn) selectedSessionIds.add(id);
else selectedSessionIds.delete(id);
}
document.querySelectorAll('input.sess_check').forEach((box) => {
box.checked = turnOn;
});
updateSessActionBar();
});
// Action bar: bulk delete + clear-selection.
document.getElementById('sess_action_delete').addEventListener('click', () => {
bulkDeleteSelectedSessions().catch((e) => {
document.getElementById('prune_result').textContent = 'error: ' + e.message;
});
});
document.getElementById('sess_action_clear').addEventListener('click', () => {
selectedSessionIds.clear();
reapplySessionSelectionState();
});
// Esc clears the selection — fast escape hatch when the operator changed
// their mind. Only fires when no input/textarea has focus so it doesn't
// trample text input.
document.addEventListener('keydown', (ev) => {
if (ev.key !== 'Escape') return;
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (selectedSessionIds.size === 0) return;
selectedSessionIds.clear();
reapplySessionSelectionState();
});
async function bulkDeleteSelectedSessions() {
const ids = [...selectedSessionIds];
if (ids.length === 0) return;
// Confirm modal: count + a sample of session ids so the operator can
// sanity-check what they're about to delete. Show first 3, "and N more".
const sample = ids.slice(0, 3).map((id) => id.slice(0, 12)).join(', ');
const more = ids.length > 3 ? ' and ' + (ids.length - 3) + ' more' : '';
if (!window.confirm(
'Delete ' + ids.length + ' session' + (ids.length === 1 ? '' : 's') + '?\\n\\n'
+ sample + more + '\\n\\nThis cannot be undone.'
)) return;
const r = await fetch('/api/sessions/prune', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionIds: ids, force: true }),
}).then(r => r.json());
document.getElementById('prune_result').textContent =
'removed ' + numFmt(r.sessionsRemoved.length) + ' sessions, '
+ numFmt(r.eventsRemoved) + ' events, '
+ fmtBytes(r.jsonlBytesFreed + r.sidecarBytesFreed);
selectedSessionIds.clear();
tickSlow();
}
// ---- slow tick (5s) - sessions / stats / disk ----------------------------
async function tickSlow() {
+13
View File
@@ -279,6 +279,11 @@ export interface PruneOptions {
keepLast?: number;
/** Drop a single session by ID. */
sessionId?: string;
/** Drop multiple sessions in one atomic pass bulk-delete from the
* dashboard's checkbox UI. Unknown IDs are silently ignored (the
* caller may have raced a concurrent prune). Coexists with
* `sessionId` (single) both contribute to the removal set. */
sessionIds?: string[];
/** When true, actually delete. When false (the default), report only. */
force: boolean;
}
@@ -308,6 +313,14 @@ export function selectSessionsToRemove(
if (sessions.has(opts.sessionId)) toRemove.add(opts.sessionId);
}
if (Array.isArray(opts.sessionIds)) {
for (const id of opts.sessionIds) {
// Silently skip unknown IDs — the client may have raced a concurrent
// prune. The report will reflect what we actually removed.
if (typeof id === 'string' && sessions.has(id)) toRemove.add(id);
}
}
if (typeof opts.olderThanDays === 'number') {
const cutoff = new Date(
now.getTime() - opts.olderThanDays * 24 * 60 * 60 * 1000,
+40
View File
@@ -279,6 +279,46 @@ describe('selectSessionsToRemove', () => {
});
expect(removed.size).toBe(0);
});
it('selects multiple via sessionIds[] (bulk-delete UI)', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
ev({ first_user_sha8: 'cccccccc' }),
ev({ first_user_sha8: 'dddddddd' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionIds: ['aaaaaaaa', 'cccccccc'],
force: false,
});
expect([...removed].sort()).toEqual(['aaaaaaaa', 'cccccccc']);
});
it('sessionIds[] silently skips unknown ids (race-safe)', async () => {
writeEvents(tmp, [ev({ first_user_sha8: 'aaaaaaaa' })]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionIds: ['aaaaaaaa', 'doesnotexist', 'alsogone'],
force: false,
});
expect([...removed]).toEqual(['aaaaaaaa']);
});
it('sessionIds[] and sessionId compose (single + bulk in one call)', async () => {
writeEvents(tmp, [
ev({ first_user_sha8: 'aaaaaaaa' }),
ev({ first_user_sha8: 'bbbbbbbb' }),
ev({ first_user_sha8: 'cccccccc' }),
]);
const { sessions } = await aggregateSessions(tmp);
const removed = selectSessionsToRemove(sessions, {
sessionId: 'aaaaaaaa',
sessionIds: ['bbbbbbbb'],
force: false,
});
expect([...removed].sort()).toEqual(['aaaaaaaa', 'bbbbbbbb']);
});
});
// ---- prune (dry-run + apply) ----------------------------------------------