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/src/widget.ts CHANGED
@@ -41,6 +41,10 @@ import {
41
41
  } from './types';
42
42
 
43
43
  const POLL_INTERVAL_MS = 30_000;
44
+ // After a fork is requested, watch for its lazily-written JSONL at this fast
45
+ // cadence (bounded) so a new branch surfaces in seconds, not on the slow poll.
46
+ const BRANCH_WATCH_INTERVAL_MS = 2_000;
47
+ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
44
48
  const DEFAULT_RECENT_LIMIT = 10;
45
49
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
46
50
 
@@ -97,6 +101,9 @@ interface ITerminalCwdResponse {
97
101
  terminal_name: string;
98
102
  cwds: string[];
99
103
  has_claude: boolean;
104
+ // Conversation id the running claude is resuming, or null for a fresh
105
+ // (non-resumed) session. Lets reuse tell branches of one project apart.
106
+ session_id?: string | null;
100
107
  }
101
108
 
102
109
  // Drop any pre-v0.6.18 localStorage entries from previous schemes - they
@@ -544,53 +551,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
544
551
 
545
552
  private async _resumeInTerminal(
546
553
  session: ISession,
547
- forceDangerous: boolean = false
554
+ forceDangerous: boolean = false,
555
+ strict: boolean = false
548
556
  ): Promise<void> {
549
- // Coalesce concurrent clicks on the same row - subsequent clicks attach
550
- // to the in-flight promise instead of creating their own terminal.
551
- const inFlight = this._pendingByPath.get(session.project_path);
557
+ // Coalesce concurrent clicks on the SAME conversation - subsequent clicks
558
+ // attach to the in-flight promise instead of creating their own terminal.
559
+ // The key is per-conversation, so opening a different branch of the same
560
+ // project launches independently rather than coalescing onto this one.
561
+ const key = `${session.project_path}\n${session.session_id ?? ''}`;
562
+ const inFlight = this._pendingByPath.get(key);
552
563
  if (inFlight) {
553
564
  return inFlight;
554
565
  }
555
- const promise = this._doResumeInTerminal(session, forceDangerous).finally(
556
- () => {
557
- this._pendingByPath.delete(session.project_path);
558
- }
559
- );
560
- this._pendingByPath.set(session.project_path, promise);
566
+ const promise = this._doResumeInTerminal(
567
+ session,
568
+ forceDangerous,
569
+ strict
570
+ ).finally(() => {
571
+ this._pendingByPath.delete(key);
572
+ });
573
+ this._pendingByPath.set(key, promise);
561
574
  return promise;
562
575
  }
563
576
 
