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/README.md +2 -1
- package/lib/widget.d.ts +34 -0
- package/lib/widget.js +357 -49
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +181 -5
- package/src/widget.ts +394 -51
- package/style/base.css +114 -12
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
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
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(
|
|
463
|
+
const promise = this._doResumeInTerminal(session, forceDangerous, strict).finally(() => {
|
|
464
|
+
this._pendingByPath.delete(key);
|
|
453
465
|
});
|
|
454
|
-
this._pendingByPath.set(
|
|
466
|
+
this._pendingByPath.set(key, promise);
|
|
455
467
|
return promise;
|
|
456
468
|
}
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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.
|
|
1551
|
-
//
|
|
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.
|
|
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",
|