jupyterlab_claude_code_extension 1.2.23 → 1.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
31
31
  - **Favorites** - star projects you keep coming back to via the right-click menu
32
32
  - **Remove** - drop a project's Claude history from the panel via the right-click menu; a confirmation dialog names the project and warns it removes the whole project and all its conversations before anything is touched, then the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
33
33
  - **Clean up parallel sessions** - when a project has accumulated extra sessions beyond the main one, a right-click menu item (showing the count in brackets) removes them all, keeping only the main session; a confirmation dialog naming the project and the count appears first, and removed files honour the same trash setting
34
- - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup over the full list where conversations can also be deleted - select one, many, or all via checkboxes, then confirm in a dialog that names the project and the count (removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
34
+ - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup - a scrollable table over the full list with the current conversation pinned at the top and accented. Conversations can also be deleted here - select one, many, or all via checkboxes, then click Delete; they move to trash immediately (no confirmation, with an "N moved to trash" message; removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
35
35
  - **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
36
36
  - **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
37
37
  - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
package/lib/widget.js CHANGED
@@ -1292,6 +1292,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1292
1292
  // Local working copy so deletions can refresh the list in place.
1293
1293
  let items = [...branches];
1294
1294
  const selected = new Set();
1295
+ let deleting = false; // guards the async delete against double-invocation
1295
1296
  const body = document.createElement('div');
1296
1297
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1297
1298
  const search = document.createElement('input');
@@ -1299,25 +1300,49 @@ export class ClaudeCodeSessionsWidget extends Widget {
1299
1300
  search.placeholder = 'Filter sessions...';
1300
1301
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1301
1302
  body.appendChild(search);
1303
+ // Table header strip: select-all on the left, conversation count right.
1304
+ const header = document.createElement('div');
1305
+ header.className = 'jp-ClaudeSessionsPanel-branchHeader';
1302
1306
  const selectAllBar = document.createElement('label');
1303
1307
  selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1304
1308
  const selectAll = document.createElement('input');
1305
1309
  selectAll.type = 'checkbox';
1306
1310
  selectAllBar.appendChild(selectAll);
1307
1311
  selectAllBar.appendChild(document.createTextNode('Select all'));
1308
- body.appendChild(selectAllBar);
1312
+ header.appendChild(selectAllBar);
1313
+ const countEl = document.createElement('span');
1314
+ countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
1315
+ header.appendChild(countEl);
1316
+ body.appendChild(header);
1309
1317
  const list = document.createElement('div');
1310
1318
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1319
+ // role=group makes the aria-label apply to the conversation list region.
1320
+ list.setAttribute('role', 'group');
1321
+ list.setAttribute('aria-label', 'Conversations');
1311
1322
  body.appendChild(list);
1312
1323
  const footer = document.createElement('div');
1313
1324
  footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1325
+ // Plain visual counter (selection or last action). The screen-reader
1326
+ // announcement lives in a separate live region (srLive) so per-checkbox
1327
+ // ticks are not announced on top of the native checkbox.
1328
+ const selCount = document.createElement('span');
1329
+ selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
1330
+ footer.appendChild(selCount);
1314
1331
  const deleteBtn = document.createElement('button');
1332
+ deleteBtn.type = 'button';
1315
1333
  deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1334
+ deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
1316
1335
  footer.appendChild(deleteBtn);
1317
1336
  body.appendChild(footer);
1337
+ // Visually-hidden polite live region, announced only on delete.
1338
+ const srLive = document.createElement('div');
1339
+ srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
1340
+ srLive.setAttribute('role', 'status');
1341
+ srLive.setAttribute('aria-live', 'polite');
1342
+ body.appendChild(srLive);
1318
1343
  const bodyWidget = new Widget({ node: body });
1319
1344
  const dialog = new Dialog({
1320
- title: 'Switch and Manage Sessions',
1345
+ title: 'Manage Sessions',
1321
1346
  body: bodyWidget,
1322
1347
  buttons: [Dialog.cancelButton()]
1323
1348
  });
@@ -1328,9 +1353,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1328
1353
  b.session_id.toLowerCase().includes(needle));
1329
1354
  };
1330
1355
  const updateControls = () => {
1331
- deleteBtn.disabled = selected.size === 0;
1332
- deleteBtn.textContent = `Delete (${selected.size})`;
1356
+ deleteBtn.disabled = deleting || selected.size === 0;
1357
+ deleteBtn.textContent = deleting
1358
+ ? 'Deleting...'
1359
+ : `Delete (${selected.size})`;
1360
+ selCount.textContent = selected.size ? `${selected.size} selected` : '';
1361
+ selCount.classList.remove('jp-mod-deleted');
1333
1362
  const visible = visibleMatches();
1363
+ const total = items.length + 1; // + the pinned current conversation
1364
+ const shown = visible.length + 1; // the current row is always shown
1365
+ countEl.textContent = search.value.trim()
1366
+ ? `${shown} of ${total}`
1367
+ : `${total} conversation${total === 1 ? '' : 's'}`;
1334
1368
  const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
1335
1369
  selectAll.checked =
1336
1370
  visible.length > 0 && visibleSelected === visible.length;
@@ -1344,6 +1378,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1344
1378
  const currentRow = document.createElement('div');
1345
1379
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1346
1380
  currentRow.title = `Session id: ${current}`;
1381
+ // Empty select cell keeps the name column aligned with branch rows.
1382
+ const currentSelect = document.createElement('span');
1383
+ currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1384
+ currentRow.appendChild(currentSelect);
1347
1385
  const currentLabel = document.createElement('span');
1348
1386
  currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1349
1387
  const currentName = this._activeSession
@@ -1371,12 +1409,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1371
1409
  const row = document.createElement('div');
1372
1410
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1373
1411
  row.title = `Session id: ${b.session_id}`;
1412
+ const selectCell = document.createElement('span');
1413
+ selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1374
1414
  const check = document.createElement('input');
1375
1415
  check.type = 'checkbox';
1376
1416
  check.checked = selected.has(b.session_id);
1417
+ check.setAttribute('aria-label', `Select ${this._branchDisplayName(b)}`);
1377
1418
  // The checkbox is its own click zone - ticking must not switch.
1378
1419
  check.addEventListener('click', e => {
1379
1420
  e.stopPropagation();
1421
+ if (deleting) {
1422
+ // Keyboard Space isn't blocked by the busy scrim's pointer-events;
1423
+ // re-sync the native toggle on the next render.
1424
+ check.checked = selected.has(b.session_id);
1425
+ return;
1426
+ }
1380
1427
  if (check.checked) {
1381
1428
  selected.add(b.session_id);
1382
1429
  }
@@ -1385,7 +1432,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
1385
1432
  }
1386
1433
  updateControls();
1387
1434
  });
1388
- row.appendChild(check);
1435
+ selectCell.appendChild(check);
1436
+ // The whole cell is a select target (>=24px), not just the checkbox;
1437
+ // clicking the padding toggles via the checkbox's own handler.
1438
+ selectCell.addEventListener('click', e => {
1439
+ if (e.target !== check) {
1440
+ e.stopPropagation();
1441
+ check.click();
1442
+ }
1443
+ });
1444
+ row.appendChild(selectCell);
1389
1445
  const label = document.createElement('span');
1390
1446
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1391
1447
  label.textContent = this._branchDisplayName(b);
@@ -1416,6 +1472,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1416
1472
  }
