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/README.md +15 -2
- package/lib/icons.d.ts +1 -0
- package/lib/icons.js +9 -0
- package/lib/types.d.ts +7 -0
- package/lib/widget.d.ts +24 -2
- package/lib/widget.js +307 -34
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +166 -5
- package/src/icons.ts +11 -0
- package/src/types.ts +9 -0
- package/src/widget.ts +345 -33
- package/style/base.css +127 -5
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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: () => `
|
|
1245
|
+
label: () => `Manage Sessions... (${this._lastBranches.length})`,
|
|
1218
1246
|
execute: () => {
|
|
1219
|
-
void this._showBranchPopup(
|
|
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
|
|
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
|
-
|
|
1326
|
-
//
|
|
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
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
|
1352
|
-
*
|
|
1353
|
-
|
|
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
|
|
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
|
|
1442
|
+
title: 'Switch and Manage Sessions',
|
|
1370
1443
|
body: bodyWidget,
|
|
1371
1444
|
buttons: [Dialog.cancelButton()]
|
|
1372
1445
|
});
|
|
1373
1446
|
|
|
1374
|
-
const
|
|
1447
|
+
const visibleMatches = (): IBranch[] => {
|
|
1375
1448
|
const needle = search.value.trim().toLowerCase();
|
|
1376
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|