jupyterlab_claude_code_extension 1.2.24 → 1.2.26

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/lib/widget.js CHANGED
@@ -7,6 +7,10 @@ import { Menu, Widget } from '@lumino/widgets';
7
7
  import { requestAPI } from './request';
8
8
  import { addIcon, branchIcon, switchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
9
9
  const POLL_INTERVAL_MS = 30000;
10
+ // After a fork is requested, watch for its lazily-written JSONL at this fast
11
+ // cadence (bounded) so a new branch surfaces in seconds, not on the slow poll.
12
+ const BRANCH_WATCH_INTERVAL_MS = 2000;
13
+ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
10
14
  const DEFAULT_RECENT_LIMIT = 10;
11
15
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
12
16
  const DEFAULT_PRESENTATION_MODE = 'folder';
@@ -72,7 +76,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
72
76
  this._activeRowEl = null;
73
77
  this._pollHandle = null;
74
78
  this._removingPaths = new Set();
79
+ // Microcache of the most-recent terminal per project, tagged with the
80
+ // conversation it is running so reuse can tell a project's branches apart.
75
81
  this._terminalsByPath = new Map();
82
+ // In-flight launches, keyed per CONVERSATION (path + session id) so two
83
+ // different branches of one project can open independently and concurrently.
76
84
  this._pendingByPath = new Map();
77
85
  this._presentationMode = DEFAULT_PRESENTATION_MODE;
78
86
  this._recentLimit = DEFAULT_RECENT_LIMIT;
@@ -441,44 +449,75 @@ export class ClaudeCodeSessionsWidget extends Widget {
441
449
  }
442
450
  }
443
451
  // -------------------------------------------------------------- terminal