1417
1473
  };
1418
1474
  selectAll.addEventListener('change', () => {
1475
+ if (deleting) {
1476
+ return;
1477
+ }
1419
1478
  // Select-all acts on the visible (filtered) rows only.
1420
1479
  const visible = visibleMatches();
1421
1480
  if (selectAll.checked) {
@@ -1427,33 +1486,100 @@ export class ClaudeCodeSessionsWidget extends Widget {
1427
1486
  render();
1428
1487
  updateControls();
1429
1488
  });
1489
+ // Busy-lock the whole body (button + list) during the async delete so a
1490
+ // slow backend cannot be double-clicked into deleting the same set twice
1491
+ // and a mid-flight selection cannot be silently discarded.
1492
+ const setDeleting = (on) => {
1493
+ deleting = on;
1494
+ // Scrim the whole popup body (search, select-all, list) so nothing can
1495
+ // be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
1496
+ // sits outside the body and stays usable. aria-busy goes on the live
1497
+ // list region, not the (disabled, unannounced) button.
1498
+ body.classList.toggle('jp-mod-busy', on);
1499
+ if (on) {
1500
+ list.setAttribute('aria-busy', 'true');
1501
+ }
1502
+ else {
1503
+ list.removeAttribute('aria-busy');
1504
+ }
1505
+ };
1430
1506
  deleteBtn.addEventListener('click', () => {
1431
- if (selected.size === 0) {
1507
+ if (deleting || selected.size === 0) {
1432
1508
  return;
1433
1509
  }
1434
- const n = selected.size;
1435
- void showDialog({
1436
- title: 'Delete Sessions',
1437
- body: `Delete ${n} session${n === 1 ? '' : 's'} from "${this._activeSession ? this._lookupName(this._activeSession) : ''}"? ` +
1438
- 'They are moved to trash. This cannot be undone.',
1439
- buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })]
1440
- }).then(confirm => {
1441
- if (!confirm.button.accept) {
1510
+ // No confirmation here: deletions move to trash (recoverable), and a
1511
+ // second Lumino dialog stacked on this popup renders detached. The only
1512
+ // confirmed destructive actions are Clean Up Parallel Sessions and
1513
+ // Remove from Claude (whole-project / bulk). Feedback is given instead
1514
+ // of a prompt: a live "N moved to trash" status (a failure still toasts
1515
+ // via _deleteBranches).
1516
+ setDeleting(true);
1517
+ updateControls();
1518
+ void this._deleteBranches([...selected])
1519
+ .then(async (deleted) => {
1520
+ if (deleted === null) {
1521
+ setDeleting(false);
1522
+ updateControls(); // re-enable; selection unchanged
1442
1523
  return;
1443
1524
  }
1444
- void this._deleteBranches([...selected]).then(deleted => {
1445
- if (deleted === null) {
1446
- return;
1525
+ // Re-sync from disk truth, not an optimistic splice: a branch the
1526
+ // backend skipped (it became the resolved-current, or was already
1527
+ // gone) must not vanish from the list while it still exists.
1528
+ const session = this._activeSession;
1529
+ try {
1530
+ if (!session) {
1531
+ throw new Error('no active session');
1447
1532
  }
1533
+ const fresh = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1534
+ items = fresh.branches;
1535
+ }
1536
+ catch (_a) {
1537
+ // Refetch failed (rare): optimistic removal. The announced count
1538
+ // may then differ from rows removed if the backend skipped one;
1539
+ // the panel's own _fetch (in _deleteBranches) reconciles on disk.
1448
1540
  items = items.filter(b => !selected.has(b.session_id));
1449
- selected.clear();
1450
- this._lastBranches = items;
1451
- render();
1541
+ }
1542
+ // The user may have closed the popup during the refetch await - don't
1543
+ // touch shared state or a detached DOM in that case.
1544
+ if (!bodyWidget.isAttached) {
1545
+ return;
1546
+ }
1547
+ setDeleting(false);
1548
+ selected.clear();
1549
+ this._lastBranches = items;
1550
+ render();
1551
+ updateControls();
1552
+ // Feedback (no prompt): a perceivable counter + a polite SR
1553
+ // announcement of the actual number the backend moved to trash.
1554
+ const moved = `${deleted} moved to trash`;
1555
+ selCount.textContent = moved;
1556
+ selCount.classList.add('jp-mod-deleted');
1557
+ // Clear then re-set on the next tick so an identical count (two
1558
+ // single deletes in a row) is still re-announced by aria-live.
1559
+ srLive.textContent = '';
1560
+ window.setTimeout(() => {
1561
+ srLive.textContent = moved;
1562
+ }, 60);
1563
+ // Keep focus inside the dialog (render() destroyed the focused row).
1564
+ // Focus the select-all checkbox (a real control with a reliable
1565
+ // keyboard ring), unless the user is still typing in the search box.
1566
+ if (document.activeElement !== search) {
1567
+ selectAll.focus();
1568
+ }
1569
+ })
1570
+ .catch(() => {
1571
+ // Defensive: never leave the button stuck disabled if the chain
1572
+ // rejects unexpectedly.
1573
+ if (bodyWidget.isAttached) {
1574
+ setDeleting(false);
1452
1575
  updateControls();
1453
- });
1576
+ }
1454
1577
  });
1455
1578
  });
1456
1579
  search.addEventListener('input', () => {
1580
+ if (deleting) {
1581
+ return;
1582
+ }
1457
1583
  render();
1458
1584
  updateControls();
1459
1585
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.23",
3
+ "version": "1.2.25",
4
4
  "description": "Browse, resume, and manage your Claude Code CLI sessions from a JupyterLab side panel. One click reactivates the right terminal - no duplicate tabs, live remote-control indicator, and favourites for the projects you keep coming back to.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -240,15 +240,56 @@ describe('launch spinner dismiss contract', () => {
240
240
  );
241
241
  });
242
242
 
243
- it('delete opens a confirmation dialog and only deletes on accept', () => {
243
+ it('delete removes selected sessions immediately, no confirmation dialog', () => {
244
244
  const popup = (widgetSrc.match(
245
245
  /private _showBranchPopup[\s\S]*?\n \}/
246
246
  ) ?? [''])[0];
247
- expect(popup).toMatch(/showDialog\(\{\s*title: 'Delete Sessions'/);
248
- expect(popup).toMatch(/Dialog\.warnButton\(\{ label: 'Delete' \}\)/);
249
- expect(popup).toMatch(
250
- /if \(!confirm\.button\.accept\) \{\s*return;[\s\S]*?_deleteBranches/
247
+ // The per-row delete must NOT stack a second Lumino dialog on the popup
248
+ // (it renders detached) - confirmation is reserved for cleanup / remove.
249
+ expect(popup).not.toMatch(/title: 'Delete Sessions'/);
250
+ const delHandler = (popup.match(
251
+ /deleteBtn\.addEventListener\('click'[\s\S]*?\n \}\);/
252
+ ) ?? [''])[0];
253
+ expect(delHandler).toMatch(/this\._deleteBranches\(\[\.\.\.selected\]\)/);
254
+ expect(delHandler).not.toMatch(/showDialog/);
255
+ });
256
+
257
+ it('manage sessions popup is a table with an accented pinned current row', () => {
258
+ const popup = (widgetSrc.match(
259
+ /private _showBranchPopup[\s\S]*?\n \}/
260
+ ) ?? [''])[0];
261
+ expect(popup).toMatch(/title: 'Manage Sessions'/);
262
+ expect(popup).toMatch(/branchHeader/);
263
+ expect(popup).toMatch(/branchHeaderCount/);
264
+ expect(popup).toMatch(/branchSelectCell/);
265
+ const css: string = fs.readFileSync(
266
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
267
+ 'utf-8'
251
268
  );
269
+ const cur = (css.match(
270
+ /\.jp-ClaudeSessionsPanel-branchRow\.jp-mod-current \{[\s\S]*?\}/
271
+ ) ?? [''])[0];
272
+ expect(cur).toMatch(/position: sticky/);
273
+ expect(cur).toMatch(/border-left: 3px solid var\(--jp-brand-color1\)/);
274
+ const del = (css.match(
275
+ /\.jp-ClaudeSessionsPanel-branchDelete \{[\s\S]*?\}/
276
+ ) ?? [''])[0];
277
+ expect(del).toMatch(/--jp-error-color1/);
278
+ });
279
+
280
+ it('popup delete is accessible and gives feedback without a prompt', () => {
281
+ const popup = (widgetSrc.match(
282
+ /private _showBranchPopup[\s\S]*?\n \}/
283
+ ) ?? [''])[0];
284
+ // per-checkbox accessible name + polite live region for the result
285
+ expect(popup).toMatch(/`Select \$\{this\._branchDisplayName/);
286
+ expect(popup).toMatch(/aria-live', 'polite'/);
287
+ // the delete handler announces "N moved to trash" and keeps focus
288
+ const delHandler = (popup.match(
289
+ /deleteBtn\.addEventListener\('click'[\s\S]*?\n \}\);/
290
+ ) ?? [''])[0];
291
+ expect(delHandler).toMatch(/moved to trash/);
292
+ expect(delHandler).toMatch(/selectAll\.focus\(\)/);
252
293
  });
253
294
 
254
295
  it('delete posts to delete-branches and resyncs the panel', () => {
package/src/widget.ts CHANGED
@@ -1496,6 +1496,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1496
1496
  // Local working copy so deletions can refresh the list in place.
1497
1497
  let items = [...branches];
1498
1498
  const selected = new Set<string>();
1499
+ let deleting = false; // guards the async delete against double-invocation
1499
1500
 
1500
1501
  const body = document.createElement('div');
1501
1502
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
@@ -1506,28 +1507,53 @@ export class ClaudeCodeSessionsWidget extends Widget {
1506
1507
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1507
1508
  body.appendChild(search);
1508
1509
 
1510
+ // Table header strip: select-all on the left, conversation count right.
1511
+ const header = document.createElement('div');
1512
+ header.className = 'jp-ClaudeSessionsPanel-branchHeader';
1509
1513
  const selectAllBar = document.createElement('label');
1510
1514
  selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1511
1515
  const selectAll = document.createElement('input');
1512
1516
  selectAll.type = 'checkbox';
1513
1517
  selectAllBar.appendChild(selectAll);
1514
1518
  selectAllBar.appendChild(document.createTextNode('Select all'));
1515
- body.appendChild(selectAllBar);
1519
+ header.appendChild(selectAllBar);
1520
+ const countEl = document.createElement('span');
1521
+ countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
1522
+ header.appendChild(countEl);
1523
+ body.appendChild(header);
1516
1524
 
1517
1525
  const list = document.createElement('div');
1518
1526
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1527
+ // role=group makes the aria-label apply to the conversation list region.
1528
+ list.setAttribute('role', 'group');
1529
+ list.setAttribute('aria-label', 'Conversations');
1519
1530
  body.appendChild(list);
1520
1531
 
1521
1532
  const footer = document.createElement('div');
1522
1533
  footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1534
+ // Plain visual counter (selection or last action). The screen-reader
1535
+ // announcement lives in a separate live region (srLive) so per-checkbox
1536
+ // ticks are not announced on top of the native checkbox.
1537
+ const selCount = document.createElement('span');
1538
+ selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
1539
+ footer.appendChild(selCount);
1523
1540
  const deleteBtn = document.createElement('button');
1541
+ deleteBtn.type = 'button';
1524
1542
  deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1543
+ deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
1525
1544
  footer.appendChild(deleteBtn);
1526
1545
  body.appendChild(footer);
1527
1546
 
1547
+ // Visually-hidden polite live region, announced only on delete.
1548
+ const srLive = document.createElement('div');
1549
+ srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
1550
+ srLive.setAttribute('role', 'status');
1551
+ srLive.setAttribute('aria-live', 'polite');
1552
+ body.appendChild(srLive);
1553
+
1528
1554
  const bodyWidget = new Widget({ node: body });
1529
1555
  const dialog = new Dialog({
1530
- title: 'Switch and Manage Sessions',
1556
+ title: 'Manage Sessions',
1531
1557
  body: bodyWidget,
1532
1558
  buttons: [Dialog.cancelButton()]
1533
1559
  });
@@ -1543,9 +1569,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1543
1569
  };
1544
1570
 
1545
1571
  const updateControls = () => {
1546
- deleteBtn.disabled = selected.size === 0;
1547
- deleteBtn.textContent = `Delete (${selected.size})`;
1572
+ deleteBtn.disabled = deleting || selected.size === 0;
1573
+ deleteBtn.textContent = deleting
1574
+ ? 'Deleting...'
1575
+ : `Delete (${selected.size})`;
1576
+ selCount.textContent = selected.size ? `${selected.size} selected` : '';
1577
+ selCount.classList.remove('jp-mod-deleted');
1548
1578
  const visible = visibleMatches();
1579
+ const total = items.length + 1; // + the pinned current conversation
1580
+ const shown = visible.length + 1; // the current row is always shown
1581
+ countEl.textContent = search.value.trim()
1582
+ ? `${shown} of ${total}`
1583
+ : `${total} conversation${total === 1 ? '' : 's'}`;
1549
1584
  const visibleSelected = visible.filter(b =>
1550
1585
  selected.has(b.session_id)
1551
1586
  ).length;
@@ -1563,6 +1598,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1563
1598
  const currentRow = document.createElement('div');
1564
1599
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1565
1600
  currentRow.title = `Session id: ${current}`;
1601
+ // Empty select cell keeps the name column aligned with branch rows.
1602
+ const currentSelect = document.createElement('span');
1603
+ currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1604
+ currentRow.appendChild(currentSelect);
1566
1605
  const currentLabel = document.createElement('span');
1567
1606
  currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1568
1607
  const currentName = this._activeSession
@@ -1592,12 +1631,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1592
1631
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1593
1632
  row.title = `Session id: ${b.session_id}`;
1594
1633
 
1634
+ const selectCell = document.createElement('span');
1635
+ selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1595
1636
  const check = document.createElement('input');
1596
1637
  check.type = 'checkbox';
1597
1638
  check.checked = selected.has(b.session_id);
1639
+ check.setAttribute(
1640
+ 'aria-label',
1641
+ `Select ${this._branchDisplayName(b)}`
1642
+ );
1598
1643
  // The checkbox is its own click zone - ticking must not switch.
1599
1644
  check.addEventListener('click', e => {
1600
1645
  e.stopPropagation();
1646
+ if (deleting) {
1647
+ // Keyboard Space isn't blocked by the busy scrim's pointer-events;
1648
+ // re-sync the native toggle on the next render.
1649
+ check.checked = selected.has(b.session_id);
1650
+ return;
1651
+ }
1601
1652
  if (check.checked) {
1602
1653
  selected.add(b.session_id);
1603
1654
  } else {
@@ -1605,7 +1656,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
1605
1656
  }
1606
1657
  updateControls();
1607
1658
  });
1608
- row.appendChild(check);
1659
+ selectCell.appendChild(check);
1660
+ // The whole cell is a select target (>=24px), not just the checkbox;
1661
+ // clicking the padding toggles via the checkbox's own handler.
1662
+ selectCell.addEventListener('click', e => {
1663
+ if (e.target !== check) {
1664
+ e.stopPropagation();
1665
+ check.click();
1666
+ }
1667
+ });
1668
+ row.appendChild(selectCell);
1609
1669
 
1610
1670
  const label = document.createElement('span');
1611
1671
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
@@ -1640,6 +1700,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1640
1700
  };
1641
1701
 
1642
1702
  selectAll.addEventListener('change', () => {
1703
+ if (deleting) {
1704
+ return;
1705
+ }
1643
1706
  // Select-all acts on the visible (filtered) rows only.
1644
1707
  const visible = visibleMatches();
1645
1708
  if (selectAll.checked) {
@@ -1651,35 +1714,104 @@ export class ClaudeCodeSessionsWidget extends Widget {
1651
1714
  updateControls();
1652
1715
  });
1653
1716
 
1717
+ // Busy-lock the whole body (button + list) during the async delete so a
1718
+ // slow backend cannot be double-clicked into deleting the same set twice
1719
+ // and a mid-flight selection cannot be silently discarded.
1720
+ const setDeleting = (on: boolean) => {
1721
+ deleting = on;
1722
+ // Scrim the whole popup body (search, select-all, list) so nothing can
1723
+ // be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
1724
+ // sits outside the body and stays usable. aria-busy goes on the live
1725
+ // list region, not the (disabled, unannounced) button.
1726
+ body.classList.toggle('jp-mod-busy', on);
1727
+ if (on) {
1728
+ list.setAttribute('aria-busy', 'true');
1729
+ } else {
1730
+ list.removeAttribute('aria-busy');
1731
+ }
1732
+ };
1733
+
1654
1734
  deleteBtn.addEventListener('click', () => {
1655
- if (selected.size === 0) {
1735
+ if (deleting || selected.size === 0) {
1656
1736
  return;
1657
1737
  }
1658
- const n = selected.size;
1659
- void showDialog({
1660
- title: 'Delete Sessions',
1661
- body:
1662
- `Delete ${n} session${n === 1 ? '' : 's'} from "${this._activeSession ? this._lookupName(this._activeSession) : ''}"? ` +
1663
- 'They are moved to trash. This cannot be undone.',
1664
- buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })]
1665
- }).then(confirm => {
1666
- if (!confirm.button.accept) {
1667
- return;
1668
- }
1669
- void this._deleteBranches([...selected]).then(deleted => {
1738
+ // No confirmation here: deletions move to trash (recoverable), and a
1739
+ // second Lumino dialog stacked on this popup renders detached. The only
1740
+ // confirmed destructive actions are Clean Up Parallel Sessions and
1741
+ // Remove from Claude (whole-project / bulk). Feedback is given instead
1742
+ // of a prompt: a live "N moved to trash" status (a failure still toasts
1743
+ // via _deleteBranches).
1744
+ setDeleting(true);
1745
+ updateControls();
1746
+ void this._deleteBranches([...selected])
1747
+ .then(async deleted => {
1670
1748
  if (deleted === null) {
1749
+ setDeleting(false);
1750
+ updateControls(); // re-enable; selection unchanged
1751
+ return;
1752
+ }
1753
+ // Re-sync from disk truth, not an optimistic splice: a branch the
1754
+ // backend skipped (it became the resolved-current, or was already
1755
+ // gone) must not vanish from the list while it still exists.
1756
+ const session = this._activeSession;
1757
+ try {
1758
+ if (!session) {
1759
+ throw new Error('no active session');
1760
+ }
1761
+ const fresh = await requestAPI<IBranchesResponse>(
1762
+ `sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
1763
+ this._serverSettings,
1764
+ { cache: 'no-store' }
1765
+ );
1766
+ items = fresh.branches;
1767
+ } catch {
1768
+ // Refetch failed (rare): optimistic removal. The announced count
1769
+ // may then differ from rows removed if the backend skipped one;
1770
+ // the panel's own _fetch (in _deleteBranches) reconciles on disk.
1771
+ items = items.filter(b => !selected.has(b.session_id));
1772
+ }
1773
+ // The user may have closed the popup during the refetch await - don't
1774
+ // touch shared state or a detached DOM in that case.
1775
+ if (!bodyWidget.isAttached) {
1671
1776
  return;
1672
1777
  }
1673
- items = items.filter(b => !selected.has(b.session_id));
1778
+ setDeleting(false);
1674
1779
  selected.clear();
1675
1780
  this._lastBranches = items;
1676
1781
  render();
1677
1782
  updateControls();
1783
+ // Feedback (no prompt): a perceivable counter + a polite SR
1784
+ // announcement of the actual number the backend moved to trash.
1785
+ const moved = `${deleted} moved to trash`;
1786
+ selCount.textContent = moved;
1787
+ selCount.classList.add('jp-mod-deleted');
1788
+ // Clear then re-set on the next tick so an identical count (two
1789
+ // single deletes in a row) is still re-announced by aria-live.
1790
+ srLive.textContent = '';
1791
+ window.setTimeout(() => {
1792
+ srLive.textContent = moved;
1793
+ }, 60);
1794
+ // Keep focus inside the dialog (render() destroyed the focused row).
1795
+ // Focus the select-all checkbox (a real control with a reliable
1796
+ // keyboard ring), unless the user is still typing in the search box.
1797
+ if (document.activeElement !== search) {
1798
+ selectAll.focus();
1799
+ }
1800
+ })
1801
+ .catch(() => {
1802
+ // Defensive: never leave the button stuck disabled if the chain
1803
+ // rejects unexpectedly.
1804
+ if (bodyWidget.isAttached) {
1805
+ setDeleting(false);
1806
+ updateControls();
1807
+ }
1678
1808
  });
1679
- });
1680
1809
  });
