jupyterlab_claude_code_extension 1.2.24 → 1.2.25
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 +1 -1
- package/lib/widget.js +147 -21
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +46 -5
- package/src/widget.ts +152 -20
- package/style/base.css +95 -12
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
31
31
|
- **Favorites** - star projects you keep coming back to via the right-click menu
|
|
32
32
|
- **Remove** - drop a project's Claude history from the panel via the right-click menu; a confirmation dialog names the project and warns it removes the whole project and all its conversations before anything is touched, then the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
|
|
33
33
|
- **Clean up parallel sessions** - when a project has accumulated extra sessions beyond the main one, a right-click menu item (showing the count in brackets) removes them all, keeping only the main session; a confirmation dialog naming the project and the count appears first, and removed files honour the same trash setting
|
|
34
|
-
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup over the full list
|
|
34
|
+
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup - a scrollable table over the full list with the current conversation pinned at the top and accented. Conversations can also be deleted here - select one, many, or all via checkboxes, then click Delete; they move to trash immediately (no confirmation, with an "N moved to trash" message; removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
|
|
35
35
|
- **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
|
|
36
36
|
- **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
|
|
37
37
|
- **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
|
package/lib/widget.js
CHANGED
|
@@ -1292,6 +1292,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1292
1292
|
// Local working copy so deletions can refresh the list in place.
|
|
1293
1293
|
let items = [...branches];
|
|
1294
1294
|
const selected = new Set();
|
|
1295
|
+
let deleting = false; // guards the async delete against double-invocation
|
|
1295
1296
|
const body = document.createElement('div');
|
|
1296
1297
|
body.className = 'jp-ClaudeSessionsPanel-branchPopup';
|
|
1297
1298
|
const search = document.createElement('input');
|
|
@@ -1299,25 +1300,49 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1299
1300
|
search.placeholder = 'Filter sessions...';
|
|
1300
1301
|
search.className = 'jp-ClaudeSessionsPanel-branchSearch';
|
|
1301
1302
|
body.appendChild(search);
|
|
1303
|
+
// Table header strip: select-all on the left, conversation count right.
|
|
1304
|
+
const header = document.createElement('div');
|
|
1305
|
+
header.className = 'jp-ClaudeSessionsPanel-branchHeader';
|
|
1302
1306
|
const selectAllBar = document.createElement('label');
|
|
1303
1307
|
selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
|
|
1304
1308
|
const selectAll = document.createElement('input');
|
|
1305
1309
|
selectAll.type = 'checkbox';
|
|
1306
1310
|
selectAllBar.appendChild(selectAll);
|
|
1307
1311
|
selectAllBar.appendChild(document.createTextNode('Select all'));
|
|
1308
|
-
|
|
1312
|
+
header.appendChild(selectAllBar);
|
|
1313
|
+
const countEl = document.createElement('span');
|
|
1314
|
+
countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
|
|
1315
|
+
header.appendChild(countEl);
|
|
1316
|
+
body.appendChild(header);
|
|
1309
1317
|
const list = document.createElement('div');
|
|
1310
1318
|
list.className = 'jp-ClaudeSessionsPanel-branchList';
|
|
1319
|
+
// role=group makes the aria-label apply to the conversation list region.
|
|
1320
|
+
list.setAttribute('role', 'group');
|
|
1321
|
+
list.setAttribute('aria-label', 'Conversations');
|
|
1311
1322
|
body.appendChild(list);
|
|
1312
1323
|
const footer = document.createElement('div');
|
|
1313
1324
|
footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
|
|
1325
|
+
// Plain visual counter (selection or last action). The screen-reader
|
|
1326
|
+
// announcement lives in a separate live region (srLive) so per-checkbox
|
|
1327
|
+
// ticks are not announced on top of the native checkbox.
|
|
1328
|
+
const selCount = document.createElement('span');
|
|
1329
|
+
selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
|
|
1330
|
+
footer.appendChild(selCount);
|
|
1314
1331
|
const deleteBtn = document.createElement('button');
|
|
1332
|
+
deleteBtn.type = 'button';
|
|
1315
1333
|
deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
|
|
1334
|
+
deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
|
|
1316
1335
|
footer.appendChild(deleteBtn);
|
|
1317
1336
|
body.appendChild(footer);
|
|
1337
|
+
// Visually-hidden polite live region, announced only on delete.
|
|
1338
|
+
const srLive = document.createElement('div');
|
|
1339
|
+
srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
|
|
1340
|
+
srLive.setAttribute('role', 'status');
|
|
1341
|
+
srLive.setAttribute('aria-live', 'polite');
|
|
1342
|
+
body.appendChild(srLive);
|
|
1318
1343
|
const bodyWidget = new Widget({ node: body });
|
|
1319
1344
|
const dialog = new Dialog({
|
|
1320
|
-
title: '
|
|
1345
|
+
title: 'Manage Sessions',
|
|
1321
1346
|
body: bodyWidget,
|
|
1322
1347
|
buttons: [Dialog.cancelButton()]
|
|
1323
1348
|
});
|
|
@@ -1328,9 +1353,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1328
1353
|
b.session_id.toLowerCase().includes(needle));
|
|
1329
1354
|
};
|
|
1330
1355
|
const updateControls = () => {
|
|
1331
|
-
deleteBtn.disabled = selected.size === 0;
|
|
1332
|
-
deleteBtn.textContent =
|
|
1356
|
+
deleteBtn.disabled = deleting || selected.size === 0;
|
|
1357
|
+
deleteBtn.textContent = deleting
|
|
1358
|
+
? 'Deleting...'
|
|
1359
|
+
: `Delete (${selected.size})`;
|
|
1360
|
+
selCount.textContent = selected.size ? `${selected.size} selected` : '';
|
|
1361
|
+
selCount.classList.remove('jp-mod-deleted');
|
|
1333
1362
|
const visible = visibleMatches();
|
|
1363
|
+
const total = items.length + 1; // + the pinned current conversation
|
|
1364
|
+
const shown = visible.length + 1; // the current row is always shown
|
|
1365
|
+
countEl.textContent = search.value.trim()
|
|
1366
|
+
? `${shown} of ${total}`
|
|
1367
|
+
: `${total} conversation${total === 1 ? '' : 's'}`;
|
|
1334
1368
|
const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
|
|
1335
1369
|
selectAll.checked =
|
|
1336
1370
|
visible.length > 0 && visibleSelected === visible.length;
|
|
@@ -1344,6 +1378,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1344
1378
|
const currentRow = document.createElement('div');
|
|
1345
1379
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1346
1380
|
currentRow.title = `Session id: ${current}`;
|
|
1381
|
+
// Empty select cell keeps the name column aligned with branch rows.
|
|
1382
|
+
const currentSelect = document.createElement('span');
|
|
1383
|
+
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
1384
|
+
currentRow.appendChild(currentSelect);
|
|
1347
1385
|
const currentLabel = document.createElement('span');
|
|
1348
1386
|
currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1349
1387
|
const currentName = this._activeSession
|
|
@@ -1371,12 +1409,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1371
1409
|
const row = document.createElement('div');
|
|
1372
1410
|
row.className = 'jp-ClaudeSessionsPanel-branchRow';
|
|
1373
1411
|
row.title = `Session id: ${b.session_id}`;
|
|
1412
|
+
const selectCell = document.createElement('span');
|
|
1413
|
+
selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
1374
1414
|
const check = document.createElement('input');
|
|
1375
1415
|
check.type = 'checkbox';
|
|
1376
1416
|
check.checked = selected.has(b.session_id);
|
|
1417
|
+
check.setAttribute('aria-label', `Select ${this._branchDisplayName(b)}`);
|
|
1377
1418
|
// The checkbox is its own click zone - ticking must not switch.
|
|
1378
1419
|
check.addEventListener('click', e => {
|
|
1379
1420
|
e.stopPropagation();
|
|
1421
|
+
if (deleting) {
|
|
1422
|
+
// Keyboard Space isn't blocked by the busy scrim's pointer-events;
|
|
1423
|
+
// re-sync the native toggle on the next render.
|
|
1424
|
+
check.checked = selected.has(b.session_id);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1380
1427
|
if (check.checked) {
|
|
1381
1428
|
selected.add(b.session_id);
|
|
1382
1429
|
}
|
|
@@ -1385,7 +1432,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1385
1432
|
}
|
|
1386
1433
|
updateControls();
|
|
1387
1434
|
});
|
|
1388
|
-
|
|
1435
|
+
selectCell.appendChild(check);
|
|
1436
|
+
// The whole cell is a select target (>=24px), not just the checkbox;
|
|
1437
|
+
// clicking the padding toggles via the checkbox's own handler.
|
|
1438
|
+
selectCell.addEventListener('click', e => {
|
|
1439
|
+
if (e.target !== check) {
|
|
1440
|
+
e.stopPropagation();
|
|
1441
|
+
check.click();
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
row.appendChild(selectCell);
|
|
1389
1445
|
const label = document.createElement('span');
|
|
1390
1446
|
label.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1391
1447
|
label.textContent = this._branchDisplayName(b);
|
|
@@ -1416,6 +1472,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1416
1472
|
}
|
|
1417
1473
|
};
|
|
1418
1474
|
selectAll.addEventListener('change', () => {
|
|
1475
|
+
if (deleting) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1419
1478
|
// Select-all acts on the visible (filtered) rows only.
|
|
1420
1479
|
const visible = visibleMatches();
|
|
1421
1480
|
if (selectAll.checked) {
|
|
@@ -1427,33 +1486,100 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1427
1486
|
render();
|
|
1428
1487
|
updateControls();
|
|
1429
1488
|
});
|
|
1489
|
+
// Busy-lock the whole body (button + list) during the async delete so a
|
|
1490
|
+
// slow backend cannot be double-clicked into deleting the same set twice
|
|
1491
|
+
// and a mid-flight selection cannot be silently discarded.
|
|
1492
|
+
const setDeleting = (on) => {
|
|
1493
|
+
deleting = on;
|
|
1494
|
+
// Scrim the whole popup body (search, select-all, list) so nothing can
|
|
1495
|
+
// be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
|
|
1496
|
+
// sits outside the body and stays usable. aria-busy goes on the live
|
|
1497
|
+
// list region, not the (disabled, unannounced) button.
|
|
1498
|
+
body.classList.toggle('jp-mod-busy', on);
|
|
1499
|
+
if (on) {
|
|
1500
|
+
list.setAttribute('aria-busy', 'true');
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
list.removeAttribute('aria-busy');
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1430
1506
|
deleteBtn.addEventListener('click', () => {
|
|
1431
|
-
if (selected.size === 0) {
|
|
1507
|
+
if (deleting || selected.size === 0) {
|
|
1432
1508
|
return;
|
|
1433
1509
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1510
|
+
// No confirmation here: deletions move to trash (recoverable), and a
|
|
1511
|
+
// second Lumino dialog stacked on this popup renders detached. The only
|
|
1512
|
+
// confirmed destructive actions are Clean Up Parallel Sessions and
|
|
1513
|
+
// Remove from Claude (whole-project / bulk). Feedback is given instead
|
|
1514
|
+
// of a prompt: a live "N moved to trash" status (a failure still toasts
|
|
1515
|
+
// via _deleteBranches).
|
|
1516
|
+
setDeleting(true);
|
|
1517
|
+
updateControls();
|
|
1518
|
+
void this._deleteBranches([...selected])
|
|
1519
|
+
.then(async (deleted) => {
|
|
1520
|
+
if (deleted === null) {
|
|
1521
|
+
setDeleting(false);
|
|
1522
|
+
updateControls(); // re-enable; selection unchanged
|
|
1442
1523
|
return;
|
|
1443
1524
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1525
|
+
// Re-sync from disk truth, not an optimistic splice: a branch the
|
|
1526
|
+
// backend skipped (it became the resolved-current, or was already
|
|
1527
|
+
// gone) must not vanish from the list while it still exists.
|
|
1528
|
+
const session = this._activeSession;
|
|
1529
|
+
try {
|
|
1530
|
+
if (!session) {
|
|
1531
|
+
throw new Error('no active session');
|
|
1447
1532
|
}
|
|
1533
|
+
const fresh = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
|
|
1534
|
+
items = fresh.branches;
|
|
1535
|
+
}
|
|
1536
|
+
catch (_a) {
|
|
1537
|
+
// Refetch failed (rare): optimistic removal. The announced count
|
|
1538
|
+
// may then differ from rows removed if the backend skipped one;
|
|
1539
|
+
// the panel's own _fetch (in _deleteBranches) reconciles on disk.
|
|
1448
1540
|
items = items.filter(b => !selected.has(b.session_id));
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1541
|
+
}
|
|
1542
|
+
// The user may have closed the popup during the refetch await - don't
|
|
1543
|
+
// touch shared state or a detached DOM in that case.
|
|
1544
|
+
if (!bodyWidget.isAttached) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
setDeleting(false);
|
|
1548
|
+
selected.clear();
|
|
1549
|
+
this._lastBranches = items;
|
|
1550
|
+
render();
|
|
1551
|
+
updateControls();
|
|
1552
|
+
// Feedback (no prompt): a perceivable counter + a polite SR
|
|
1553
|
+
// announcement of the actual number the backend moved to trash.
|
|
1554
|
+
const moved = `${deleted} moved to trash`;
|
|
1555
|
+
selCount.textContent = moved;
|
|
1556
|
+
selCount.classList.add('jp-mod-deleted');
|
|
1557
|
+
// Clear then re-set on the next tick so an identical count (two
|
|
1558
|
+
// single deletes in a row) is still re-announced by aria-live.
|
|
1559
|
+
srLive.textContent = '';
|
|
1560
|
+
window.setTimeout(() => {
|
|
1561
|
+
srLive.textContent = moved;
|
|
1562
|
+
}, 60);
|
|
1563
|
+
// Keep focus inside the dialog (render() destroyed the focused row).
|
|
1564
|
+
// Focus the select-all checkbox (a real control with a reliable
|
|
1565
|
+
// keyboard ring), unless the user is still typing in the search box.
|
|
1566
|
+
if (document.activeElement !== search) {
|
|
1567
|
+
selectAll.focus();
|
|
1568
|
+
}
|
|
1569
|
+
})
|
|
1570
|
+
.catch(() => {
|
|
1571
|
+
// Defensive: never leave the button stuck disabled if the chain
|
|
1572
|
+
// rejects unexpectedly.
|
|
1573
|
+
if (bodyWidget.isAttached) {
|
|
1574
|
+
setDeleting(false);
|
|
1452
1575
|
updateControls();
|
|
1453
|
-
}
|
|
1576
|
+
}
|
|
1454
1577
|
});
|
|
1455
1578
|
});
|
|
1456
1579
|
search.addEventListener('input', () => {
|
|
1580
|
+
if (deleting) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1457
1583
|
render();
|
|
1458
1584
|
updateControls();
|
|
1459
1585
|
});
|
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.25",
|
|
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",
|
|
@@ -240,15 +240,56 @@ describe('launch spinner dismiss contract', () => {
|
|
|
240
240
|
);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
-
it('delete
|
|
243
|
+
it('delete removes selected sessions immediately, no confirmation dialog', () => {
|
|
244
244
|
const popup = (widgetSrc.match(
|
|
245
245
|
/private _showBranchPopup[\s\S]*?\n \}/
|
|
246
246
|
) ?? [''])[0];
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
expect(popup).toMatch(
|
|
250
|
-
|
|
247
|
+
// The per-row delete must NOT stack a second Lumino dialog on the popup
|
|
248
|
+
// (it renders detached) - confirmation is reserved for cleanup / remove.
|
|
249
|
+
expect(popup).not.toMatch(/title: 'Delete Sessions'/);
|
|
250
|
+
const delHandler = (popup.match(
|
|
251
|
+
/deleteBtn\.addEventListener\('click'[\s\S]*?\n \}\);/
|
|
252
|
+
) ?? [''])[0];
|
|
253
|
+
expect(delHandler).toMatch(/this\._deleteBranches\(\[\.\.\.selected\]\)/);
|
|
254
|
+
expect(delHandler).not.toMatch(/showDialog/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('manage sessions popup is a table with an accented pinned current row', () => {
|
|
258
|
+
const popup = (widgetSrc.match(
|
|
259
|
+
/private _showBranchPopup[\s\S]*?\n \}/
|
|
260
|
+
) ?? [''])[0];
|
|
261
|
+
expect(popup).toMatch(/title: 'Manage Sessions'/);
|
|
262
|
+
expect(popup).toMatch(/branchHeader/);
|
|
263
|
+
expect(popup).toMatch(/branchHeaderCount/);
|
|
264
|
+
expect(popup).toMatch(/branchSelectCell/);
|
|
265
|
+
const css: string = fs.readFileSync(
|
|
266
|
+
path.join(__dirname, '..', '..', 'style', 'base.css'),
|
|
267
|
+
'utf-8'
|
|
251
268
|
);
|
|
269
|
+
const cur = (css.match(
|
|
270
|
+
/\.jp-ClaudeSessionsPanel-branchRow\.jp-mod-current \{[\s\S]*?\}/
|
|
271
|
+
) ?? [''])[0];
|
|
272
|
+
expect(cur).toMatch(/position: sticky/);
|
|
273
|
+
expect(cur).toMatch(/border-left: 3px solid var\(--jp-brand-color1\)/);
|
|
274
|
+
const del = (css.match(
|
|
275
|
+
/\.jp-ClaudeSessionsPanel-branchDelete \{[\s\S]*?\}/
|
|
276
|
+
) ?? [''])[0];
|
|
277
|
+
expect(del).toMatch(/--jp-error-color1/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('popup delete is accessible and gives feedback without a prompt', () => {
|
|
281
|
+
const popup = (widgetSrc.match(
|
|
282
|
+
/private _showBranchPopup[\s\S]*?\n \}/
|
|
283
|
+
) ?? [''])[0];
|
|
284
|
+
// per-checkbox accessible name + polite live region for the result
|
|
285
|
+
expect(popup).toMatch(/`Select \$\{this\._branchDisplayName/);
|
|
286
|
+
expect(popup).toMatch(/aria-live', 'polite'/);
|
|
287
|
+
// the delete handler announces "N moved to trash" and keeps focus
|
|
288
|
+
const delHandler = (popup.match(
|
|
289
|
+
/deleteBtn\.addEventListener\('click'[\s\S]*?\n \}\);/
|
|
290
|
+
) ?? [''])[0];
|
|
291
|
+
expect(delHandler).toMatch(/moved to trash/);
|
|
292
|
+
expect(delHandler).toMatch(/selectAll\.focus\(\)/);
|
|
252
293
|
});
|
|
253
294
|
|
|
254
295
|
it('delete posts to delete-branches and resyncs the panel', () => {
|
package/src/widget.ts
CHANGED
|
@@ -1496,6 +1496,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1496
1496
|
// Local working copy so deletions can refresh the list in place.
|
|
1497
1497
|
let items = [...branches];
|
|
1498
1498
|
const selected = new Set<string>();
|
|
1499
|
+
let deleting = false; // guards the async delete against double-invocation
|
|
1499
1500
|
|
|
1500
1501
|
const body = document.createElement('div');
|
|
1501
1502
|
body.className = 'jp-ClaudeSessionsPanel-branchPopup';
|
|
@@ -1506,28 +1507,53 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1506
1507
|
search.className = 'jp-ClaudeSessionsPanel-branchSearch';
|
|
1507
1508
|
body.appendChild(search);
|
|
1508
1509
|
|
|
1510
|
+
// Table header strip: select-all on the left, conversation count right.
|
|
1511
|
+
const header = document.createElement('div');
|
|
1512
|
+
header.className = 'jp-ClaudeSessionsPanel-branchHeader';
|
|
1509
1513
|
const selectAllBar = document.createElement('label');
|
|
1510
1514
|
selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
|
|
1511
1515
|
const selectAll = document.createElement('input');
|
|
1512
1516
|
selectAll.type = 'checkbox';
|
|
1513
1517
|
selectAllBar.appendChild(selectAll);
|
|
1514
1518
|
selectAllBar.appendChild(document.createTextNode('Select all'));
|
|
1515
|
-
|
|
1519
|
+
header.appendChild(selectAllBar);
|
|
1520
|
+
const countEl = document.createElement('span');
|
|
1521
|
+
countEl.className = 'jp-ClaudeSessionsPanel-branchHeaderCount';
|
|
1522
|
+
header.appendChild(countEl);
|
|
1523
|
+
body.appendChild(header);
|
|
1516
1524
|
|
|
1517
1525
|
const list = document.createElement('div');
|
|
1518
1526
|
list.className = 'jp-ClaudeSessionsPanel-branchList';
|
|
1527
|
+
// role=group makes the aria-label apply to the conversation list region.
|
|
1528
|
+
list.setAttribute('role', 'group');
|
|
1529
|
+
list.setAttribute('aria-label', 'Conversations');
|
|
1519
1530
|
body.appendChild(list);
|
|
1520
1531
|
|
|
1521
1532
|
const footer = document.createElement('div');
|
|
1522
1533
|
footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
|
|
1534
|
+
// Plain visual counter (selection or last action). The screen-reader
|
|
1535
|
+
// announcement lives in a separate live region (srLive) so per-checkbox
|
|
1536
|
+
// ticks are not announced on top of the native checkbox.
|
|
1537
|
+
const selCount = document.createElement('span');
|
|
1538
|
+
selCount.className = 'jp-ClaudeSessionsPanel-branchSelCount';
|
|
1539
|
+
footer.appendChild(selCount);
|
|
1523
1540
|
const deleteBtn = document.createElement('button');
|
|
1541
|
+
deleteBtn.type = 'button';
|
|
1524
1542
|
deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
|
|
1543
|
+
deleteBtn.title = 'Deleted conversations move to the trash (recoverable)';
|
|
1525
1544
|
footer.appendChild(deleteBtn);
|
|
1526
1545
|
body.appendChild(footer);
|
|
1527
1546
|
|
|
1547
|
+
// Visually-hidden polite live region, announced only on delete.
|
|
1548
|
+
const srLive = document.createElement('div');
|
|
1549
|
+
srLive.className = 'jp-ClaudeSessionsPanel-srOnly';
|
|
1550
|
+
srLive.setAttribute('role', 'status');
|
|
1551
|
+
srLive.setAttribute('aria-live', 'polite');
|
|
1552
|
+
body.appendChild(srLive);
|
|
1553
|
+
|
|
1528
1554
|
const bodyWidget = new Widget({ node: body });
|
|
1529
1555
|
const dialog = new Dialog({
|
|
1530
|
-
title: '
|
|
1556
|
+
title: 'Manage Sessions',
|
|
1531
1557
|
body: bodyWidget,
|
|
1532
1558
|
buttons: [Dialog.cancelButton()]
|
|
1533
1559
|
});
|
|
@@ -1543,9 +1569,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1543
1569
|
};
|
|
1544
1570
|
|
|
1545
1571
|
const updateControls = () => {
|
|
1546
|
-
deleteBtn.disabled = selected.size === 0;
|
|
1547
|
-
deleteBtn.textContent =
|
|
1572
|
+
deleteBtn.disabled = deleting || selected.size === 0;
|
|
1573
|
+
deleteBtn.textContent = deleting
|
|
1574
|
+
? 'Deleting...'
|
|
1575
|
+
: `Delete (${selected.size})`;
|
|
1576
|
+
selCount.textContent = selected.size ? `${selected.size} selected` : '';
|
|
1577
|
+
selCount.classList.remove('jp-mod-deleted');
|
|
1548
1578
|
const visible = visibleMatches();
|
|
1579
|
+
const total = items.length + 1; // + the pinned current conversation
|
|
1580
|
+
const shown = visible.length + 1; // the current row is always shown
|
|
1581
|
+
countEl.textContent = search.value.trim()
|
|
1582
|
+
? `${shown} of ${total}`
|
|
1583
|
+
: `${total} conversation${total === 1 ? '' : 's'}`;
|
|
1549
1584
|
const visibleSelected = visible.filter(b =>
|
|
1550
1585
|
selected.has(b.session_id)
|
|
1551
1586
|
).length;
|
|
@@ -1563,6 +1598,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1563
1598
|
const currentRow = document.createElement('div');
|
|
1564
1599
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1565
1600
|
currentRow.title = `Session id: ${current}`;
|
|
1601
|
+
// Empty select cell keeps the name column aligned with branch rows.
|
|
1602
|
+
const currentSelect = document.createElement('span');
|
|
1603
|
+
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
1604
|
+
currentRow.appendChild(currentSelect);
|
|
1566
1605
|
const currentLabel = document.createElement('span');
|
|
1567
1606
|
currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1568
1607
|
const currentName = this._activeSession
|
|
@@ -1592,12 +1631,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1592
1631
|
row.className = 'jp-ClaudeSessionsPanel-branchRow';
|
|
1593
1632
|
row.title = `Session id: ${b.session_id}`;
|
|
1594
1633
|
|
|
1634
|
+
const selectCell = document.createElement('span');
|
|
1635
|
+
selectCell.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
1595
1636
|
const check = document.createElement('input');
|
|
1596
1637
|
check.type = 'checkbox';
|
|
1597
1638
|
check.checked = selected.has(b.session_id);
|
|
1639
|
+
check.setAttribute(
|
|
1640
|
+
'aria-label',
|
|
1641
|
+
`Select ${this._branchDisplayName(b)}`
|
|
1642
|
+
);
|
|
1598
1643
|
// The checkbox is its own click zone - ticking must not switch.
|
|
1599
1644
|
check.addEventListener('click', e => {
|
|
1600
1645
|
e.stopPropagation();
|
|
1646
|
+
if (deleting) {
|
|
1647
|
+
// Keyboard Space isn't blocked by the busy scrim's pointer-events;
|
|
1648
|
+
// re-sync the native toggle on the next render.
|
|
1649
|
+
check.checked = selected.has(b.session_id);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1601
1652
|
if (check.checked) {
|
|
1602
1653
|
selected.add(b.session_id);
|
|
1603
1654
|
} else {
|
|
@@ -1605,7 +1656,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1605
1656
|
}
|
|
1606
1657
|
updateControls();
|
|
1607
1658
|
});
|
|
1608
|
-
|
|
1659
|
+
selectCell.appendChild(check);
|
|
1660
|
+
// The whole cell is a select target (>=24px), not just the checkbox;
|
|
1661
|
+
// clicking the padding toggles via the checkbox's own handler.
|
|
1662
|
+
selectCell.addEventListener('click', e => {
|
|
1663
|
+
if (e.target !== check) {
|
|
1664
|
+
e.stopPropagation();
|
|
1665
|
+
check.click();
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
row.appendChild(selectCell);
|
|
1609
1669
|
|
|
1610
1670
|
const label = document.createElement('span');
|
|
1611
1671
|
label.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
@@ -1640,6 +1700,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1640
1700
|
};
|
|
1641
1701
|
|
|
1642
1702
|
selectAll.addEventListener('change', () => {
|
|
1703
|
+
if (deleting) {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1643
1706
|
// Select-all acts on the visible (filtered) rows only.
|
|
1644
1707
|
const visible = visibleMatches();
|
|
1645
1708
|
if (selectAll.checked) {
|
|
@@ -1651,35 +1714,104 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1651
1714
|
updateControls();
|
|
1652
1715
|
});
|
|
1653
1716
|
|
|
1717
|
+
// Busy-lock the whole body (button + list) during the async delete so a
|
|
1718
|
+
// slow backend cannot be double-clicked into deleting the same set twice
|
|
1719
|
+
// and a mid-flight selection cannot be silently discarded.
|
|
1720
|
+
const setDeleting = (on: boolean) => {
|
|
1721
|
+
deleting = on;
|
|
1722
|
+
// Scrim the whole popup body (search, select-all, list) so nothing can
|
|
1723
|
+
// be re-ticked or re-triggered mid-flight; the Dialog's Cancel button
|
|
1724
|
+
// sits outside the body and stays usable. aria-busy goes on the live
|
|
1725
|
+
// list region, not the (disabled, unannounced) button.
|
|
1726
|
+
body.classList.toggle('jp-mod-busy', on);
|
|
1727
|
+
if (on) {
|
|
1728
|
+
list.setAttribute('aria-busy', 'true');
|
|
1729
|
+
} else {
|
|
1730
|
+
list.removeAttribute('aria-busy');
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1654
1734
|
deleteBtn.addEventListener('click', () => {
|
|
1655
|
-
if (selected.size === 0) {
|
|
1735
|
+
if (deleting || selected.size === 0) {
|
|
1656
1736
|
return;
|
|
1657
1737
|
}
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
}
|
|
1669
|
-
void this._deleteBranches([...selected]).then(deleted => {
|
|
1738
|
+
// No confirmation here: deletions move to trash (recoverable), and a
|
|
1739
|
+
// second Lumino dialog stacked on this popup renders detached. The only
|
|
1740
|
+
// confirmed destructive actions are Clean Up Parallel Sessions and
|
|
1741
|
+
// Remove from Claude (whole-project / bulk). Feedback is given instead
|
|
1742
|
+
// of a prompt: a live "N moved to trash" status (a failure still toasts
|
|
1743
|
+
// via _deleteBranches).
|
|
1744
|
+
setDeleting(true);
|
|
1745
|
+
updateControls();
|
|
1746
|
+
void this._deleteBranches([...selected])
|
|
1747
|
+
.then(async deleted => {
|
|
1670
1748
|
if (deleted === null) {
|
|
1749
|
+
setDeleting(false);
|
|
1750
|
+
updateControls(); // re-enable; selection unchanged
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
// Re-sync from disk truth, not an optimistic splice: a branch the
|
|
1754
|
+
// backend skipped (it became the resolved-current, or was already
|
|
1755
|
+
// gone) must not vanish from the list while it still exists.
|
|
1756
|
+
const session = this._activeSession;
|
|
1757
|
+
try {
|
|
1758
|
+
if (!session) {
|
|
1759
|
+
throw new Error('no active session');
|
|
1760
|
+
}
|
|
1761
|
+
const fresh = await requestAPI<IBranchesResponse>(
|
|
1762
|
+
`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
|
|
1763
|
+
this._serverSettings,
|
|
1764
|
+
{ cache: 'no-store' }
|
|
1765
|
+
);
|
|
1766
|
+
items = fresh.branches;
|
|
1767
|
+
} catch {
|
|
1768
|
+
// Refetch failed (rare): optimistic removal. The announced count
|
|
1769
|
+
// may then differ from rows removed if the backend skipped one;
|
|
1770
|
+
// the panel's own _fetch (in _deleteBranches) reconciles on disk.
|
|
1771
|
+
items = items.filter(b => !selected.has(b.session_id));
|
|
1772
|
+
}
|
|
1773
|
+
// The user may have closed the popup during the refetch await - don't
|
|
1774
|
+
// touch shared state or a detached DOM in that case.
|
|
1775
|
+
if (!bodyWidget.isAttached) {
|
|
1671
1776
|
return;
|
|
1672
1777
|
}
|
|
1673
|
-
|
|
1778
|
+
setDeleting(false);
|
|
1674
1779
|
selected.clear();
|
|
1675
1780
|
this._lastBranches = items;
|
|
1676
1781
|
render();
|
|
1677
1782
|
updateControls();
|
|
1783
|
+
// Feedback (no prompt): a perceivable counter + a polite SR
|
|
1784
|
+
// announcement of the actual number the backend moved to trash.
|
|
1785
|
+
const moved = `${deleted} moved to trash`;
|
|
1786
|
+
selCount.textContent = moved;
|
|
1787
|
+
selCount.classList.add('jp-mod-deleted');
|
|
1788
|
+
// Clear then re-set on the next tick so an identical count (two
|
|
1789
|
+
// single deletes in a row) is still re-announced by aria-live.
|
|
1790
|
+
srLive.textContent = '';
|
|
1791
|
+
window.setTimeout(() => {
|
|
1792
|
+
srLive.textContent = moved;
|
|
1793
|
+
}, 60);
|
|
1794
|
+
// Keep focus inside the dialog (render() destroyed the focused row).
|
|
1795
|
+
// Focus the select-all checkbox (a real control with a reliable
|
|
1796
|
+
// keyboard ring), unless the user is still typing in the search box.
|
|
1797
|
+
if (document.activeElement !== search) {
|
|
1798
|
+
selectAll.focus();
|
|
1799
|
+
}
|
|
1800
|
+
})
|
|
1801
|
+
.catch(() => {
|
|
1802
|
+
// Defensive: never leave the button stuck disabled if the chain
|
|
1803
|
+
// rejects unexpectedly.
|
|
1804
|
+
if (bodyWidget.isAttached) {
|
|
1805
|
+
setDeleting(false);
|
|
1806
|
+
updateControls();
|
|
1807
|
+
}
|
|
1678
1808
|
});
|
|
1679
|
-
});
|
|
1680
1809
|
});
|
|
1681
1810
|
|
|
1682
1811
|
search.addEventListener('input', () => {
|
|
1812
|
+
if (deleting) {
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1683
1815
|
render();
|
|
1684
1816
|
updateControls();
|
|
1685
1817
|
});
|
package/style/base.css
CHANGED
|
@@ -377,9 +377,50 @@
|
|
|
377
377
|
border-color: var(--jp-brand-color1);
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
+
/* Table header strip above the scrollable list - select-all and count. */
|
|
381
|
+
.jp-ClaudeSessionsPanel-branchHeader {
|
|
382
|
+
display: flex;
|
|
383
|
+
align-items: center;
|
|
384
|
+
justify-content: space-between;
|
|
385
|
+
padding: 0 2px;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.jp-ClaudeSessionsPanel-branchHeaderCount {
|
|
389
|
+
color: var(--jp-ui-font-color2);
|
|
390
|
+
font-size: var(--jp-ui-font-size0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* Fixed-width first column so the checkbox (branch rows) and the empty cell
|
|
394
|
+
(current row) keep the name column aligned, giving a table feel. */
|
|
395
|
+
.jp-ClaudeSessionsPanel-branchSelectCell {
|
|
396
|
+
flex: none;
|
|
397
|
+
width: 24px;
|
|
398
|
+
height: 24px;
|
|
399
|
+
display: flex;
|
|
400
|
+
align-items: center;
|
|
401
|
+
justify-content: center;
|
|
402
|
+
cursor: pointer;
|
|
403
|
+
}
|
|
404
|
+
|
|
380
405
|
.jp-ClaudeSessionsPanel-branchList {
|
|
381
406
|
max-height: 280px;
|
|
382
407
|
overflow-y: auto;
|
|
408
|
+
position: relative;
|
|
409
|
+
border: 1px solid var(--jp-border-color2);
|
|
410
|
+
border-radius: 2px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* Visually-hidden polite live region (delete announcements). */
|
|
414
|
+
.jp-ClaudeSessionsPanel-srOnly {
|
|
415
|
+
position: absolute;
|
|
416
|
+
width: 1px;
|
|
417
|
+
height: 1px;
|
|
418
|
+
padding: 0;
|
|
419
|
+
margin: -1px;
|
|
420
|
+
overflow: hidden;
|
|
421
|
+
clip: rect(0, 0, 0, 0);
|
|
422
|
+
white-space: nowrap;
|
|
423
|
+
border: 0;
|
|
383
424
|
}
|
|
384
425
|
|
|
385
426
|
.jp-ClaudeSessionsPanel-branchRow {
|
|
@@ -437,23 +478,39 @@
|
|
|
437
478
|
cursor: pointer;
|
|
438
479
|
}
|
|
439
480
|
|
|
440
|
-
/* The current conversation
|
|
481
|
+
/* The current conversation is pinned at the top of the scroll area and
|
|
482
|
+
accented (brand left-bar + tint) so it reads as the active one - not
|
|
483
|
+
dimmed; it is not selectable or deletable. */
|
|
441
484
|
.jp-ClaudeSessionsPanel-branchRow.jp-mod-current {
|
|
485
|
+
position: sticky;
|
|
486
|
+
top: 0;
|
|
487
|
+
z-index: 1;
|
|
442
488
|
cursor: default;
|
|
443
|
-
|
|
489
|
+
background: var(--jp-layout-color2);
|
|
490
|
+
border-left: 3px solid var(--jp-brand-color1);
|
|
491
|
+
padding-left: 5px;
|
|
444
492
|
}
|
|
445
493
|
|
|
446
494
|
.jp-ClaudeSessionsPanel-branchRow.jp-mod-current:hover {
|
|
447
|
-
background:
|
|
495
|
+
background: var(--jp-layout-color2);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* The current row's select cell is an empty alignment spacer - no pointer. */
|
|
499
|
+
.jp-ClaudeSessionsPanel-branchRow.jp-mod-current
|
|
500
|
+
.jp-ClaudeSessionsPanel-branchSelectCell {
|
|
501
|
+
cursor: default;
|
|
448
502
|
}
|
|
449
503
|
|
|
450
504
|
.jp-ClaudeSessionsPanel-branchCurrentBadge {
|
|
451
505
|
flex: none;
|
|
452
506
|
padding: 0 6px;
|
|
453
|
-
border: 1px solid var(--jp-
|
|
507
|
+
border: 1px solid var(--jp-brand-color1);
|
|
454
508
|
border-radius: 8px;
|
|
455
|
-
color: var(--jp-
|
|
509
|
+
color: var(--jp-brand-color1);
|
|
456
510
|
font-size: var(--jp-ui-font-size0);
|
|
511
|
+
font-weight: 600;
|
|
512
|
+
text-transform: uppercase;
|
|
513
|
+
letter-spacing: 0.5px;
|
|
457
514
|
}
|
|
458
515
|
|
|
459
516
|
.jp-ClaudeSessionsPanel-branchSelectAll {
|
|
@@ -474,27 +531,53 @@
|
|
|
474
531
|
|
|
475
532
|
.jp-ClaudeSessionsPanel-branchFooter {
|
|
476
533
|
display: flex;
|
|
477
|
-
|
|
534
|
+
align-items: center;
|
|
535
|
+
justify-content: space-between;
|
|
478
536
|
}
|
|
479
537
|
|
|
538
|
+
.jp-ClaudeSessionsPanel-branchSelCount {
|
|
539
|
+
color: var(--jp-ui-font-color2);
|
|
540
|
+
font-size: var(--jp-ui-font-size0);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* The post-delete "N moved to trash" feedback reads at full strength - it is
|
|
544
|
+
the recoverability cue that stands in for a confirmation prompt. */
|
|
545
|
+
.jp-ClaudeSessionsPanel-branchSelCount.jp-mod-deleted {
|
|
546
|
+
color: var(--jp-ui-font-color1);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* While a delete is in flight the whole body is scrimmed so a mid-flight tick,
|
|
550
|
+
a select-all, a search or a second delete cannot race the async refetch. The
|
|
551
|
+
Dialog's Cancel button is outside the body and stays usable. */
|
|
552
|
+
.jp-ClaudeSessionsPanel-branchPopup.jp-mod-busy {
|
|
553
|
+
pointer-events: none;
|
|
554
|
+
opacity: 0.6;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/* Destructive: deletes immediately (no confirmation), so it reads in the
|
|
558
|
+
error colour and fills on hover. */
|
|
480
559
|
.jp-ClaudeSessionsPanel-branchDelete {
|
|
481
560
|
padding: 2px 8px;
|
|
482
|
-
border: 1px solid var(--jp-
|
|
561
|
+
border: 1px solid var(--jp-error-color1);
|
|
483
562
|
border-radius: 2px;
|
|
484
563
|
background: var(--jp-layout-color1);
|
|
485
|
-
color: var(--jp-
|
|
564
|
+
color: var(--jp-error-color1);
|
|
486
565
|
cursor: pointer;
|
|
566
|
+
transition:
|
|
567
|
+
background 120ms ease,
|
|
568
|
+
color 120ms ease;
|
|
487
569
|
}
|
|
488
570
|
|
|
489
571
|
.jp-ClaudeSessionsPanel-branchDelete:disabled {
|
|
490
572
|
opacity: 0.5;
|
|
491
573
|
cursor: default;
|
|
574
|
+
border-color: var(--jp-border-color1);
|
|
575
|
+
color: var(--jp-ui-font-color2);
|
|
492
576
|
}
|
|
493
577
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
color: var(--jp-error-color1);
|
|
578
|
+
.jp-ClaudeSessionsPanel-branchDelete:hover:not(:disabled) {
|
|
579
|
+
background: var(--jp-error-color1);
|
|
580
|
+
color: var(--jp-ui-inverse-font-color1, #fff);
|
|
498
581
|
}
|
|
499
582
|
|
|
500
583
|
/* Last-activity time on session rows - dim, fixed-width right-aligned
|