577
+ /**
578
+ * Reuse an open terminal only when it is running the conversation the
579
+ * caller wants; otherwise launch a fresh ``claude --resume <id>``.
580
+ *
581
+ * ``strict`` selects how a cwd-matching claude terminal whose conversation
582
+ * is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
583
+ * - lenient (row resume): reuse it. A fresh session's id is not in its
584
+ * argv, so this is the only way to refocus a just-started session, and
585
+ * it avoids ``claude --resume`` erroring with "already in use".
586
+ * - strict (Open Branched Conversation): never reuse an unknown terminal -
587
+ * the user picked a specific branch and must land in THAT conversation,
588
+ * so a fresh/foreign terminal is left alone and a new one is launched.
589
+ * A terminal whose resume id is KNOWN and differs is never reused in
590
+ * either mode - that is the switch-then-click bug this fixes.
591
+ */
564
592
  private async _doResumeInTerminal(
565
593
  session: ISession,
566
- forceDangerous: boolean
594
+ forceDangerous: boolean,
595
+ strict: boolean
567
596
  ): Promise<void> {
568
597
  try {
569
- // Always prefer reusing an open terminal for this project. The
598
+ // Always prefer reusing an open terminal for this conversation. The
570
599
  // skip-permissions flag can only be applied to a fresh pty, never
571
600
  // retroactively. So if the user wants dangerous mode but an open
572
601
  // terminal already exists, show a modal asking them to close it
573
602
  // first - we won't auto-close, won't silently reuse the wrong mode.
574
603
 
575
- // 1. In-memory microcache.
604
+ // 1. In-memory microcache (most-recent terminal for this project).
576
605
  const cached = this._terminalsByPath.get(session.project_path);
577
- if (cached && !cached.isDisposed) {
578
- if (forceDangerous) {
579
- await this._showCloseExistingDialog();
606
+ if (cached && !cached.widget.isDisposed) {
607
+ const sameConversation = cached.sessionId === session.session_id;
608
+ const unknownConversation = !cached.sessionId;
609
+ if (sameConversation || (!strict && unknownConversation)) {
610
+ if (forceDangerous) {
611
+ await this._showCloseExistingDialog();
612
+ }
613
+ this._focusTerminal(cached.widget);
614
+ return;
580
615
  }
581
- this._focusTerminal(cached);
582
- return;
583
616
  }
584
617
 
585
618
  // 2. Walk every live terminal widget JL knows about.
586
- const found = await this._findTerminalForCwd(session.project_path);
619
+ const found = await this._findTerminalForCwd(
620
+ session.project_path,
621
+ session.session_id,
622
+ strict
623
+ );
587
624
  if (found) {
588
- this._terminalsByPath.set(session.project_path, found);
589
- this._wireTerminalDisposal(session.project_path, found);
625
+ // Tag the cache with the OBSERVED conversation (null when the reused
626
+ // terminal is a fresh no-resume one), not the wanted id - otherwise a
627
+ // later strict reuse would trust a fabricated tag and focus the wrong
628
+ // conversation.
629
+ this._terminalsByPath.set(session.project_path, {
630
+ widget: found.widget,
631
+ sessionId: found.runningId ?? undefined
632
+ });
633
+ this._wireTerminalDisposal(session.project_path, found.widget);
590
634
  if (forceDangerous) {
591
635
  await this._showCloseExistingDialog();
592
636
  }
593
- this._focusTerminal(found);
637
+ this._focusTerminal(found.widget);
594
638
  return;
595
639
  }
596
640
 
@@ -620,7 +664,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
620
664
  name: launched.terminal_name
621
665
  });
622
666
  if (widget?.id) {
623
- this._terminalsByPath.set(session.project_path, widget);
667
+ this._terminalsByPath.set(session.project_path, {
668
+ widget,
669
+ sessionId: session.session_id
670
+ });
624
671
  this._wireTerminalDisposal(session.project_path, widget);
625
672
  this._focusTerminal(widget);
626
673
  }
@@ -673,7 +720,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
673
720
  name: launched.terminal_name
674
721
  });
675
722
  if (widget?.id) {
676
- this._terminalsByPath.set(projectPath, widget);
723
+ // A fresh session's conversation id is not in its argv, so leave
724
+ // sessionId undefined - reuse treats it as the project's current.
725
+ this._terminalsByPath.set(projectPath, {
726
+ widget,
727
+ sessionId: undefined
728
+ });
677
729
  this._wireTerminalDisposal(projectPath, widget);
678
730
  this._focusTerminal(widget);
679
731
  }
@@ -711,7 +763,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
711
763
  });
712
764
  }
713
765
 
714
- private async _findTerminalForCwd(projectPath: string): Promise<any | null> {
766
+ private async _findTerminalForCwd(
767
+ projectPath: string,
768
+ wantedSessionId: string | undefined,
769
+ strict: boolean
770
+ ): Promise<{ widget: any; runningId: string | null } | null> {
715
771
  if (!this._terminalTracker) {
716
772
  return null;
717
773
  }
@@ -738,10 +794,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
738
794
  if (!data?.has_claude) {
739
795
  continue;
740
796
  }
797
+ // Conversation gate: reuse only a terminal running the wanted
798
+ // conversation. A known, different resume id is never reused (the
799
+ // switch-then-click bug). An unknown id (fresh session, no --resume)
800
+ // is reused in lenient mode but skipped in strict mode, where the
801
+ // user picked a specific branch to open.
802
+ const runningId = data.session_id ?? null;
803
+ const sameConversation = runningId === wantedSessionId;
804
+ const unknownConversation = runningId === null;
805
+ if (!(sameConversation || (!strict && unknownConversation))) {
806
+ continue;
807
+ }
741
808
  const cwds = Array.isArray(data?.cwds) ? data.cwds : [];
742
809
  for (const cwd of cwds) {
743
810
  if ((cwd || '').replace(/\/+$/, '') === target) {
744
- return widget;
811
+ // Return the OBSERVED running id (may be null for a fresh
812
+ // terminal), never the wanted id - the caller tags its cache
813
+ // with this so a later strict reuse trusts the process, not a
814
+ // wish.
815
+ return { widget, runningId };
745
816
  }
746
817
  }
747
818
  } catch (_err) {
@@ -792,7 +863,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
792
863
  return;
793
864
  }
794
865
  widget.disposed.connect(() => {
795
- if (this._terminalsByPath.get(projectPath) === widget) {
866
+ if (this._terminalsByPath.get(projectPath)?.widget === widget) {
796
867
  this._terminalsByPath.delete(projectPath);
797
868
  }
798
869
  });
@@ -1311,6 +1382,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1311
1382
  }
1312
1383
  });