1681
1810
 
1682
1811
  search.addEventListener('input', () => {
1812
+ if (deleting) {
1813
+ return;
1814
+ }
1683
1815
  render();
1684
1816
  updateControls();
1685
1817
  });
package/style/base.css CHANGED
@@ -377,9 +377,50 @@
377
377
  border-color: var(--jp-brand-color1);
378
378
  }
379
379
 
380
+ /* Table header strip above the scrollable list - select-all and count. */
381
+ .jp-ClaudeSessionsPanel-branchHeader {
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: space-between;
385
+ padding: 0 2px;
386
+ }
387
+
388
+ .jp-ClaudeSessionsPanel-branchHeaderCount {
389
+ color: var(--jp-ui-font-color2);
390
+ font-size: var(--jp-ui-font-size0);
391
+ }
392
+
393
+ /* Fixed-width first column so the checkbox (branch rows) and the empty cell
394
+ (current row) keep the name column aligned, giving a table feel. */
395
+ .jp-ClaudeSessionsPanel-branchSelectCell {
396
+ flex: none;
397
+ width: 24px;
398
+ height: 24px;
399
+ display: flex;
400
+ align-items: center;
401
+ justify-content: center;
402
+ cursor: pointer;
403
+ }
404
+
380
405
  .jp-ClaudeSessionsPanel-branchList {
381
406
  max-height: 280px;
382
407
  overflow-y: auto;
408
+ position: relative;
409
+ border: 1px solid var(--jp-border-color2);
410
+ border-radius: 2px;
411
+ }
412
+
413
+ /* Visually-hidden polite live region (delete announcements). */
414
+ .jp-ClaudeSessionsPanel-srOnly {
415
+ position: absolute;
416
+ width: 1px;
417
+ height: 1px;
418
+ padding: 0;
419
+ margin: -1px;
420
+ overflow: hidden;
421
+ clip: rect(0, 0, 0, 0);
422
+ white-space: nowrap;
423
+ border: 0;
383
424
  }