444
- async _resumeInTerminal(session, forceDangerous = false) {
445
- // Coalesce concurrent clicks on the same row - subsequent clicks attach
446
- // to the in-flight promise instead of creating their own terminal.
447
- const inFlight = this._pendingByPath.get(session.project_path);
452
+ async _resumeInTerminal(session, forceDangerous = false, strict = false) {
453
+ var _a;
454
+ // Coalesce concurrent clicks on the SAME conversation - subsequent clicks
455
+ // attach to the in-flight promise instead of creating their own terminal.
456
+ // The key is per-conversation, so opening a different branch of the same
457
+ // project launches independently rather than coalescing onto this one.
458
+ const key = `${session.project_path}\n${(_a = session.session_id) !== null && _a !== void 0 ? _a : ''}`;
459
+ const inFlight = this._pendingByPath.get(key);
448
460
  if (inFlight) {
449
461
  return inFlight;
450
462
  }
451
- const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
452
- this._pendingByPath.delete(session.project_path);
463
+ const promise = this._doResumeInTerminal(session, forceDangerous, strict).finally(() => {
464
+ this._pendingByPath.delete(key);
453
465
  });
454
- this._pendingByPath.set(session.project_path, promise);
466
+ this._pendingByPath.set(key, promise);
455
467
  return promise;
456
468
  }
457
- async _doResumeInTerminal(session, forceDangerous) {
469
+ /**
470
+ * Reuse an open terminal only when it is running the conversation the
471
+ * caller wants; otherwise launch a fresh ``claude --resume <id>``.
472
+ *
473
+ * ``strict`` selects how a cwd-matching claude terminal whose conversation
474
+ * is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
475
+ * - lenient (row resume): reuse it. A fresh session's id is not in its
476
+ * argv, so this is the only way to refocus a just-started session, and
477
+ * it avoids ``claude --resume`` erroring with "already in use".
478
+ * - strict (Open Branched Conversation): never reuse an unknown terminal -
479
+ * the user picked a specific branch and must land in THAT conversation,
480
+ * so a fresh/foreign terminal is left alone and a new one is launched.
481
+ * A terminal whose resume id is KNOWN and differs is never reused in
482
+ * either mode - that is the switch-then-click bug this fixes.
483
+ */
484
+ async _doResumeInTerminal(session, forceDangerous, strict) {
485
+ var _a;
458
486
  try {
459
- // Always prefer reusing an open terminal for this project. The
487
+ // Always prefer reusing an open terminal for this conversation. The
460
488
  // skip-permissions flag can only be applied to a fresh pty, never
461
489
  // retroactively. So if the user wants dangerous mode but an open
462
490
  // terminal already exists, show a modal asking them to close it
463
491
  // first - we won't auto-close, won't silently reuse the wrong mode.
464
- // 1. In-memory microcache.
492
+ // 1. In-memory microcache (most-recent terminal for this project).
465
493
  const cached = this._terminalsByPath.get(session.project_path);
466
- if (cached && !cached.isDisposed) {
467
- if (forceDangerous) {
468
- await this._showCloseExistingDialog();
494
+ if (cached && !cached.widget.isDisposed) {
495
+ const sameConversation = cached.sessionId === session.session_id;
496
+ const unknownConversation = !cached.sessionId;
497
+ if (sameConversation || (!strict && unknownConversation)) {
498
+ if (forceDangerous) {
499
+ await this._showCloseExistingDialog();
500
+ }
501
+ this._focusTerminal(cached.widget);
502
+ return;
469
503
  }
470
- this._focusTerminal(cached);
471
- return;
472
504
  }
473
505
  // 2. Walk every live terminal widget JL knows about.
474
- const found = await this._findTerminalForCwd(session.project_path);
506
+ const found = await this._findTerminalForCwd(session.project_path, session.session_id, strict);
475
507
  if (found) {
476
- this._terminalsByPath.set(session.project_path, found);
477
- this._wireTerminalDisposal(session.project_path, found);
508
+ // Tag the cache with the OBSERVED conversation (null when the reused
509
+ // terminal is a fresh no-resume one), not the wanted id - otherwise a
510
+ // later strict reuse would trust a fabricated tag and focus the wrong
511
+ // conversation.
512
+ this._terminalsByPath.set(session.project_path, {
513
+ widget: found.widget,
514
+ sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
515
+ });
516
+ this._wireTerminalDisposal(session.project_path, found.widget);
478
517
  if (forceDangerous) {
479
518
  await this._showCloseExistingDialog();
480
519
  }
481
- this._focusTerminal(found);
520
+ this._focusTerminal(found.widget);
482
521
  return;
483
522
  }
484
523
  // 3. No matching terminal - spawn a new one with `claude --resume <id>`
@@ -502,7 +541,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
502
541
  name: launched.terminal_name
503
542
  });
504
543
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
505
- this._terminalsByPath.set(session.project_path, widget);
544
+ this._terminalsByPath.set(session.project_path, {
545
+ widget,
546
+ sessionId: session.session_id
547
+ });
506
548
  this._wireTerminalDisposal(session.project_path, widget);
507
549
  this._focusTerminal(widget);
508
550
  }
@@ -552,7 +594,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
552
594
  name: launched.terminal_name
553
595
  });
554
596
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
555
- this._terminalsByPath.set(projectPath, widget);
597
+ // A fresh session's conversation id is not in its argv, so leave
598
+ // sessionId undefined - reuse treats it as the project's current.
599
+ this._terminalsByPath.set(projectPath, {
600
+ widget,
601
+ sessionId: undefined
602
+ });
556
603
  this._wireTerminalDisposal(projectPath, widget);
557
604
  this._focusTerminal(widget);
558
605
  }
@@ -592,8 +639,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
592
639
  }
593
640
  });
594
641
  }
595
- async _findTerminalForCwd(projectPath) {
596
- var _a, _b;
642
+ async _findTerminalForCwd(projectPath, wantedSessionId, strict) {
643
+ var _a, _b, _c;
597
644
  if (!this._terminalTracker) {
598
645
  return null;
599
646
  }
@@ -617,10 +664,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
617
664
  if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
618
665
  continue;
619
666
  }
667
+ // Conversation gate: reuse only a terminal running the wanted
668
+ // conversation. A known, different resume id is never reused (the
669
+ // switch-then-click bug). An unknown id (fresh session, no --resume)
670
+ // is reused in lenient mode but skipped in strict mode, where the
671
+ // user picked a specific branch to open.
672
+ const runningId = (_c = data.session_id) !== null && _c !== void 0 ? _c : null;
673
+ const sameConversation = runningId === wantedSessionId;
674
+ const unknownConversation = runningId === null;
675
+ if (!(sameConversation || (!strict && unknownConversation))) {
676
+ continue;
677
+ }
620
678
  const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
621
679
  for (const cwd of cwds) {
622
680
  if ((cwd || '').replace(/\/+$/, '') === target) {
623
- return widget;
681
+ // Return the OBSERVED running id (may be null for a fresh
682
+ // terminal), never the wanted id - the caller tags its cache
683
+ // with this so a later strict reuse trusts the process, not a
684
+ // wish.
685
+ return { widget, runningId };
624
686
  }
625
687
  }
626
688
  }
