From 9c860f02ddd43186572b030d1f2739400900543f Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Tue, 19 May 2026 14:56:30 -0400 Subject: [PATCH] feat(dashboard): sessions bulk select + delete (Gmail/GitHub pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). - 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. --- src/dashboard.ts | 172 ++++++++++++++++++++++++++++++++++++++++- src/sessions.ts | 13 ++++ tests/sessions.test.ts | 40 ++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/src/dashboard.ts b/src/dashboard.ts index b9126dd..b130332 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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 = `

sessions

loading...
+ + + @@ -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) { : '-'; const disk = fmtBytes((s.jsonlBytes||0) + (s.sidecarBytes||0)); const projShort = s.project ? escapeHtml(shortPath(s.project)) : '-'; + // 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 '' + + '' + '' + '' @@ -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() { diff --git a/src/sessions.ts b/src/sessions.ts index 9bc95ab..4d2a48e 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -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, diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts index 5b9adbe..d54ecb6 100644 --- a/tests/sessions.test.ts +++ b/tests/sessions.test.ts @@ -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) ----------------------------------------------
session project claude code' + escapeHtml(s.id) + '' + projShort + '