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