@@ -667,7 +729,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
667
729
  return;
668
730
  }
669
731
  widget.disposed.connect(() => {
670
- if (this._terminalsByPath.get(projectPath) === widget) {
732
+ var _a;
733
+ if (((_a = this._terminalsByPath.get(projectPath)) === null || _a === void 0 ? void 0 : _a.widget) === widget) {
671
734
  this._terminalsByPath.delete(projectPath);
672
735
  }
673
736
  });
@@ -1127,6 +1190,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1127
1190
  void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1128
1191
  }
1129
1192
  });
1193
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1194
+ label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
1195
+ icon: terminalIcon,
1196
+ execute: args => {
1197
+ var _a;
1198
+ const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
1199
+ if (sessionId) {
1200
+ void this._openBranch(sessionId);
1201
+ }
1202
+ }
1203
+ });
1130
1204
  this._commands.addCommand('claude-code-sessions:branch-session', {
1131
1205
  label: 'Normal',
1132
1206
  execute: () => void this._branchSession(false)
@@ -1171,6 +1245,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
1171
1245
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1172
1246
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1173
1247
  this._branchSubmenu.title.icon = switchIcon;
1248
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1249
+ // switch submenu, which only changes which branch the row points at).
1250
+ // Several branches can be open at once, independently.
1251
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1252
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1253
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1254
+ this._openBranchSubmenu.title.icon = terminalIcon;
1174
1255
  // Submenu grouping the two branch-session launch modes.
1175
1256
  this._branchSessionMenu = new Menu({ commands: this._commands });
1176
1257
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1217,6 +1298,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1217
1298
  });
1218
1299
  this._contextMenu.addItem({ type: 'separator' });