1313
1384
 
1385
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1386
+ label: args => String(args.label ?? ''),
1387
+ icon: terminalIcon,
1388
+ execute: args => {
1389
+ const sessionId = String(args.session_id ?? '');
1390
+ if (sessionId) {
1391
+ void this._openBranch(sessionId);
1392
+ }
1393
+ }
1394
+ });
1395
+
1314
1396
  this._commands.addCommand('claude-code-sessions:branch-session', {
1315
1397
  label: 'Normal',
1316
1398
  execute: () => void this._branchSession(false)
@@ -1362,6 +1444,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
1362
1444
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1363
1445
  this._branchSubmenu.title.icon = switchIcon;
1364
1446
 
1447
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1448
+ // switch submenu, which only changes which branch the row points at).
1449
+ // Several branches can be open at once, independently.
1450
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1451
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1452
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1453
+ this._openBranchSubmenu.title.icon = terminalIcon;
1454
+
1365
1455
  // Submenu grouping the two branch-session launch modes.
1366
1456
  this._branchSessionMenu = new Menu({ commands: this._commands });
1367
1457
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1411,6 +1501,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1411
1501
  });
1412
1502
  this._contextMenu.addItem({ type: 'separator' });
1413
1503
  if (withBranches) {
1504
+ this._contextMenu.addItem({
1505
+ type: 'submenu',
1506
+ submenu: this._openBranchSubmenu
1507
+ });
1414
1508
  this._contextMenu.addItem({
1415
1509
  type: 'submenu',
1416
1510
  submenu: this._branchSubmenu
@@ -1462,6 +1556,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
1462
1556
  this._branchSubmenu.addItem({
1463
1557
  command: 'claude-code-sessions:switch-branch-more'
1464
1558
  });
1559
+
1560
+ // Open submenu: same top-5 branches, but each launches its own
1561
+ // terminal directly. Falls through to the Manage Sessions popup for
1562
+ // the full list (from which any conversation can also be opened).
1563
+ this._openBranchSubmenu.clearItems();
1564
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1565
+ for (const b of data.branches.slice(0, 5)) {
1566
+ this._openBranchSubmenu.addItem({
1567
+ command: 'claude-code-sessions:open-branch',
1568
+ args: {
1569
+ session_id: b.session_id,
1570
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1571
+ }
1572
+ });
1573
+ }
1574
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1575
+ this._openBranchSubmenu.addItem({
1576
+ command: 'claude-code-sessions:switch-branch-more'
1577
+ });
1465
1578
  hasBranches = data.branches.length > 0;
1466
1579
  } catch {
1467
1580
  hasBranches = false;
@@ -1496,6 +1609,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1496
1609
  // Local working copy so deletions can refresh the list in place.
1497
1610
  let items = [...branches];
1498
1611
  const selected = new Set<string>();
1612
+ let deleting = false; // guards the async delete against double-invocation
1499
1613
 
1500
1614
  const body = document.createElement('div');
1501
1615
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
@@ -1506,32 +1620,74 @@ export class ClaudeCodeSessionsWidget extends Widget {
1506
1620
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1507
1621
  body.appendChild(search);
1508
1622
 
1623
+ // Table header strip: select-all on the left, conversation count right.
1624
+ const header = document.createElement('div');
1625
+ header.className = 'jp-ClaudeSessionsPanel-branchHeader';
1509
1626
  const selectAllBar = document.createElement('label');
1510
1627
  selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1511
1628
  const selectAll = document.createElement('input');
1512
1629
  selectAll.type = 'checkbox';
1513
1630
  selectAllBar.appendChild(selectAll);
1514
1631
  selectAllBar.appendChild(document.createTextNode('Select all'));
1515
- body.appendChild(selectAllBar);
1632
+ header.appendChild(selectAllBar);
1633
+ const countEl = document.createElement('span');
1634
+ countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
1635
+ header.appendChild(countEl);
1636
+ body.appendChild(header);
1516
1637
 
1517
1638
  const list = document.createElement('div');
1518
1639
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1640
+ // role=group makes the aria-label apply to the conversation list region.
1641
+ list.setAttribute('role', 'group');
1642
+ list.setAttribute('aria-label', 'Conversations');
1519
1643
  body.appendChild(list);
1520
1644
 
1521
1645
  const footer = document.createElement('div');
1522
1646
  footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1647
+ // Plain visual counter (selection or last action). The screen-reader
1648
+ // announcement lives in a separate live region (srLive) so per-checkbox
1649
+ // ticks are not announced on top of the native checkbox.
1650
+ const selCount = document.createElement('span');
1651
+ selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
1652
+ footer.appendChild(selCount);
1523
1653
  const deleteBtn = document.createElement('button');
1654
+ deleteBtn.type = 'button';
1524
1655
  deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1656
+ deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
1525
1657
  footer.appendChild(deleteBtn);
1526
1658
  body.appendChild(footer);
1527
1659
 
1660
+ // Visually-hidden polite live region, announced only on delete.
1661
+ const srLive = document.createElement('div');
1662
+ srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
1663
+ srLive.setAttribute('role', 'status');
1664
+ srLive.setAttribute('aria-live', 'polite');
1665
+ body.appendChild(srLive);
1666
+
1528
1667
  const bodyWidget = new Widget({ node: body });
1529
1668
  const dialog = new Dialog({
1530
- title: 'Switch and Manage Sessions',
1669
+ title: 'Manage Sessions',
1531
1670
  body: bodyWidget,
1532
1671
  buttons: [Dialog.cancelButton()]
1533
1672
  });
1534
1673
 
1674
+ // Per-row "Open" launches that conversation in its own terminal (strict
1675
+ // reuse via _openBranch) and closes the popup. stopPropagation keeps the
1676
+ // click from toggling selection or switching the row.
1677
+ const openButton = (sessionId: string): HTMLButtonElement => {
1678
+ const btn = document.createElement('button');
1679
+ btn.type = 'button';
1680
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1681
+ btn.textContent = 'Open';
1682
+ btn.title = 'Open this conversation in its own terminal';
1683
+ btn.addEventListener('click', e => {
1684
+ e.stopPropagation();
1685
+ dialog.dispose();
1686
+ void this._openBranch(sessionId);
1687
+ });
1688
+ return btn;
1689
+ };
1690
+
1535
1691
  const visibleMatches = (): IBranch[] => {
1536
1692
  const needle = search.value.trim().toLowerCase();
1537
1693
  return items.filter(
@@ -1543,9 +1699,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1543
1699
  };
1544
1700
 
1545
1701
  const updateControls = () => {
1546
- deleteBtn.disabled = selected.size === 0;
1547
- deleteBtn.textContent = `Delete (${selected.size})`;
1702
+ deleteBtn.disabled = deleting || selected.size === 0;
1703
+ deleteBtn.textContent = deleting
1704
+ ? 'Deleting...'
1705
+ : `Delete (${selected.size})`;
1706
+ selCount.textContent = selected.size ? `${selected.size} selected` : '';
1707
+ selCount.classList.remove('jp-mod-deleted');
1548
1708
  const visible = visibleMatches();
1709
+ const total = items.length + 1; // + the pinned current conversation
1710
+ const shown = visible.length + 1; // the current row is always shown
1711
+ countEl.textContent = search.value.trim()
1712
+ ? `${shown} of ${total}`
1713
+ : `${total} conversation${total === 1 ? '' : 's'}`;
1549
1714
  const visibleSelected = visible.filter(b =>
1550
1715
  selected.has(b.session_id)
1551
1716
  ).length;
@@ -1563,6 +1728,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1563
1728
  const currentRow = document.createElement('div');
1564
1729
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1565
1730
  currentRow.title = `Session id: ${current}`;
1731
+ // Empty select cell keeps the name column aligned with branch rows.
1732
+ const currentSelect = document.createElement('span');
1733
+ currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1734
+ currentRow.appendChild(currentSelect);
1566
1735
  const currentLabel = document.createElement('span');
1567
1736
  currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1568
1737
  const currentName = this._activeSession
@@ -1574,6 +1743,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1574
1743
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1575
1744
  badge.textContent = 'current';
1576
1745
  currentRow.appendChild(badge);
1746
+ currentRow.appendChild(openButton(current));
1577
1747
  currentRow.appendChild(this._branchCopyButton(current));
1578
1748
  list.appendChild(currentRow);
1579
1749
 
@@ -1592,12 +1762,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1592
1762
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1593
1763
  row.title = `Session id: ${b.session_id}`;
1594
1764
 
1765
+ const selectCell = document.createElement('span');
1766
+ selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
1595
1767
  const check = document.createElement('input');
1596
1768
  check.type = 'checkbox';
1597
1769
  check.checked = selected.has(b.session_id);
1770
+ check.setAttribute(
1771
+ 'aria-label',
1772
+ `Select ${this._branchDisplayName(b)}`
1773
+ );
1598
1774
  // The checkbox is its own click zone - ticking must not switch.
1599
1775
  check.addEventListener('click', e => {
1600
1776
  e.stopPropagation();
1777
+ if (deleting) {
1778
+ // Keyboard Space isn't blocked by the busy scrim's pointer-events;
1779
+ // re-sync the native toggle on the next render.
1780
+ check.checked = selected.has(b.session_id);
1781
+ return;
1782
+ }
1601
1783
  if (check.checked) {
1602
1784
  selected.add(b.session_id);
1603
1785
  } else {
@@ -1605,7 +1787,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
1605
1787
  }
1606
1788
  updateControls();
1607
1789
  });
1608
- row.appendChild(check);
1790
+ selectCell.appendChild(check);
1791
+ // The whole cell is a select target (>=24px), not just the checkbox;
1792
+ // clicking the padding toggles via the checkbox's own handler.
1793
+ selectCell.addEventListener('click', e => {
1794
+ if (e.target !== check) {
1795
+ e.stopPropagation();
1796
+ check.click();
1797
+ }
1798
+ });
1799
+ row.appendChild(selectCell);
1609
1800
 
1610
1801
  const label = document.createElement('span');
1611
1802
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
@@ -1617,6 +1808,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1617
1808
  time.textContent = this._formatRelativeTime(b.file_mtime);
1618
1809
  row.appendChild(time);
1619
1810
 
1811
+ row.appendChild(openButton(b.session_id));
1620
1812
  row.appendChild(this._branchCopyButton(b.session_id));
1621
1813
 
1622
1814
  row.addEventListener('click', () => {
@@ -1640,6 +1832,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1640
1832
  };
1641
1833
 
1642
1834
  selectAll.addEventListener('change', () => {
1835
+ if (deleting) {
1836
+ return;
1837
+ }
1643
1838
  // Select-all acts on the visible (filtered) rows only.
1644
1839
  const visible = visibleMatches();
1645
1840
  if (selectAll.checked) {
@@ -1651,35 +1846,104 @@ export class ClaudeCodeSessionsWidget extends Widget {
1651
1846
  updateControls();
1652
1847
  });
1653
1848
 
1849
+ // Busy-lock the whole body (button + list) during the async delete so a
1850
+ // slow backend cannot be double-clicked into deleting the same set twice
1851
+ // and a mid-flight selection cannot be silently discarded.
1852
+ const setDeleting = (on: boolean) => {
1853
+ deleting = on;
1854
+ // Scrim the whole popup body (search, select-all, list) so nothing can
1855
+ // be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
1856
+ // sits outside the body and stays usable. aria-busy goes on the live
1857
+ // list region, not the (disabled, unannounced) button.
1858
+ body.classList.toggle('jp-mod-busy', on);
1859
+ if (on) {
1860
+ list.setAttribute('aria-busy', 'true');
1861
+ } else {
1862
+ list.removeAttribute('aria-busy');
1863
+ }
1864
+ };
1865
+
1654
1866
  deleteBtn.addEventListener('click', () => {
1655
- if (selected.size === 0) {
1867
+ if (deleting || selected.size === 0) {
1656
1868
  return;
1657
1869
  }
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 => {
1870
+ // No confirmation here: deletions move to trash (recoverable), and a
1871
+ // second Lumino dialog stacked on this popup renders detached. The only
1872
+ // confirmed destructive actions are Clean Up Parallel Sessions and
1873
+ // Remove from Claude (whole-project / bulk). Feedback is given instead
1874
+ // of a prompt: a live "N moved to trash" status (a failure still toasts
1875
+ // via _deleteBranches).
1876
+ setDeleting(true);
1877
+ updateControls();
1878
+ void this._deleteBranches([...selected])
1879
+ .then(async deleted => {
1670
1880
  if (deleted === null) {
1881
+ setDeleting(false);
1882
+ updateControls(); // re-enable; selection unchanged
1883
+ return;
1884
+ }
1885
+ // Re-sync from disk truth, not an optimistic splice: a branch the
1886
+ // backend skipped (it became the resolved-current, or was already
1887
+ // gone) must not vanish from the list while it still exists.
1888
+ const session = this._activeSession;
1889
+ try {
1890
+ if (!session) {
1891
+ throw new Error('no active session');
1892
+ }
1893
+ const fresh = await requestAPI<IBranchesResponse>(
1894
+ `sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
1895
+ this._serverSettings,
1896
+ { cache: 'no-store' }
1897
+ );
1898
+ items = fresh.branches;
1899
+ } catch {
1900
+ // Refetch failed (rare): optimistic removal. The announced count
1901
+ // may then differ from rows removed if the backend skipped one;
1902
+ // the panel's own _fetch (in _deleteBranches) reconciles on disk.
1903
+ items = items.filter(b => !selected.has(b.session_id));
1904
+ }
1905
+ // The user may have closed the popup during the refetch await - don't
1906
+ // touch shared state or a detached DOM in that case.
1907
+ if (!bodyWidget.isAttached) {
1671
1908
  return;
1672
1909
  }
1673
- items = items.filter(b => !selected.has(b.session_id));
1910
+ setDeleting(false);
1674
1911
  selected.clear();
1675
1912
  this._lastBranches = items;
1676
1913
  render();
1677
1914
  updateControls();
1915
+ // Feedback (no prompt): a perceivable counter + a polite SR
1916
+ // announcement of the actual number the backend moved to trash.
1917
+ const moved = `${deleted} moved to trash`;
1918
+ selCount.textContent = moved;
1919
+ selCount.classList.add('jp-mod-deleted');
1920
+ // Clear then re-set on the next tick so an identical count (two
1921
+ // single deletes in a row) is still re-announced by aria-live.
1922
+ srLive.textContent = '';
1923
+ window.setTimeout(() => {
1924
+ srLive.textContent = moved;
1925
+ }, 60);
1926
+ // Keep focus inside the dialog (render() destroyed the focused row).
1927
+ // Focus the select-all checkbox (a real control with a reliable
1928
+ // keyboard ring), unless the user is still typing in the search box.
1929
+ if (document.activeElement !== search) {
1930
+ selectAll.focus();
1931
+ }
1932
+ })
1933
+ .catch(() => {
1934
+ // Defensive: never leave the button stuck disabled if the chain
1935
+ // rejects unexpectedly.
1936
+ if (bodyWidget.isAttached) {
1937
+ setDeleting(false);
1938
+ updateControls();
1939
+ }
1678
1940
  });
1679
- });
1680
1941
  });
1681
1942
 
1682
1943
  search.addEventListener('input', () => {
1944
+ if (deleting) {
1945
+ return;
1946
+ }
1683
1947
  render();
1684
1948
  updateControls();
1685
1949
  });
@@ -1770,7 +2034,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1770
2034
  name: launched.terminal_name
1771
2035
  });
1772
2036
  if (widget?.id) {
1773
- this._terminalsByPath.set(session.project_path, widget);
2037
+ // The terminal runs the FORK (forkId), so tag it with that id - a
2038
+ // later click on the now-current forked row reuses this terminal.
2039
+ this._terminalsByPath.set(session.project_path, {
2040
+ widget,
2041
+ sessionId: forkId
2042
+ });
1774
2043
  this._wireTerminalDisposal(session.project_path, widget);
1775
2044
  this._focusTerminal(widget);
1776
2045
  }
@@ -1781,8 +2050,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1781
2050
  spinner.dispose();
1782
2051
  }
1783
2052
  // No post-hoc title write: claude owns the name via ``-n`` and stamps it
1784
- // on the fork's first turn. The branch surfaces on the next poll once
1785
- // claude materialises its JSONL.
2053
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
2054
+ // so watch for it at a fast cadence and refresh the moment it appears -
2055
+ // the branch surfaces in seconds instead of on the next slow poll.
2056
+ this._watchForBranch(session.encoded_path, forkId);
1786
2057
  }
1787
2058
 
1788
2059
  /** Switch the active row's project to another conversation branch.
@@ -1828,6 +2099,70 @@ export class ClaudeCodeSessionsWidget extends Widget {
1828
2099
  }
1829
2100
  }
1830
2101
 
2102
+ /** Open a specific conversation branch in its own terminal.
2103
+ *
2104
+ * Strict reuse: only a terminal already running THIS conversation is
2105
+ * refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
2106
+ * several branches of one project can be open independently and side by
2107
+ * side - opening branch B never disturbs branch A's terminal. Honours the
2108
+ * global skip-permissions toggle like a normal resume. */
2109
+ private async _openBranch(sessionId: string): Promise<void> {
2110
+ const active = this._activeSession;
2111
+ if (!active || !sessionId) {
2112
+ return;
2113
+ }
2114
+ // Opening the row's CURRENT conversation behaves like a normal row resume
2115
+ // (lenient): a fresh terminal already running it is reused, avoiding a
2116
+ // `claude --resume` "already in use" collision. Opening a DIFFERENT branch
2117
+ // is strict so the user lands in that specific conversation, even if some
2118
+ // other (fresh) terminal sits at the same cwd.
2119
+ const strict = sessionId !== active.session_id;
2120
+ await this._resumeInTerminal(
2121
+ { ...active, session_id: sessionId },
2122
+ false,
2123
+ strict
2124
+ );
2125
+ }
2126
+
2127
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
2128
+ *
2129
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
2130
+ * just-requested branch does not exist at launch and would otherwise only
2131
+ * surface on the next 30s poll. This watches the project's branch list
2132
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
2133
+ * or in the list) - then does one full refresh and stops - or until a
2134
+ * bounded number of attempts elapses. The panel is never updated before
2135
+ * the branch genuinely exists. */
2136
+ private _watchForBranch(encodedPath: string, forkId: string): void {
2137
+ let attempts = 0;
2138
+ const tick = async (): Promise<void> => {
2139
+ if (this.isDisposed) {
2140
+ return;
2141
+ }
2142
+ attempts += 1;
2143
+ try {
2144
+ const data = await requestAPI<IBranchesResponse>(
2145
+ `sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`,
2146
+ this._serverSettings,
2147
+ { cache: 'no-store' }
2148
+ );
2149
+ const appeared =
2150
+ data.current === forkId ||
2151
+ data.branches.some(b => b.session_id === forkId);
2152
+ if (appeared) {
2153
+ await this._fetch();
2154
+ return;
2155
+ }
2156
+ } catch {
2157
+ // Transient failure - keep watching until the attempt budget runs out.
2158
+ }
2159
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
2160
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2161
+ }
2162
+ };
2163
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2164
+ }
2165
+
1831
2166
  // --------------------------------------------------------------- polling
1832
2167
 
1833
2168
  private _startPolling(): void {
@@ -1862,6 +2197,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1862
2197
  private _commands!: CommandRegistry;
1863
2198
  private _contextMenu!: Menu;
1864
2199
  private _branchSubmenu!: Menu;
2200
+ private _openBranchSubmenu!: Menu;
1865
2201
  private _branchSessionMenu!: Menu;
1866
2202
  private _lastBranches: IBranch[] = [];
1867
2203
  private _lastBranchesCurrent = '';
@@ -1872,7 +2208,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
1872
2208
  private readonly _removingPaths: Set<string> = new Set();
1873
2209
  private readonly _terminalTracker: ITerminalTracker | null;
1874
2210
  private readonly _fileBrowser: IDefaultFileBrowser | null;
1875
- private readonly _terminalsByPath: Map<string, any> = new Map();
2211
+ // Microcache of the most-recent terminal per project, tagged with the
2212
+ // conversation it is running so reuse can tell a project's branches apart.
2213
+ private readonly _terminalsByPath: Map<
2214
+ string,
2215
+ { widget: any; sessionId?: string }
2216
+ > = new Map();
2217
+ // In-flight launches, keyed per CONVERSATION (path + session id) so two
2218
+ // different branches of one project can open independently and concurrently.
1876
2219
  private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
1877
2220
  private readonly _rootDir: string;
1878
2221
  private _presentationMode: PresentationMode = DEFAULT_PRESENTATION_MODE;