jupyterlab_claude_code_extension 1.2.26 → 1.2.34
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/lib/widget.d.ts +24 -22
- package/lib/widget.js +68 -65
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +44 -26
- package/src/widget.ts +74 -79
- package/style/base.css +2 -7
package/lib/widget.d.ts
CHANGED
|
@@ -48,19 +48,18 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
48
48
|
private _cleanupParallel;
|
|
49
49
|
private _resumeInTerminal;
|
|
50
50
|
/**
|
|
51
|
-
* Reuse an open terminal only when it is running the
|
|
52
|
-
* caller wants
|
|
51
|
+
* Reuse an open terminal only when it is POSITIVELY running the
|
|
52
|
+
* conversation the caller wants (its claude argv carries the same
|
|
53
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
54
|
+
* --resume <id>``.
|
|
53
55
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* -
|
|
60
|
-
*
|
|
61
|
-
* so a fresh/foreign terminal is left alone and a new one is launched.
|
|
62
|
-
* A terminal whose resume id is KNOWN and differs is never reused in
|
|
63
|
-
* either mode - that is the switch-then-click bug this fixes.
|
|
56
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
57
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
58
|
+
* is never reused - it may be running a different conversation of this
|
|
59
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
60
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
61
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
62
|
+
* necessarily one the extension did not start.
|
|
64
63
|
*/
|
|
65
64
|
private _doResumeInTerminal;
|
|
66
65
|
/** Absolute path of the file browser's current folder; falls back to the
|
|
@@ -147,11 +146,13 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
147
146
|
* the uuid is generated here so the forked JSONL is known up front, and
|
|
148
147
|
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
149
148
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
150
|
-
* it sticks even though the fork inherits the parent's title. The
|
|
151
|
-
* the
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
149
|
+
* it sticks even though the fork inherits the parent's title. The backend
|
|
150
|
+
* pins the fork as the project's current conversation at launch, so the fork
|
|
151
|
+
* becomes the row's primary the moment its JSONL lands - recency alone would
|
|
152
|
+
* not, since the actively-written parent it was branched from quickly
|
|
153
|
+
* overtakes the fork's mtime. claude writes the fork's JSONL lazily (on its
|
|
154
|
+
* first turn), so the branch appears on the next poll once it materialises -
|
|
155
|
+
* the panel cannot list a file that does not exist yet.
|
|
155
156
|
*/
|
|
156
157
|
private _branchSession;
|
|
157
158
|
/** Switch the active row's project to another conversation branch.
|
|
@@ -160,11 +161,12 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
160
161
|
private _switchBranch;
|
|
161
162
|
/** Open a specific conversation branch in its own terminal.
|
|
162
163
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* global skip-permissions toggle like a normal
|
|
164
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
165
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
166
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
167
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
168
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
169
|
+
* resume. */
|
|
168
170
|
private _openBranch;
|
|
169
171
|
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
170
172
|
*
|
package/lib/widget.js
CHANGED
|
@@ -449,7 +449,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
// -------------------------------------------------------------- terminal
|
|
452
|
-
async _resumeInTerminal(session, forceDangerous = false
|
|
452
|
+
async _resumeInTerminal(session, forceDangerous = false) {
|
|
453
453
|
var _a;
|
|
454
454
|
// Coalesce concurrent clicks on the SAME conversation - subsequent clicks
|
|
455
455
|
// attach to the in-flight promise instead of creating their own terminal.
|
|
@@ -460,28 +460,27 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
460
460
|
if (inFlight) {
|
|
461
461
|
return inFlight;
|
|
462
462
|
}
|
|
463
|
-
const promise = this._doResumeInTerminal(session, forceDangerous
|
|
463
|
+
const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
|
|
464
464
|
this._pendingByPath.delete(key);
|
|
465
465
|
});
|
|
466
466
|
this._pendingByPath.set(key, promise);
|
|
467
467
|
return promise;
|
|
468
468
|
}
|
|
469
469
|
/**
|
|
470
|
-
* Reuse an open terminal only when it is running the
|
|
471
|
-
* caller wants
|
|
470
|
+
* Reuse an open terminal only when it is POSITIVELY running the
|
|
471
|
+
* conversation the caller wants (its claude argv carries the same
|
|
472
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
473
|
+
* --resume <id>``.
|
|
472
474
|
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* -
|
|
479
|
-
*
|
|
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.
|
|
475
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
476
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
477
|
+
* is never reused - it may be running a different conversation of this
|
|
478
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
479
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
480
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
481
|
+
* necessarily one the extension did not start.
|
|
483
482
|
*/
|
|
484
|
-
async _doResumeInTerminal(session, forceDangerous
|
|
483
|
+
async _doResumeInTerminal(session, forceDangerous) {
|
|
485
484
|
var _a;
|
|
486
485
|
try {
|
|
487
486
|
// Always prefer reusing an open terminal for this conversation. The
|
|
@@ -490,25 +489,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
490
489
|
// terminal already exists, show a modal asking them to close it
|
|
491
490
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
492
491
|
// 1. In-memory microcache (most-recent terminal for this project).
|
|
492
|
+
// Reuse it only when it is running the wanted conversation.
|
|
493
493
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
494
|
-
if (cached &&
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
await this._showCloseExistingDialog();
|
|
500
|
-
}
|
|
501
|
-
this._focusTerminal(cached.widget);
|
|
502
|
-
return;
|
|
494
|
+
if (cached &&
|
|
495
|
+
!cached.widget.isDisposed &&
|
|
496
|
+
cached.sessionId === session.session_id) {
|
|
497
|
+
if (forceDangerous) {
|
|
498
|
+
await this._showCloseExistingDialog();
|
|
503
499
|
}
|
|
500
|
+
this._focusTerminal(cached.widget);
|
|
501
|
+
return;
|
|
504
502
|
}
|
|
505
503
|
// 2. Walk every live terminal widget JL knows about.
|
|
506
|
-
const found = await this._findTerminalForCwd(session.project_path, session.session_id
|
|
504
|
+
const found = await this._findTerminalForCwd(session.project_path, session.session_id);
|
|
507
505
|
if (found) {
|
|
508
|
-
// Tag the cache with the OBSERVED conversation
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
// conversation.
|
|
506
|
+
// Tag the cache with the OBSERVED conversation - here it equals the
|
|
507
|
+
// wanted id (the gate in _findTerminalForCwd), so a later reuse trusts
|
|
508
|
+
// a confirmed conversation rather than a wish.
|
|
512
509
|
this._terminalsByPath.set(session.project_path, {
|
|
513
510
|
widget: found.widget,
|
|
514
511
|
sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
|
|
@@ -581,12 +578,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
581
578
|
if (!projectPath) {
|
|
582
579
|
return;
|
|
583
580
|
}
|
|
581
|
+
// Launch with a frontend-chosen session id (claude --session-id <uuid>
|
|
582
|
+
// starts a fresh session with that id) so the terminal's claude is
|
|
583
|
+
// identifiable from its argv. Reuse can then refocus this exact
|
|
584
|
+
// conversation later instead of guessing from an unknown terminal.
|
|
585
|
+
const newId = UUID.uuid4();
|
|
584
586
|
const spinner = this._showLaunchSpinner();
|
|
585
587
|
try {
|
|
586
588
|
const launched = await requestAPI('launch-terminal', this._serverSettings, {
|
|
587
589
|
method: 'POST',
|
|
588
590
|
body: JSON.stringify({
|
|
589
591
|
project_path: projectPath,
|
|
592
|
+
new_session_id: newId,
|
|
590
593
|
dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
|
|
591
594
|
})
|
|
592
595
|
});
|
|
@@ -594,11 +597,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
594
597
|
name: launched.terminal_name
|
|
595
598
|
});
|
|
596
599
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
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
600
|
this._terminalsByPath.set(projectPath, {
|
|
600
601
|
widget,
|
|
601
|
-
sessionId:
|
|
602
|
+
sessionId: newId
|
|
602
603
|
});
|
|
603
604
|
this._wireTerminalDisposal(projectPath, widget);
|
|
604
605
|
this._focusTerminal(widget);
|
|
@@ -639,7 +640,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
639
640
|
}
|
|
640
641
|
});
|
|
641
642
|
}
|
|
642
|
-
async _findTerminalForCwd(projectPath, wantedSessionId
|
|
643
|
+
async _findTerminalForCwd(projectPath, wantedSessionId) {
|
|
643
644
|
var _a, _b, _c;
|
|
644
645
|
if (!this._terminalTracker) {
|
|
645
646
|
return null;
|
|
@@ -664,24 +665,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
664
665
|
if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
|
|
665
666
|
continue;
|
|
666
667
|
}
|
|
667
|
-
// Conversation gate: reuse
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
668
|
+
// Conversation gate: reuse ONLY a terminal we can positively confirm
|
|
669
|
+
// is running the wanted conversation. A terminal whose conversation
|
|
670
|
+
// is unknown (claude started with -c/--continue or a bare claude, so
|
|
671
|
+
// its argv carries no id) is never reused - it may be running a
|
|
672
|
+
// different conversation of this project (the switch-then-click bug).
|
|
673
|
+
// Extension launches always carry an explicit id, so an unknown
|
|
674
|
+
// terminal is necessarily foreign.
|
|
672
675
|
const runningId = (_c = data.session_id) !== null && _c !== void 0 ? _c : null;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
+
// A missing wanted id (undefined) or an unknown running id (null) can
|
|
677
|
+
// never be a positive match - guard so neither side's "absent" value
|
|
678
|
+
// is mistaken for equality and an unknown terminal slips through.
|
|
679
|
+
if (!wantedSessionId || runningId !== wantedSessionId) {
|
|
676
680
|
continue;
|
|
677
681
|
}
|
|
678
682
|
const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
|
|
679
683
|
for (const cwd of cwds) {
|
|
680
684
|
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
// with this so a later strict reuse trusts the process, not a
|
|
684
|
-
// wish.
|
|
685
|
+
// runningId equals the wanted id here (gated above), so the caller
|
|
686
|
+
// tags its cache with a confirmed conversation id.
|
|
685
687
|
return { widget, runningId };
|
|
686
688
|
}
|
|
687
689
|
}
|
|
@@ -1449,9 +1451,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1449
1451
|
body: bodyWidget,
|
|
1450
1452
|
buttons: [Dialog.cancelButton()]
|
|
1451
1453
|
});
|
|
1452
|
-
// Per-row "Open" launches that conversation in its own terminal (
|
|
1453
|
-
//
|
|
1454
|
-
// click from toggling selection or
|
|
1454
|
+
// Per-row "Open" launches that conversation in its own terminal (via
|
|
1455
|
+
// _openBranch, reusing only a terminal already running it) and closes the
|
|
1456
|
+
// popup. stopPropagation keeps the click from toggling selection or
|
|
1457
|
+
// switching the row.
|
|
1455
1458
|
const openButton = (sessionId) => {
|
|
1456
1459
|
const btn = document.createElement('button');
|
|
1457
1460
|
btn.type = 'button';
|
|
@@ -1497,6 +1500,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1497
1500
|
const currentRow = document.createElement('div');
|
|
1498
1501
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1499
1502
|
currentRow.title = `Session id: ${current}`;
|
|
1503
|
+
// Expose the active state to assistive tech - the brand left-bar, tint
|
|
1504
|
+
// and the plain "current" text are visual-only cues.
|
|
1505
|
+
currentRow.setAttribute('aria-current', 'true');
|
|
1500
1506
|
// Empty select cell keeps the name column aligned with branch rows.
|
|
1501
1507
|
const currentSelect = document.createElement('span');
|
|
1502
1508
|
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
@@ -1744,11 +1750,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1744
1750
|
* the uuid is generated here so the forked JSONL is known up front, and
|
|
1745
1751
|
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
1746
1752
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1747
|
-
* it sticks even though the fork inherits the parent's title. The
|
|
1748
|
-
* the
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1753
|
+
* it sticks even though the fork inherits the parent's title. The backend
|
|
1754
|
+
* pins the fork as the project's current conversation at launch, so the fork
|
|
1755
|
+
* becomes the row's primary the moment its JSONL lands - recency alone would
|
|
1756
|
+
* not, since the actively-written parent it was branched from quickly
|
|
1757
|
+
* overtakes the fork's mtime. claude writes the fork's JSONL lazily (on its
|
|
1758
|
+
* first turn), so the branch appears on the next poll once it materialises -
|
|
1759
|
+
* the panel cannot list a file that does not exist yet.
|
|
1752
1760
|
*/
|
|
1753
1761
|
async _branchSession(forceDangerous) {
|
|
1754
1762
|
const session = this._activeSession;
|
|
@@ -1839,23 +1847,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1839
1847
|
}
|
|
1840
1848
|
/** Open a specific conversation branch in its own terminal.
|
|
1841
1849
|
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
* global skip-permissions toggle like a normal
|
|
1850
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
1851
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
1852
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
1853
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
1854
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
1855
|
+
* resume. */
|
|
1847
1856
|
async _openBranch(sessionId) {
|
|
1848
1857
|
const active = this._activeSession;
|
|
1849
1858
|
if (!active || !sessionId) {
|
|
1850
1859
|
return;
|
|
1851
1860
|
}
|
|
1852
|
-
|
|
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);
|
|
1861
|
+
await this._resumeInTerminal({ ...active, session_id: sessionId });
|
|
1859
1862
|
}
|
|
1860
1863
|
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
1861
1864
|
*
|
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.34",
|
|
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",
|
|
@@ -220,6 +220,17 @@ describe('launch spinner dismiss contract', () => {
|
|
|
220
220
|
expect(checkboxIdx).toBeGreaterThan(currentIdx);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it('current row exposes aria-current and a plain lowercase "current" label', () => {
|
|
224
|
+
const popup = (widgetSrc.match(
|
|
225
|
+
/private _showBranchPopup[\s\S]*?\n \}/
|
|
226
|
+
) ?? [''])[0];
|
|
227
|
+
// Programmatic active state for assistive tech (visual cues are the
|
|
228
|
+
// brand left-bar, tint and the plain word) - UX review finding.
|
|
229
|
+
expect(popup).toMatch(/setAttribute\('aria-current', 'true'\)/);
|
|
230
|
+
// The marker is plain text "current" - the boxed uppercase chip is gone.
|
|
231
|
+
expect(popup).toMatch(/badge\.textContent = 'current'/);
|
|
232
|
+
});
|
|
233
|
+
|
|
223
234
|
it('checkbox is its own click zone and selection gates row switch', () => {
|
|
224
235
|
const popup = (widgetSrc.match(
|
|
225
236
|
/private _showBranchPopup[\s\S]*?\n \}/
|
|
@@ -508,10 +519,11 @@ describe('launch spinner dismiss contract', () => {
|
|
|
508
519
|
|
|
509
520
|
/**
|
|
510
521
|
* Contract for conversation-aware terminal reuse and the Open Branched
|
|
511
|
-
* Conversation feature.
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
522
|
+
* Conversation feature. Reuse must refuse to focus a terminal running a
|
|
523
|
+
* DIFFERENT or UNKNOWN conversation (the switch-then-click bug, DEF-4): a
|
|
524
|
+
* terminal is reused only on a POSITIVE conversation-id match. Every
|
|
525
|
+
* extension launch carries an explicit id so its terminal is identifiable
|
|
526
|
+
* (resume -> --resume, new session / fork -> --session-id).
|
|
515
527
|
*/
|
|
516
528
|
describe('conversation-aware reuse + open-branch contract', () => {
|
|
517
529
|
const findTerm = (widgetSrc.match(
|
|
@@ -526,6 +538,9 @@ describe('launch spinner dismiss contract', () => {
|
|
|
526
538
|
const openBranch = (widgetSrc.match(
|
|
527
539
|
/private async _openBranch[\s\S]*?\n \}/
|
|
528
540
|
) ?? [''])[0];
|
|
541
|
+
const newSession = (widgetSrc.match(
|
|
542
|
+
/private async _newSession[\s\S]*?\n \}/
|
|
543
|
+
) ?? [''])[0];
|
|
529
544
|
const watch = (widgetSrc.match(/private _watchForBranch[\s\S]*?\n \}/) ?? [
|
|
530
545
|
''
|
|
531
546
|
])[0];
|
|
@@ -537,32 +552,32 @@ describe('launch spinner dismiss contract', () => {
|
|
|
537
552
|
expect(iface).toMatch(/session_id\?: string \| null/);
|
|
538
553
|
});
|
|
539
554
|
|
|
540
|
-
it('_findTerminalForCwd takes the wanted id
|
|
555
|
+
it('_findTerminalForCwd takes the wanted id with no strict flag', () => {
|
|
541
556
|
expect(findTerm).toMatch(
|
|
542
|
-
/_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined
|
|
557
|
+
/_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined\s*\)/
|
|
543
558
|
);
|
|
544
559
|
});
|
|
545
560
|
|
|
546
|
-
it('reuse
|
|
547
|
-
// Same conversation, or unknown (fresh) only in lenient mode, may reuse.
|
|
561
|
+
it('reuse requires a positive conversation-id match (no lenient unknown reuse)', () => {
|
|
548
562
|
expect(findTerm).toMatch(/const runningId = data\.session_id \?\? null/);
|
|
563
|
+
// The ONLY reuse condition: a truthy wanted id equal to the observed
|
|
564
|
+
// id. A missing wanted id or an unknown (null) running id never matches,
|
|
565
|
+
// so a -c/--continue or bare-claude terminal is never reused (DEF-4).
|
|
549
566
|
expect(findTerm).toMatch(
|
|
550
|
-
/
|
|
551
|
-
);
|
|
552
|
-
expect(findTerm).toMatch(/unknownConversation = runningId === null/);
|
|
553
|
-
expect(findTerm).toMatch(
|
|
554
|
-
/if \(!\(sameConversation \|\| \(!strict && unknownConversation\)\)\) \{\s*continue;/
|
|
567
|
+
/if \(!wantedSessionId \|\| runningId !== wantedSessionId\) \{\s*continue;/
|
|
555
568
|
);
|
|
569
|
+
// The old lenient/strict machinery is gone.
|
|
570
|
+
expect(findTerm).not.toMatch(/unknownConversation/);
|
|
571
|
+
expect(findTerm).not.toMatch(/strict/);
|
|
556
572
|
});
|
|
557
573
|
|
|
558
|
-
it('microcache reuse is gated on the conversation id', () => {
|
|
574
|
+
it('microcache reuse is gated purely on the conversation id', () => {
|
|
559
575
|
expect(doResume).toMatch(/cached\.sessionId === session\.session_id/);
|
|
560
|
-
expect(doResume).toMatch(
|
|
576
|
+
expect(doResume).not.toMatch(/unknownConversation/);
|
|
577
|
+
expect(doResume).not.toMatch(/strict/);
|
|
561
578
|
});
|
|
562
579
|
|
|
563
|
-
it('microcache is tagged with the OBSERVED running id
|
|
564
|
-
// Storing the wanted id would let a later strict reuse trust a
|
|
565
|
-
// fabricated tag and focus the wrong conversation (review finding 1).
|
|
580
|
+
it('microcache is tagged with the OBSERVED running id', () => {
|
|
566
581
|
expect(doResume).toMatch(
|
|
567
582
|
/widget: found\.widget,\s*sessionId: found\.runningId \?\? undefined/
|
|
568
583
|
);
|
|
@@ -576,16 +591,19 @@ describe('launch spinner dismiss contract', () => {
|
|
|
576
591
|
expect(resume).toMatch(/this\._pendingByPath\.(get|set)\(key/);
|
|
577
592
|
});
|
|
578
593
|
|
|
579
|
-
it('
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
expect(
|
|
584
|
-
|
|
585
|
-
|
|
594
|
+
it('a new session launches with an explicit --session-id so it is identifiable', () => {
|
|
595
|
+
// DEF-4: a bare `claude` reports no id and would be wrongly reused;
|
|
596
|
+
// launching with a frontend id makes the terminal positively matchable.
|
|
597
|
+
expect(newSession).toMatch(/const newId = UUID\.uuid4\(\)/);
|
|
598
|
+
expect(newSession).toMatch(/new_session_id: newId/);
|
|
599
|
+
expect(newSession).toMatch(/sessionId: newId/);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('_openBranch reuses only a terminal running that conversation', () => {
|
|
586
603
|
expect(openBranch).toMatch(
|
|
587
|
-
/this\._resumeInTerminal\(\
|
|
604
|
+
/this\._resumeInTerminal\(\{ \.\.\.active, session_id: sessionId \}\)/
|
|
588
605
|
);
|
|
606
|
+
expect(openBranch).not.toMatch(/strict/);
|
|
589
607
|
});
|
|
590
608
|
|
|
591
609
|
it('open-branch command and submenu are wired up', () => {
|
package/src/widget.ts
CHANGED
|
@@ -551,8 +551,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
551
551
|
|
|
552
552
|
private async _resumeInTerminal(
|
|
553
553
|
session: ISession,
|
|
554
|
-
forceDangerous: boolean = false
|
|
555
|
-
strict: boolean = false
|
|
554
|
+
forceDangerous: boolean = false
|
|
556
555
|
): Promise<void> {
|
|
557
556
|
// Coalesce concurrent clicks on the SAME conversation - subsequent clicks
|
|
558
557
|
// attach to the in-flight promise instead of creating their own terminal.
|
|
@@ -563,36 +562,32 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
563
562
|
if (inFlight) {
|
|
564
563
|
return inFlight;
|
|
565
564
|
}
|
|
566
|
-
const promise = this._doResumeInTerminal(
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
)
|
|
571
|
-
this._pendingByPath.delete(key);
|
|
572
|
-
});
|
|
565
|
+
const promise = this._doResumeInTerminal(session, forceDangerous).finally(
|
|
566
|
+
() => {
|
|
567
|
+
this._pendingByPath.delete(key);
|
|
568
|
+
}
|
|
569
|
+
);
|
|
573
570
|
this._pendingByPath.set(key, promise);
|
|
574
571
|
return promise;
|
|
575
572
|
}
|
|
576
573
|
|
|
577
574
|
/**
|
|
578
|
-
* Reuse an open terminal only when it is running the
|
|
579
|
-
* caller wants
|
|
575
|
+
* Reuse an open terminal only when it is POSITIVELY running the
|
|
576
|
+
* conversation the caller wants (its claude argv carries the same
|
|
577
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
578
|
+
* --resume <id>``.
|
|
580
579
|
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* -
|
|
587
|
-
*
|
|
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.
|
|
580
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
581
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
582
|
+
* is never reused - it may be running a different conversation of this
|
|
583
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
584
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
585
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
586
|
+
* necessarily one the extension did not start.
|
|
591
587
|
*/
|
|
592
588
|
private async _doResumeInTerminal(
|
|
593
589
|
session: ISession,
|
|
594
|
-
forceDangerous: boolean
|
|
595
|
-
strict: boolean
|
|
590
|
+
forceDangerous: boolean
|
|
596
591
|
): Promise<void> {
|
|
597
592
|
try {
|
|
598
593
|
// Always prefer reusing an open terminal for this conversation. The
|
|
@@ -602,30 +597,29 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
602
597
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
603
598
|
|
|
604
599
|
// 1. In-memory microcache (most-recent terminal for this project).
|
|
600
|
+
// Reuse it only when it is running the wanted conversation.
|
|
605
601
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
this._focusTerminal(cached.widget);
|
|
614
|
-
return;
|
|
602
|
+
if (
|
|
603
|
+
cached &&
|
|
604
|
+
!cached.widget.isDisposed &&
|
|
605
|
+
cached.sessionId === session.session_id
|
|
606
|
+
) {
|
|
607
|
+
if (forceDangerous) {
|
|
608
|
+
await this._showCloseExistingDialog();
|
|
615
609
|
}
|
|
610
|
+
this._focusTerminal(cached.widget);
|
|
611
|
+
return;
|
|
616
612
|
}
|
|
617
613
|
|
|
618
614
|
// 2. Walk every live terminal widget JL knows about.
|
|
619
615
|
const found = await this._findTerminalForCwd(
|
|
620
616
|
session.project_path,
|
|
621
|
-
session.session_id
|
|
622
|
-
strict
|
|
617
|
+
session.session_id
|
|
623
618
|
);
|
|
624
619
|
if (found) {
|
|
625
|
-
// Tag the cache with the OBSERVED conversation
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
// conversation.
|
|
620
|
+
// Tag the cache with the OBSERVED conversation - here it equals the
|
|
621
|
+
// wanted id (the gate in _findTerminalForCwd), so a later reuse trusts
|
|
622
|
+
// a confirmed conversation rather than a wish.
|
|
629
623
|
this._terminalsByPath.set(session.project_path, {
|
|
630
624
|
widget: found.widget,
|
|
631
625
|
sessionId: found.runningId ?? undefined
|
|
@@ -702,6 +696,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
702
696
|
if (!projectPath) {
|
|
703
697
|
return;
|
|
704
698
|
}
|
|
699
|
+
// Launch with a frontend-chosen session id (claude --session-id <uuid>
|
|
700
|
+
// starts a fresh session with that id) so the terminal's claude is
|
|
701
|
+
// identifiable from its argv. Reuse can then refocus this exact
|
|
702
|
+
// conversation later instead of guessing from an unknown terminal.
|
|
703
|
+
const newId = UUID.uuid4();
|
|
705
704
|
const spinner = this._showLaunchSpinner();
|
|
706
705
|
try {
|
|
707
706
|
const launched = await requestAPI<ILaunchTerminalResponse>(
|
|
@@ -711,6 +710,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
711
710
|
method: 'POST',
|
|
712
711
|
body: JSON.stringify({
|
|
713
712
|
project_path: projectPath,
|
|
713
|
+
new_session_id: newId,
|
|
714
714
|
dangerously_skip_permissions:
|
|
715
715
|
forceDangerous || this._dangerouslySkip
|
|
716
716
|
})
|
|
@@ -720,11 +720,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
720
720
|
name: launched.terminal_name
|
|
721
721
|
});
|
|
722
722
|
if (widget?.id) {
|
|
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
723
|
this._terminalsByPath.set(projectPath, {
|
|
726
724
|
widget,
|
|
727
|
-
sessionId:
|
|
725
|
+
sessionId: newId
|
|
728
726
|
});
|
|
729
727
|
this._wireTerminalDisposal(projectPath, widget);
|
|
730
728
|
this._focusTerminal(widget);
|
|
@@ -765,8 +763,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
765
763
|
|
|
766
764
|
private async _findTerminalForCwd(
|
|
767
765
|
projectPath: string,
|
|
768
|
-
wantedSessionId: string | undefined
|
|
769
|
-
strict: boolean
|
|
766
|
+
wantedSessionId: string | undefined
|
|
770
767
|
): Promise<{ widget: any; runningId: string | null } | null> {
|
|
771
768
|
if (!this._terminalTracker) {
|
|
772
769
|
return null;
|
|
@@ -794,24 +791,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
794
791
|
if (!data?.has_claude) {
|
|
795
792
|
continue;
|
|
796
793
|
}
|
|
797
|
-
// Conversation gate: reuse
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
794
|
+
// Conversation gate: reuse ONLY a terminal we can positively confirm
|
|
795
|
+
// is running the wanted conversation. A terminal whose conversation
|
|
796
|
+
// is unknown (claude started with -c/--continue or a bare claude, so
|
|
797
|
+
// its argv carries no id) is never reused - it may be running a
|
|
798
|
+
// different conversation of this project (the switch-then-click bug).
|
|
799
|
+
// Extension launches always carry an explicit id, so an unknown
|
|
800
|
+
// terminal is necessarily foreign.
|
|
802
801
|
const runningId = data.session_id ?? null;
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
802
|
+
// A missing wanted id (undefined) or an unknown running id (null) can
|
|
803
|
+
// never be a positive match - guard so neither side's "absent" value
|
|
804
|
+
// is mistaken for equality and an unknown terminal slips through.
|
|
805
|
+
if (!wantedSessionId || runningId !== wantedSessionId) {
|
|
806
806
|
continue;
|
|
807
807
|
}
|
|
808
808
|
const cwds = Array.isArray(data?.cwds) ? data.cwds : [];
|
|
809
809
|
for (const cwd of cwds) {
|
|
810
810
|
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
// with this so a later strict reuse trusts the process, not a
|
|
814
|
-
// wish.
|
|
811
|
+
// runningId equals the wanted id here (gated above), so the caller
|
|
812
|
+
// tags its cache with a confirmed conversation id.
|
|
815
813
|
return { widget, runningId };
|
|
816
814
|
}
|
|
817
815
|
}
|
|
@@ -1671,9 +1669,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1671
1669
|
buttons: [Dialog.cancelButton()]
|
|
1672
1670
|
});
|
|
1673
1671
|
|
|
1674
|
-
// Per-row "Open" launches that conversation in its own terminal (
|
|
1675
|
-
//
|
|
1676
|
-
// click from toggling selection or
|
|
1672
|
+
// Per-row "Open" launches that conversation in its own terminal (via
|
|
1673
|
+
// _openBranch, reusing only a terminal already running it) and closes the
|
|
1674
|
+
// popup. stopPropagation keeps the click from toggling selection or
|
|
1675
|
+
// switching the row.
|
|
1677
1676
|
const openButton = (sessionId: string): HTMLButtonElement => {
|
|
1678
1677
|
const btn = document.createElement('button');
|
|
1679
1678
|
btn.type = 'button';
|
|
@@ -1728,6 +1727,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1728
1727
|
const currentRow = document.createElement('div');
|
|
1729
1728
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1730
1729
|
currentRow.title = `Session id: ${current}`;
|
|
1730
|
+
// Expose the active state to assistive tech - the brand left-bar, tint
|
|
1731
|
+
// and the plain "current" text are visual-only cues.
|
|
1732
|
+
currentRow.setAttribute('aria-current', 'true');
|
|
1731
1733
|
// Empty select cell keeps the name column aligned with branch rows.
|
|
1732
1734
|
const currentSelect = document.createElement('span');
|
|
1733
1735
|
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
@@ -1992,11 +1994,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1992
1994
|
* the uuid is generated here so the forked JSONL is known up front, and
|
|
1993
1995
|
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
1994
1996
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1995
|
-
* it sticks even though the fork inherits the parent's title. The
|
|
1996
|
-
* the
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
1997
|
+
* it sticks even though the fork inherits the parent's title. The backend
|
|
1998
|
+
* pins the fork as the project's current conversation at launch, so the fork
|
|
1999
|
+
* becomes the row's primary the moment its JSONL lands - recency alone would
|
|
2000
|
+
* not, since the actively-written parent it was branched from quickly
|
|
2001
|
+
* overtakes the fork's mtime. claude writes the fork's JSONL lazily (on its
|
|
2002
|
+
* first turn), so the branch appears on the next poll once it materialises -
|
|
2003
|
+
* the panel cannot list a file that does not exist yet.
|
|
2000
2004
|
*/
|
|
2001
2005
|
private async _branchSession(forceDangerous: boolean): Promise<void> {
|
|
2002
2006
|
const session = this._activeSession;
|
|
@@ -2101,27 +2105,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
2101
2105
|
|
|
2102
2106
|
/** Open a specific conversation branch in its own terminal.
|
|
2103
2107
|
*
|
|
2104
|
-
*
|
|
2105
|
-
*
|
|
2106
|
-
*
|
|
2107
|
-
*
|
|
2108
|
-
* global skip-permissions toggle like a normal
|
|
2108
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
2109
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
2110
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
2111
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
2112
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
2113
|
+
* resume. */
|
|
2109
2114
|
private async _openBranch(sessionId: string): Promise<void> {
|
|
2110
2115
|
const active = this._activeSession;
|
|
2111
2116
|
if (!active || !sessionId) {
|
|
2112
2117
|
return;
|
|
2113
2118
|
}
|
|
2114
|
-
|
|
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
|
-
);
|
|
2119
|
+
await this._resumeInTerminal({ ...active, session_id: sessionId });
|
|
2125
2120
|
}
|
|
2126
2121
|
|
|
2127
2122
|
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
package/style/base.css
CHANGED
|
@@ -522,14 +522,9 @@
|
|
|
522
522
|
|
|
523
523
|
.jp-ClaudeSessionsPanel-branchCurrentBadge {
|
|
524
524
|
flex: none;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
border-radius: 8px;
|
|
528
|
-
color: var(--jp-brand-color1);
|
|
525
|
+
white-space: nowrap;
|
|
526
|
+
color: var(--jp-ui-font-color2);
|
|
529
527
|
font-size: var(--jp-ui-font-size0);
|
|
530
|
-
font-weight: 600;
|
|
531
|
-
text-transform: uppercase;
|
|
532
|
-
letter-spacing: 0.5px;
|
|
533
528
|
}
|
|
534
529
|
|
|
535
530
|
.jp-ClaudeSessionsPanel-branchSelectAll {
|