1219
1300
  if (withBranches) {
1301
+ this._contextMenu.addItem({
1302
+ type: 'submenu',
1303
+ submenu: this._openBranchSubmenu
1304
+ });
1220
1305
  this._contextMenu.addItem({
1221
1306
  type: 'submenu',
1222
1307
  submenu: this._branchSubmenu
@@ -1259,6 +1344,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1259
1344
  this._branchSubmenu.addItem({
1260
1345
  command: 'claude-code-sessions:switch-branch-more'
1261
1346
  });
1347
+ // Open submenu: same top-5 branches, but each launches its own
1348
+ // terminal directly. Falls through to the Manage Sessions popup for
1349
+ // the full list (from which any conversation can also be opened).
1350
+ this._openBranchSubmenu.clearItems();
1351
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1352
+ for (const b of data.branches.slice(0, 5)) {
1353
+ this._openBranchSubmenu.addItem({
1354
+ command: 'claude-code-sessions:open-branch',
1355
+ args: {
1356
+ session_id: b.session_id,
1357
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1358
+ }
1359
+ });
1360
+ }
1361
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1362
+ this._openBranchSubmenu.addItem({
1363
+ command: 'claude-code-sessions:switch-branch-more'
1364
+ });
1262
1365
  hasBranches = data.branches.length > 0;
1263
1366
  }
1264
1367
  catch (_a) {
@@ -1292,6 +1395,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1292
1395
  // Local working copy so deletions can refresh the list in place.
1293
1396
  let items = [...branches];
1294
1397
  const selected = new Set();
1398
+ let deleting = false; // guards the async delete against double-invocation
1295
1399
  const body = document.createElement('div');
1296
1400
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1297
1401
  const search = document.createElement('input');
@@ -1299,28 +1403,68 @@ export class ClaudeCodeSessionsWidget extends Widget {
1299
1403
  search.placeholder = 'Filter sessions...';
1300
1404
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1301
1405
  body.appendChild(search);
1406
+ // Table header strip: select-all on the left, conversation count right.
1407
+ const header = document.createElement('div');
1408
+ header.className = 'jp-ClaudeSessionsPanel-branchHeader';
1302
1409
  const selectAllBar = document.createElement('label');
1303
1410
  selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1304
1411
  const selectAll = document.createElement('input');
1305
1412
  selectAll.type = 'checkbox';
1306
1413
  selectAllBar.appendChild(selectAll);
1307
1414
  selectAllBar.appendChild(document.createTextNode('Select all'));
1308
- body.appendChild(selectAllBar);
1415
+ header.appendChild(selectAllBar);
1416
+ const countEl = document.createElement('span');
1417
+ countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
1418
+ header.appendChild(countEl);
1419
+ body.appendChild(header);
1309
1420
  const list = document.createElement('div');
1310
1421
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1422
+ // role=group makes the aria-label apply to the conversation list region.
1423
+ list.setAttribute('role', 'group');
1424
+ list.setAttribute('aria-label', 'Conversations');
1311
1425
  body.appendChild(list);
1312
1426
  const footer = document.createElement('div');
1313
1427
  footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1428
+ // Plain visual counter (selection or last action). The screen-reader
1429
+ // announcement lives in a separate live region (srLive) so per-checkbox
1430
+ // ticks are not announced on top of the native checkbox.
1431
+ const selCount = document.createElement('span');
1432
+ selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
1433
+ footer.appendChild(selCount);
1314
1434
  const deleteBtn = document.createElement('button');
1435
+ deleteBtn.type = 'button';
1315
1436
  deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1437
+ deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
1316
1438
  footer.appendChild(deleteBtn);
1317
1439
  body.appendChild(footer);
1440
+ // Visually-hidden polite live region, announced only on delete.
1441
+ const srLive = document.createElement('div');
1442
+ srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
1443
+ srLive.setAttribute('role', 'status');
1444
+ srLive.setAttribute('aria-live', 'polite');
1445
+ body.appendChild(srLive);
1318
1446
  const bodyWidget = new Widget({ node: body });
1319
1447
  const dialog = new Dialog({
1320
- title: 'Switch and Manage Sessions',
1448
+ title: 'Manage Sessions',
1321
1449
  body: bodyWidget,
1322
1450
  buttons: [Dialog.cancelButton()]
1323
1451
  });
1452
+ // Per-row "Open" launches that conversation in its own terminal (strict
1453
+ // reuse via _openBranch) and closes the popup. stopPropagation keeps the
1454
+ // click from toggling selection or switching the row.
1455
+ const openButton = (sessionId) => {
1456
+ const btn = document.createElement('button');
1457
+ btn.type = 'button';
1458
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1459
+ btn.textContent = 'Open';
1460
+ btn.title = 'Open this conversation in its own terminal';
1461
+ btn.addEventListener('click', e => {
1462
+ e.stopPropagation();
1463
+ dialog.dispose();
1464
+ void this._openBranch(sessionId);
1465
+ });
1466
+ return btn;
1467
+ };
1324
1468
  const visibleMatches = () => {
1325
1469
  const needle = search.value.trim().toLowerCase();
1326
1470
  return items.filter(b => !needle ||
@@ -1328,9 +1472,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1328
1472
  b.session_id.toLowerCase().includes(needle));
1329
1473
  };
1330
1474
  const updateControls = () => {
1331
- deleteBtn.disabled = selected.size === 0;
1332
- deleteBtn.textContent = `Delete (${selected.size})`;
1475
+ deleteBtn.disabled = deleting || selected.size === 0;
1476
+ deleteBtn.textContent = deleting
1477
+ ? 'Deleting...'
1478
+ : `Delete (${selected.size})`;
1479
+ selCount.textContent = selected.size ? `${selected.size} selected` : '';
1480
+ selCount.classList.remove('jp-mod-deleted');
1333
1481
  const visible = visibleMatches();
1482
+ const total = items.length + 1; // + the pinned current conversation
1483
+ const shown = visible.length + 1; // the current row is always shown
1484
+ countEl.textContent = search.value.trim()
1485
+ ? `${shown} of ${total}`
1486
+ : `${total} conversation${total === 1 ? '' : 's'}`;
1334
1487
  const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
1335
1488
  selectAll.checked =
1336
1489
  visible.length > 0 && visibleSelected === visible.length;
@@ -1344,6 +1497,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1344
1497
  const currentRow = document.createElement('div');
1345
1498
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1346
1499
  currentRow.title = `Session id: ${current}`;
1500
+ // Empty select cell keeps the name column aligned with branch rows.
1501
+ const currentSelect = document.createElement('span');
1502
+ currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1503
+ currentRow.appendChild(currentSelect);
1347
1504
  const currentLabel = document.createElement('span');
1348
1505
  currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1349
1506
  const currentName = this._activeSession
@@ -1355,6 +1512,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1355
1512
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1356
1513
  badge.textContent = 'current';
1357
1514
  currentRow.appendChild(badge);
1515
+ currentRow.appendChild(openButton(current));
1358
1516
  currentRow.appendChild(this._branchCopyButton(current));
1359
1517
  list.appendChild(currentRow);
1360
1518
  const matches = visibleMatches();
@@ -1371,12 +1529,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1371
1529
  const row = document.createElement('div');
1372
1530
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1373
1531
  row.title = `Session id: ${b.session_id}`;
1532
+ const selectCell = document.createElement('span');
1533
+ selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1374
1534
  const check = document.createElement('input');
1375
1535
  check.type = 'checkbox';
1376
1536
  check.checked = selected.has(b.session_id);
1537
+ check.setAttribute('aria-label', `Select ${this._branchDisplayName(b)}`);
1377
1538
  // The checkbox is its own click zone - ticking must not switch.
1378
1539
  check.addEventListener('click', e => {
1379
1540
  e.stopPropagation();
1541
+ if (deleting) {
1542
+ // Keyboard Space isn't blocked by the busy scrim's pointer-events;
1543
+ // re-sync the native toggle on the next render.
1544
+ check.checked = selected.has(b.session_id);
1545
+ return;
1546
+ }
1380
1547
  if (check.checked) {
1381
1548
  selected.add(b.session_id);
1382
1549
  }
@@ -1385,7 +1552,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
1385
1552
  }
1386
1553
  updateControls();
1387
1554
  });
1388
- row.appendChild(check);
1555
+ selectCell.appendChild(check);
1556
+ // The whole cell is a select target (>=24px), not just the checkbox;
1557
+ // clicking the padding toggles via the checkbox's own handler.
1558
+ selectCell.addEventListener('click', e => {
1559
+ if (e.target !== check) {
1560
+ e.stopPropagation();
1561
+ check.click();
1562
+ }
1563
+ });
1564
+ row.appendChild(selectCell);
1389
1565
  const label = document.createElement('span');
1390
1566
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1391
1567
  label.textContent = this._branchDisplayName(b);
@@ -1394,6 +1570,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1394
1570
  time.className = 'jp-ClaudeSessionsPanel-branchTime';
1395
1571
  time.textContent = this._formatRelativeTime(b.file_mtime);
1396
1572
  row.appendChild(time);
1573
+ row.appendChild(openButton(b.session_id));
1397
1574
  row.appendChild(this._branchCopyButton(b.session_id));
1398
1575
  row.addEventListener('click', () => {
1399
1576
  // Selection mode: while anything is ticked, row clicks toggle
@@ -1416,6 +1593,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1416
1593
  }
1417
1594
  };
1418
1595
  selectAll.addEventListener('change', () => {
1596
+ if (deleting) {
1597
+ return;
1598
+ }
1419
1599
  // Select-all acts on the visible (filtered) rows only.
1420
1600
  const visible = visibleMatches();
1421
1601
  if (selectAll.checked) {
@@ -1427,33 +1607,100 @@ export class ClaudeCodeSessionsWidget extends Widget {
1427
1607
  render();
1428
1608
  updateControls();
1429
1609
  });
1610
+ // Busy-lock the whole body (button + list) during the async delete so a
1611
+ // slow backend cannot be double-clicked into deleting the same set twice
1612
+ // and a mid-flight selection cannot be silently discarded.
1613
+ const setDeleting = (on) => {
1614
+ deleting = on;
1615
+ // Scrim the whole popup body (search, select-all, list) so nothing can
1616
+ // be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
1617
+ // sits outside the body and stays usable. aria-busy goes on the live
1618
+ // list region, not the (disabled, unannounced) button.
1619
+ body.classList.toggle('jp-mod-busy', on);
1620
+ if (on) {
1621
+ list.setAttribute('aria-busy', 'true');
1622
+ }
1623
+ else {
1624
+ list.removeAttribute('aria-busy');
1625
+ }
1626
+ };
1430
1627
  deleteBtn.addEventListener('click', () => {
1431
- if (selected.size === 0) {
1628
+ if (deleting || selected.size === 0) {
1432
1629
  return;
1433
1630
  }
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) {
1631
+ // No confirmation here: deletions move to trash (recoverable), and a
1632
+ // second Lumino dialog stacked on this popup renders detached. The only
1633
+ // confirmed destructive actions are Clean Up Parallel Sessions and
1634
+ // Remove from Claude (whole-project / bulk). Feedback is given instead
1635
+ // of a prompt: a live "N moved to trash" status (a failure still toasts
1636
+ // via _deleteBranches).
1637
+ setDeleting(true);
1638
+ updateControls();
1639
+ void this._deleteBranches([...selected])
1640
+ .then(async (deleted) => {
1641
+ if (deleted === null) {
1642
+ setDeleting(false);
1643
+ updateControls(); // re-enable; selection unchanged
1442
1644
  return;
1443
1645
  }
1444
- void this._deleteBranches([...selected]).then(deleted => {
1445
- if (deleted === null) {
1446
- return;
1646
+ // Re-sync from disk truth, not an optimistic splice: a branch the
1647
+ // backend skipped (it became the resolved-current, or was already
1648
+ // gone) must not vanish from the list while it still exists.
1649
+ const session = this._activeSession;
1650
+ try {
1651
+ if (!session) {
1652
+ throw new Error('no active session');
1447
1653
  }
1654
+ const fresh = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1655
+ items = fresh.branches;
1656
+ }
1657
+ catch (_a) {
1658
+ // Refetch failed (rare): optimistic removal. The announced count
1659
+ // may then differ from rows removed if the backend skipped one;
1660
+ // the panel's own _fetch (in _deleteBranches) reconciles on disk.
1448
1661
  items = items.filter(b => !selected.has(b.session_id));
1449
- selected.clear();
1450
- this._lastBranches = items;
1451
- render();
1662
+ }
1663
+ // The user may have closed the popup during the refetch await - don't
1664
+ // touch shared state or a detached DOM in that case.
1665
+ if (!bodyWidget.isAttached) {
1666
+ return;
1667
+ }
1668
+ setDeleting(false);
1669
+ selected.clear();
1670
+ this._lastBranches = items;
1671
+ render();
1672
+ updateControls();
1673
+ // Feedback (no prompt): a perceivable counter + a polite SR
1674
+ // announcement of the actual number the backend moved to trash.
1675
+ const moved = `${deleted} moved to trash`;
1676
+ selCount.textContent = moved;
1677
+ selCount.classList.add('jp-mod-deleted');
1678
+ // Clear then re-set on the next tick so an identical count (two
1679
+ // single deletes in a row) is still re-announced by aria-live.
1680
+ srLive.textContent = '';
1681
+ window.setTimeout(() => {
1682
+ srLive.textContent = moved;
1683
+ }, 60);
1684
+ // Keep focus inside the dialog (render() destroyed the focused row).
1685
+ // Focus the select-all checkbox (a real control with a reliable
1686
+ // keyboard ring), unless the user is still typing in the search box.
1687
+ if (document.activeElement !== search) {
1688
+ selectAll.focus();
1689
+ }
1690
+ })
1691
+ .catch(() => {
1692
+ // Defensive: never leave the button stuck disabled if the chain
1693
+ // rejects unexpectedly.
1694
+ if (bodyWidget.isAttached) {
1695
+ setDeleting(false);
1452
1696
  updateControls();
1453
- });
1697
+ }
1454
1698
  });
1455
1699
  });
1456
1700
  search.addEventListener('input', () => {
1701
+ if (deleting) {
1702
+ return;
1703
+ }
1457
1704
  render();
1458
1705
  updateControls();
1459
1706
  });
@@ -1534,7 +1781,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1534
1781
  name: launched.terminal_name
1535
1782
  });
1536
1783
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
1537
- this._terminalsByPath.set(session.project_path, widget);
1784
+ // The terminal runs the FORK (forkId), so tag it with that id - a
1785
+ // later click on the now-current forked row reuses this terminal.
1786
+ this._terminalsByPath.set(session.project_path, {
1787
+ widget,
1788
+ sessionId: forkId
1789
+ });
1538
1790
  this._wireTerminalDisposal(session.project_path, widget);
1539
1791
  this._focusTerminal(widget);
1540
1792
  }
@@ -1547,8 +1799,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1547
1799
  spinner.dispose();
1548
1800
  }
1549
1801
  // No post-hoc title write: claude owns the name via ``-n`` and stamps it
1550
- // on the fork's first turn. The branch surfaces on the next poll once
1551
- // claude materialises its JSONL.
1802
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
1803
+ // so watch for it at a fast cadence and refresh the moment it appears -
1804
+ // the branch surfaces in seconds instead of on the next slow poll.
1805
+ this._watchForBranch(session.encoded_path, forkId);
1552
1806
  }
1553
1807
  /** Switch the active row's project to another conversation branch.
1554
1808
  * The backend touches the branch JSONL's mtime; a refresh then shows
@@ -1583,6 +1837,60 @@ export class ClaudeCodeSessionsWidget extends Widget {
1583
1837
  await this._fetch();
1584
1838
  }
1585
1839
  }
1840
+ /** Open a specific conversation branch in its own terminal.
1841
+ *
1842
+ * Strict reuse: only a terminal already running THIS conversation is
1843
+ * refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
1844
+ * several branches of one project can be open independently and side by
1845
+ * side - opening branch B never disturbs branch A's terminal. Honours the
1846
+ * global skip-permissions toggle like a normal resume. */
1847
+ async _openBranch(sessionId) {
1848
+ const active = this._activeSession;
1849
+ if (!active || !sessionId) {
1850
+ return;
1851
+ }
1852
+ // Opening the row's CURRENT conversation behaves like a normal row resume
1853
+ // (lenient): a fresh terminal already running it is reused, avoiding a
1854
+ // `claude --resume` "already in use" collision. Opening a DIFFERENT branch
1855
+ // is strict so the user lands in that specific conversation, even if some
1856
+ // other (fresh) terminal sits at the same cwd.
1857
+ const strict = sessionId !== active.session_id;
1858
+ await this._resumeInTerminal({ ...active, session_id: sessionId }, false, strict);
1859
+ }
1860
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
1861
+ *
1862
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
1863
+ * just-requested branch does not exist at launch and would otherwise only
1864
+ * surface on the next 30s poll. This watches the project's branch list
1865
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
1866
+ * or in the list) - then does one full refresh and stops - or until a
1867
+ * bounded number of attempts elapses. The panel is never updated before
1868
+ * the branch genuinely exists. */
1869
+ _watchForBranch(encodedPath, forkId) {
1870
+ let attempts = 0;
1871
+ const tick = async () => {
1872
+ if (this.isDisposed) {
1873
+ return;
1874
+ }
1875
+ attempts += 1;
1876
+ try {
1877
+ const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`, this._serverSettings, { cache: 'no-store' });
1878
+ const appeared = data.current === forkId ||
1879
+ data.branches.some(b => b.session_id === forkId);
1880
+ if (appeared) {
1881
+ await this._fetch();
1882
+ return;
1883
+ }
1884
+ }
1885
+ catch (_a) {
1886
+ // Transient failure - keep watching until the attempt budget runs out.
1887
+ }
1888
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
1889
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1890
+ }
1891
+ };
1892
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1893
+ }
1586
1894
  // --------------------------------------------------------------- polling
1587
1895
  _startPolling() {
1588
1896
  if (this._pollHandle !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.24",
3
+ "version": "1.2.26",
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",