jupyterlab_claude_code_extension 1.2.5 → 1.2.9

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
@@ -2,6 +2,7 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import {
3
3
  Clipboard,
4
4
  Dialog,
5
+ InputDialog,
5
6
  Notification,
6
7
  showDialog
7
8
  } from '@jupyterlab/apputils';
@@ -10,12 +11,14 @@ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
10
11
  import { ITerminalTracker } from '@jupyterlab/terminal';
11
12
  import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
12
13
  import { CommandRegistry } from '@lumino/commands';
14
+ import { UUID } from '@lumino/coreutils';
13
15
  import { Menu, Widget } from '@lumino/widgets';
14
16
  import { Message } from '@lumino/messaging';
15
17
 
16
18
  import { requestAPI } from './request';
17
19
  import {
18
20
  addIcon,
21
+ branchIcon,
19
22
  claudeIcon,
20
23
  filterIcon,
21
24
  refreshIcon,
@@ -26,6 +29,7 @@ import {
26
29
  import {
27
30
  IBranch,
28
31
  IBranchesResponse,
32
+ IDeleteBranchesResponse,
29
33
  IFavouriteResponse,
30
34
  ILaunchTerminalResponse,
31
35
  ICleanupResponse,
@@ -928,6 +932,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
928
932
  row.className = 'jp-ClaudeSessionsPanel-row';
929
933
  row.title = this._buildRowTooltip(session);
930
934
 
935
+ // Age emphasis: active within the last minute reads bright, idle for
936
+ // over a week dims; the state decays/promotes on the next refresh.
937
+ if (session.file_mtime) {
938
+ const age = Date.now() - session.file_mtime;
939
+ if (age < 60_000) {
940
+ row.classList.add('jp-mod-recentlyActive');
941
+ } else if (age > 7 * 86_400_000) {
942
+ row.classList.add('jp-mod-stale');
943
+ }
944
+ }
945
+
931
946
  const removing = this._removingPaths.has(session.encoded_path);
932
947
  if (removing) {
933
948
  row.classList.add('jp-mod-busy');
@@ -951,22 +966,28 @@ export class ClaudeCodeSessionsWidget extends Widget {
951
966
 
952
967
  const name = document.createElement('span');
953
968
  name.className = 'jp-ClaudeSessionsPanel-name';
954
- // Conversation count in brackets - only when the project has branches.
955
- name.textContent =
956
- session.extra_sessions > 0
957
- ? `${this._lookupName(session)} (${session.extra_sessions + 1})`
958
- : this._lookupName(session);
959
- row.appendChild(name);
960
-
961
- if (session.file_mtime) {
962
- const time = document.createElement('span');
963
- time.className = 'jp-ClaudeSessionsPanel-rowTime';
964
- time.textContent = this._formatRelativeTime(session.file_mtime);
965
- row.appendChild(time);
969
+ name.textContent = this._lookupName(session);
970
+ // Branch icon + total conversation count - only when the project has
971
+ // branches. Lives inside the name span so it hugs the label text
972
+ // instead of being flexed to the row's right edge.
973
+ if (session.extra_sessions > 0) {
974
+ const badge = document.createElement('span');
975
+ badge.className = 'jp-ClaudeSessionsPanel-branchBadge';
976
+ const icon = document.createElement('span');
977
+ icon.className = 'jp-ClaudeSessionsPanel-branchBadgeIcon';
978
+ branchIcon.element({ container: icon });
979
+ badge.appendChild(icon);
980
+ badge.appendChild(
981
+ document.createTextNode(String(session.extra_sessions + 1))
982
+ );
983
+ name.appendChild(badge);
966
984
  }
985
+ row.appendChild(name);
967
986
 
968
987
  // No star in the Favorites section - every row there is a favorite
969
988
  // by definition; stars are an indicator only useful in Recent/All.
989
+ // Star sits before the time so the fixed-width time column stays the
990
+ // rightmost alignment anchor across all rows.
970
991
  if (session.favourite && sectionKey !== 'favourites') {
971
992
  const star = document.createElement('span');
972
993
  star.className = 'jp-ClaudeSessionsPanel-favStar';
@@ -975,6 +996,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
975
996
  row.appendChild(star);
976
997
  }
977
998
 
999
+ if (session.file_mtime) {
1000
+ const time = document.createElement('span');
1001
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
1002
+ time.textContent = this._formatRelativeTime(session.file_mtime);
1003
+ row.appendChild(time);
1004
+ }
1005
+
978
1006
  row.addEventListener('click', () => {
979
1007
  if (removing) {
980
1008
  return;
@@ -1214,12 +1242,27 @@ export class ClaudeCodeSessionsWidget extends Widget {
1214
1242
  });
1215
1243
 
1216
1244
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1217
- label: () => `More... (${this._lastBranches.length} total)`,
1245
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1218
1246
  execute: () => {
1219
- void this._showBranchPopup(this._lastBranches);
1247
+ void this._showBranchPopup(
1248
+ this._lastBranches,
1249
+ this._lastBranchesCurrent
1250
+ );
1220
1251
  }
1221
1252
  });
1222
1253
 
1254
+ this._commands.addCommand('claude-code-sessions:branch-session', {
1255
+ label: 'Branch Session...',
1256
+ icon: branchIcon,
1257
+ execute: () => void this._branchSession(false)
1258
+ });
1259
+
1260
+ this._commands.addCommand('claude-code-sessions:branch-session-dangerous', {
1261
+ label: 'Branch Session (Skip Permissions)...',
1262
+ icon: shieldIcon,
1263
+ execute: () => void this._branchSession(true)
1264
+ });
1265
+
1223
1266
  this._commands.addCommand('claude-code-sessions:remove', {
1224
1267
  label: 'Remove from Claude',
1225
1268
  icon: removeIcon,
@@ -1257,7 +1300,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1257
1300
  // sessions/branches fetch.
1258
1301
  this._branchSubmenu = new Menu({ commands: this._commands });
1259
1302
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1260
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1303
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1261
1304
 
1262
1305
  this._contextMenu = new Menu({ commands: this._commands });
1263
1306
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1298,6 +1341,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1298
1341
  submenu: this._branchSubmenu
1299
1342
  });
1300
1343
  }
1344
+ this._contextMenu.addItem({
1345
+ command: 'claude-code-sessions:branch-session'
1346
+ });
1347
+ this._contextMenu.addItem({
1348
+ command: 'claude-code-sessions:branch-session-dangerous'
1349
+ });
1301
1350
  this._contextMenu.addItem({
1302
1351
  command: 'claude-code-sessions:cleanup-parallel'
1303
1352
  });
@@ -1321,9 +1370,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1321
1370
  { cache: 'no-store' }
1322
1371
  );
1323
1372
  this._lastBranches = data.branches;
1373
+ this._lastBranchesCurrent = data.current;
1324
1374
  this._branchSubmenu.clearItems();
1325
- // The submenu shows only the 5 most recent; the full list lives
1326
- // behind "More..." in a searchable popup.
1375
+ this._branchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
1376
+ // The submenu shows only the 5 most recent inline (fewest clicks
1377
+ // for often-used sessions); the full list plus management lives
1378
+ // behind the always-present "Manage Sessions..." popup.
1327
1379
  for (const b of data.branches.slice(0, 5)) {
1328
1380
  this._branchSubmenu.addItem({
1329
1381
  command: 'claude-code-sessions:switch-branch',
@@ -1333,12 +1385,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1333
1385
  }
1334
1386
  });
1335
1387
  }
1336
- if (data.branches.length > 5) {
1337
- this._branchSubmenu.addItem({ type: 'separator' });
1338
- this._branchSubmenu.addItem({
1339
- command: 'claude-code-sessions:switch-branch-more'
1340
- });
1341
- }
1388
+ this._branchSubmenu.addItem({ type: 'separator' });
1389
+ this._branchSubmenu.addItem({
1390
+ command: 'claude-code-sessions:switch-branch-more'
1391
+ });
1342
1392
  hasBranches = data.branches.length > 0;
1343
1393
  } catch {
1344
1394
  hasBranches = false;
@@ -1348,42 +1398,106 @@ export class ClaudeCodeSessionsWidget extends Widget {
1348
1398
  this._contextMenu.open(x, y);
1349
1399
  }
1350
1400
 
1351
- /** Popup with the project's full branch list - browse and filter when
1352
- * the list is too large for the submenu. Clicking an entry switches. */
1353
- private _showBranchPopup(branches: IBranch[]): void {
1401
+ /** Popup with the project's full branch list - browse, filter, switch
1402
+ * and manage. Clicking an entry switches while nothing is selected;
1403
+ * checkbox selection (one, many, or select-all) arms a two-step Delete
1404
+ * button that removes the chosen sessions. The current conversation is
1405
+ * shown first, badged and untouchable. */
1406
+ private _showBranchPopup(branches: IBranch[], current: string): void {
1407
+ // Local working copy so deletions can refresh the list in place.
1408
+ let items = [...branches];
1409
+ const selected = new Set<string>();
1410
+ let confirmArmed = false;
1411
+
1354
1412
  const body = document.createElement('div');
1355
1413
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1356
1414
 
1357
1415
  const search = document.createElement('input');
1358
1416
  search.type = 'search';
1359
- search.placeholder = 'Filter branches...';
1417
+ search.placeholder = 'Filter sessions...';
1360
1418
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1361
1419
  body.appendChild(search);
1362
1420
 
1421
+ const selectAllBar = document.createElement('label');
1422
+ selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1423
+ const selectAll = document.createElement('input');
1424
+ selectAll.type = 'checkbox';
1425
+ selectAllBar.appendChild(selectAll);
1426
+ selectAllBar.appendChild(document.createTextNode('Select all'));
1427
+ body.appendChild(selectAllBar);
1428
+
1363
1429
  const list = document.createElement('div');
1364
1430
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1365
1431
  body.appendChild(list);
1366
1432
 
1433
+ const footer = document.createElement('div');
1434
+ footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1435
+ const deleteBtn = document.createElement('button');
1436
+ deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1437
+ footer.appendChild(deleteBtn);
1438
+ body.appendChild(footer);
1439
+
1367
1440
  const bodyWidget = new Widget({ node: body });
1368
1441
  const dialog = new Dialog({
1369
- title: 'Switch Conversation Branch',
1442
+ title: 'Switch and Manage Sessions',
1370
1443
  body: bodyWidget,
1371
1444
  buttons: [Dialog.cancelButton()]
1372
1445
  });
1373
1446
 
1374
- const render = () => {
1447
+ const visibleMatches = (): IBranch[] => {
1375
1448
  const needle = search.value.trim().toLowerCase();
1376
- list.replaceChildren();
1377
- const matches = branches.filter(
1449
+ return items.filter(
1378
1450
  b =>
1379
1451
  !needle ||
1380
1452
  b.label.toLowerCase().includes(needle) ||
1381
1453
  b.session_id.toLowerCase().includes(needle)
1382
1454
  );
1455
+ };
1456
+
1457
+ // Any selection change disarms a pending confirm.
1458
+ const updateControls = () => {
1459
+ confirmArmed = false;
1460
+ deleteBtn.disabled = selected.size === 0;
1461
+ deleteBtn.textContent = `Delete (${selected.size})`;
1462
+ deleteBtn.classList.remove('jp-mod-confirm');
1463
+ const visible = visibleMatches();
1464
+ const visibleSelected = visible.filter(b =>
1465
+ selected.has(b.session_id)
1466
+ ).length;
1467
+ selectAll.checked =
1468
+ visible.length > 0 && visibleSelected === visible.length;
1469
+ selectAll.indeterminate =
1470
+ visibleSelected > 0 && visibleSelected < visible.length;
1471
+ };
1472
+
1473
+ const render = () => {
1474
+ list.replaceChildren();
1475
+
1476
+ // The current conversation leads the list - badged, unselectable,
1477
+ // undeletable; only the extras below it are manageable.
1478
+ const currentRow = document.createElement('div');
1479
+ currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1480
+ currentRow.title = `Session id: ${current}`;
1481
+ const currentLabel = document.createElement('span');
1482
+ currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1483
+ const currentName = this._activeSession
1484
+ ? this._lookupName(this._activeSession)
1485
+ : current.slice(0, 8);
1486
+ currentLabel.textContent = `${currentName} (${current.slice(0, 8)})`;
1487
+ currentRow.appendChild(currentLabel);
1488
+ const badge = document.createElement('span');
1489
+ badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1490
+ badge.textContent = 'current';
1491
+ currentRow.appendChild(badge);
1492
+ list.appendChild(currentRow);
1493
+
1494
+ const matches = visibleMatches();
1383
1495
  if (matches.length === 0) {
1384
1496
  const empty = document.createElement('div');
1385
1497
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1386
- empty.textContent = 'No matching branches.';
1498
+ empty.textContent = items.length
1499
+ ? 'No matching sessions.'
1500
+ : 'No other conversations.';
1387
1501
  list.appendChild(empty);
1388
1502
  return;
1389
1503
  }
@@ -1392,6 +1506,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1392
1506
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1393
1507
  row.title = `Session id: ${b.session_id}`;
1394
1508
 
1509
+ const check = document.createElement('input');
1510
+ check.type = 'checkbox';
1511
+ check.checked = selected.has(b.session_id);
1512
+ // The checkbox is its own click zone - ticking must not switch.
1513
+ check.addEventListener('click', e => {
1514
+ e.stopPropagation();
1515
+ if (check.checked) {
1516
+ selected.add(b.session_id);
1517
+ } else {
1518
+ selected.delete(b.session_id);
1519
+ }
1520
+ updateControls();
1521
+ });
1522
+ row.appendChild(check);
1523
+
1395
1524
  const label = document.createElement('span');
1396
1525
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1397
1526
  label.textContent = this._branchDisplayName(b);
@@ -1403,19 +1532,201 @@ export class ClaudeCodeSessionsWidget extends Widget {
1403
1532
  row.appendChild(time);
1404
1533
 
1405
1534
  row.addEventListener('click', () => {
1535
+ // Selection mode: while anything is ticked, row clicks toggle
1536
+ // selection - no accidental switch mid-selection.
1537
+ if (selected.size > 0) {
1538
+ if (selected.has(b.session_id)) {
1539
+ selected.delete(b.session_id);
1540
+ } else {
1541
+ selected.add(b.session_id);
1542
+ }
1543
+ check.checked = selected.has(b.session_id);
1544
+ updateControls();
1545
+ return;
1546
+ }
1406
1547
  dialog.dispose();
1407
1548
  void this._switchBranch(b.session_id);
1408
1549
  });
1409
1550
  list.appendChild(row);
1410
1551
  }
1411
1552
  };
1412
- search.addEventListener('input', render);
1553
+
1554
+ selectAll.addEventListener('change', () => {
1555
+ // Select-all acts on the visible (filtered) rows only.
1556
+ const visible = visibleMatches();
1557
+ if (selectAll.checked) {
1558
+ visible.forEach(b => selected.add(b.session_id));
1559
+ } else {
1560
+ visible.forEach(b => selected.delete(b.session_id));
1561
+ }
1562
+ render();
1563
+ updateControls();
1564
+ });
1565
+
1566
+ deleteBtn.addEventListener('click', () => {
1567
+ if (selected.size === 0) {
1568
+ return;
1569
+ }
1570
+ if (!confirmArmed) {
1571
+ // Two-step delete: first click arms, second click executes.
1572
+ confirmArmed = true;
1573
+ deleteBtn.textContent = `Confirm delete (${selected.size})`;
1574
+ deleteBtn.classList.add('jp-mod-confirm');
1575
+ return;
1576
+ }
1577
+ void this._deleteBranches([...selected]).then(deleted => {
1578
+ if (deleted === null) {
1579
+ return;
1580
+ }
1581
+ items = items.filter(b => !selected.has(b.session_id));
1582
+ selected.clear();
1583
+ this._lastBranches = items;
1584
+ render();
1585
+ updateControls();
1586
+ });
1587
+ });
1588
+
1589
+ search.addEventListener('input', () => {
1590
+ render();
1591
+ updateControls();
1592
+ });
1413
1593
  render();
1594
+ updateControls();
1414
1595
 
1415
1596
  void dialog.launch();
1416
1597
  search.focus();
1417
1598
  }
1418
1599
 
1600
+ /** Delete the given branch sessions of the active row's project.
1601
+ * Returns the removed count, or null on failure (after notifying).
1602
+ * Always resyncs the panel so the row's conversation count drops. */
1603
+ private async _deleteBranches(sessionIds: string[]): Promise<number | null> {
1604
+ const session = this._activeSession;
1605
+ if (!session) {
1606
+ return null;
1607
+ }
1608
+ try {
1609
+ const result = await requestAPI<IDeleteBranchesResponse>(
1610
+ 'sessions/delete-branches',
1611
+ this._serverSettings,
1612
+ {
1613
+ method: 'POST',
1614
+ body: JSON.stringify({
1615
+ encoded_path: session.encoded_path,
1616
+ session_ids: sessionIds
1617
+ })
1618
+ }
1619
+ );
1620
+ return result.removed_count;
1621
+ } catch (err) {
1622
+ Notification.error(`Delete failed: ${String(err)}`, {
1623
+ autoClose: 4000
1624
+ });
1625
+ return null;
1626
+ } finally {
1627
+ await this._fetch();
1628
+ }
1629
+ }
1630
+
1631
+ /** Fork the active row's current conversation into a new named branch.
1632
+ *
1633
+ * Asks for a name, then launches a terminal running
1634
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
1635
+ * the uuid is generated here so the forked JSONL is known up front. Once
1636
+ * claude materialises the file (polled via sessions/set-title) the chosen
1637
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
1638
+ * so the recency resolution makes it the row's current conversation
1639
+ * without an explicit switch.
1640
+ */
1641
+ private async _branchSession(forceDangerous: boolean): Promise<void> {
1642
+ const session = this._activeSession;
1643
+ if (!session) {
1644
+ return;
1645
+ }
1646
+ const named = await InputDialog.getText({
1647
+ title: 'Branch Session',
1648
+ label: 'Name for the new session',
1649
+ placeholder: this._lookupName(session)
1650
+ });
1651
+ if (!named.button.accept || !named.value || !named.value.trim()) {
1652
+ return;
1653
+ }
1654
+ const title = named.value.trim();
1655
+ const forkId = UUID.uuid4();
1656
+ const spinner = this._showLaunchSpinner();
1657
+ try {
1658
+ const launched = await requestAPI<ILaunchTerminalResponse>(
1659
+ 'launch-terminal',
1660
+ this._serverSettings,
1661
+ {
1662
+ method: 'POST',
1663
+ body: JSON.stringify({
1664
+ project_path: session.project_path,
1665
+ session_id: session.session_id,
1666
+ fork_session_id: forkId,
1667
+ dangerously_skip_permissions:
1668
+ forceDangerous || this._dangerouslySkip
1669
+ })
1670
+ }
1671
+ );
1672
+ const widget: any = await this._app.commands.execute('terminal:open', {
1673
+ name: launched.terminal_name
1674
+ });
1675
+ if (widget?.id) {
1676
+ this._terminalsByPath.set(session.project_path, widget);
1677
+ this._wireTerminalDisposal(session.project_path, widget);
1678
+ this._focusTerminal(widget);
1679
+ }
1680
+ } catch (err) {
1681
+ this._showError(err);
1682
+ return;
1683
+ } finally {
1684
+ spinner.dispose();
1685
+ }
1686
+ // Stamp the name in the background once the forked JSONL appears -
1687
+ // claude writes it on its first record, typically within seconds.
1688
+ void this._stampForkTitle(session.encoded_path, forkId, title);
1689
+ }
1690
+
1691
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
1692
+ * does not), then refresh so the row shows the named fork as current. */
1693
+ private async _stampForkTitle(
1694
+ encodedPath: string,
1695
+ sessionId: string,
1696
+ title: string
1697
+ ): Promise<void> {
1698
+ for (let attempt = 0; attempt < 30; attempt++) {
1699
+ try {
1700
+ await requestAPI<{ ok: boolean }>(
1701
+ 'sessions/set-title',
1702
+ this._serverSettings,
1703
+ {
1704
+ method: 'POST',
1705
+ body: JSON.stringify({
1706
+ encoded_path: encodedPath,
1707
+ session_id: sessionId,
1708
+ title
1709
+ })
1710
+ }
1711
+ );
1712
+ await this._fetch();
1713
+ return;
1714
+ } catch (err) {
1715
+ const notYet =
1716
+ err instanceof ServerConnection.ResponseError &&
1717
+ err.response.status === 404;
1718
+ if (!notYet) {
1719
+ break;
1720
+ }
1721
+ await new Promise(resolve => setTimeout(resolve, 1000));
1722
+ }
1723
+ }
1724
+ Notification.warning(
1725
+ `Branched session started, but the name "${title}" could not be applied - use /rename in the session.`,
1726
+ { autoClose: 6000 }
1727
+ );
1728
+ }
1729
+
1419
1730
  /** Switch the active row's project to another conversation branch.
1420
1731
  * The backend touches the branch JSONL's mtime; a refresh then shows
1421
1732
  * the selected conversation as the row's current one. */
@@ -1493,6 +1804,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1493
1804
  private _contextMenu!: Menu;
1494
1805
  private _branchSubmenu!: Menu;
1495
1806
  private _lastBranches: IBranch[] = [];
1807
+ private _lastBranchesCurrent = '';
1496
1808
  private _newSessionMenu!: Menu;
1497
1809
  private _activeSession: ISession | null = null;
1498
1810
  private _activeRowEl: HTMLElement | null = null;