384
425
 
385
426
  .jp-ClaudeSessionsPanel-branchRow {
@@ -437,23 +478,39 @@
437
478
  cursor: pointer;
438
479
  }
439
480
 
440
- /* The current conversation leads the popup list - badged, untouchable. */
481
+ /* The current conversation is pinned at the top of the scroll area and
482
+ accented (brand left-bar + tint) so it reads as the active one - not
483
+ dimmed; it is not selectable or deletable. */
441
484
  .jp-ClaudeSessionsPanel-branchRow.jp-mod-current {
485
+ position: sticky;
486
+ top: 0;
487
+ z-index: 1;
442
488
  cursor: default;
443
- color: var(--jp-ui-font-color2);
489
+ background: var(--jp-layout-color2);
490
+ border-left: 3px solid var(--jp-brand-color1);
491
+ padding-left: 5px;
444
492
  }
445
493
 
446
494
  .jp-ClaudeSessionsPanel-branchRow.jp-mod-current:hover {
447
- background: transparent;
495
+ background: var(--jp-layout-color2);
496
+ }
497
+
498
+ /* The current row's select cell is an empty alignment spacer - no pointer. */
499
+ .jp-ClaudeSessionsPanel-branchRow.jp-mod-current
500
+ .jp-ClaudeSessionsPanel-branchSelectCell {
501
+ cursor: default;
448
502
  }
449
503
 
450
504
  .jp-ClaudeSessionsPanel-branchCurrentBadge {
451
505
  flex: none;
452
506
  padding: 0 6px;
453
- border: 1px solid var(--jp-border-color2);
507
+ border: 1px solid var(--jp-brand-color1);
454
508
  border-radius: 8px;
455
- color: var(--jp-ui-font-color2);
509
+ color: var(--jp-brand-color1);
456
510
  font-size: var(--jp-ui-font-size0);
511
+ font-weight: 600;
512
+ text-transform: uppercase;
513
+ letter-spacing: 0.5px;
457
514
  }
458
515
 
459
516
  .jp-ClaudeSessionsPanel-branchSelectAll {
@@ -474,27 +531,53 @@
474
531
 
475
532
  .jp-ClaudeSessionsPanel-branchFooter {
476
533
  display: flex;
477
- justify-content: flex-end;
534
+ align-items: center;
535
+ justify-content: space-between;
478
536
  }
479
537
 
538
+ .jp-ClaudeSessionsPanel-branchSelCount {
539
+ color: var(--jp-ui-font-color2);
540
+ font-size: var(--jp-ui-font-size0);
541
+ }
542
+
543
+ /* The post-delete "N moved to trash" feedback reads at full strength - it is
544
+ the recoverability cue that stands in for a confirmation prompt. */
545
+ .jp-ClaudeSessionsPanel-branchSelCount.jp-mod-deleted {
546
+ color: var(--jp-ui-font-color1);
547
+ }
548
+
549
+ /* While a delete is in flight the whole body is scrimmed so a mid-flight tick,
550
+ a select-all, a search or a second delete cannot race the async refetch. The
551
+ Dialog's Cancel button is outside the body and stays usable. */
552
+ .jp-ClaudeSessionsPanel-branchPopup.jp-mod-busy {
553
+ pointer-events: none;
554
+ opacity: 0.6;
555
+ }
556
+
557
+ /* Destructive: deletes immediately (no confirmation), so it reads in the
558
+ error colour and fills on hover. */
480
559
  .jp-ClaudeSessionsPanel-branchDelete {
481
560
  padding: 2px 8px;
482
- border: 1px solid var(--jp-border-color1);
561
+ border: 1px solid var(--jp-error-color1);
483
562
  border-radius: 2px;
484
563
  background: var(--jp-layout-color1);
485
- color: var(--jp-ui-font-color1);
564
+ color: var(--jp-error-color1);
486
565
  cursor: pointer;
566
+ transition:
567
+ background 120ms ease,
568
+ color 120ms ease;
487
569
  }
488
570
 
489
571
  .jp-ClaudeSessionsPanel-branchDelete:disabled {
490
572
  opacity: 0.5;
491
573
  cursor: default;
574
+ border-color: var(--jp-border-color1);
575
+ color: var(--jp-ui-font-color2);
492
576
  }
493
577
 
494
- /* Armed state of the two-step delete - second click executes. */
495
- .jp-ClaudeSessionsPanel-branchDelete.jp-mod-confirm {
496
- border-color: var(--jp-error-color1);
497
- color: var(--jp-error-color1);
578
+ .jp-ClaudeSessionsPanel-branchDelete:hover:not(:disabled) {
579
+ background: var(--jp-error-color1);
580
+ color: var(--jp-ui-inverse-font-color1, #fff);
498
581
  }
499
582
 
500
583
  /* Last-activity time on session rows - dim, fixed-width right-aligned