jupyterlab_claude_code_extension 1.2.26 → 1.2.33

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 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 conversation the
52
- * caller wants; otherwise launch a fresh ``claude --resume <id>``.
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
- * ``strict`` selects how a cwd-matching claude terminal whose conversation
55
- * is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
56
- * - lenient (row resume): reuse it. A fresh session's id is not in its
57
- * argv, so this is the only way to refocus a just-started session, and
58
- * it avoids ``claude --resume`` erroring with "already in use".
59
- * - strict (Open Branched Conversation): never reuse an unknown terminal -
60
- * the user picked a specific branch and must land in THAT conversation,
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
@@ -160,11 +159,12 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
160
159
  private _switchBranch;
161
160
  /** Open a specific conversation branch in its own terminal.
162
161
  *
163
- * Strict reuse: only a terminal already running THIS conversation is
164
- * refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
165
- * several branches of one project can be open independently and side by
166
- * side - opening branch B never disturbs branch A's terminal. Honours the
167
- * global skip-permissions toggle like a normal resume. */
162
+ * Reuse only a terminal already running THIS conversation; otherwise launch
163
+ * a fresh ``claude --resume <id>``. So several branches of one project can
164
+ * be open independently and side by side - opening branch B never disturbs
165
+ * branch A's terminal, and never refocuses a terminal running a different
166
+ * conversation. Honours the global skip-permissions toggle like a normal
167
+ * resume. */
168
168
  private _openBranch;
169
169
  /** Poll fast for a freshly forked branch to materialise, then refresh.
170
170
  *
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, strict = 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, strict).finally(() => {
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 conversation the
471
- * caller wants; otherwise launch a fresh ``claude --resume <id>``.
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
- * ``strict`` selects how a cwd-matching claude terminal whose conversation
474
- * is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
475
- * - lenient (row resume): reuse it. A fresh session's id is not in its
476
- * argv, so this is the only way to refocus a just-started session, and
477
- * it avoids ``claude --resume`` erroring with "already in use".
478
- * - strict (Open Branched Conversation): never reuse an unknown terminal -
479
- * the user picked a specific branch and must land in THAT conversation,
480
- * so a fresh/foreign terminal is left alone and a new one is launched.
481
- * A terminal whose resume id is KNOWN and differs is never reused in
482
- * either mode - that is the switch-then-click bug this fixes.
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, strict) {
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 && !cached.widget.isDisposed) {
495
- const sameConversation = cached.sessionId === session.session_id;
496
- const unknownConversation = !cached.sessionId;
497
- if (sameConversation || (!strict && unknownConversation)) {
498
- if (forceDangerous) {
499
- await this._showCloseExistingDialog();
500
- }
501
- this._focusTerminal(cached.widget);
502
- return;
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, strict);
504
+ const found = await this._findTerminalForCwd(session.project_path, session.session_id);
507
505
  if (found) {
508
- // Tag the cache with the OBSERVED conversation (null when the reused
509
- // terminal is a fresh no-resume one), not the wanted id - otherwise a
510
- // later strict reuse would trust a fabricated tag and focus the wrong
511
- // conversation.
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: undefined
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, strict) {
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 only a terminal running the wanted
668
- // conversation. A known, different resume id is never reused (the
669
- // switch-then-click bug). An unknown id (fresh session, no --resume)
670
- // is reused in lenient mode but skipped in strict mode, where the
671
- // user picked a specific branch to open.
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
- const sameConversation = runningId === wantedSessionId;
674
- const unknownConversation = runningId === null;
675
- if (!(sameConversation || (!strict && unknownConversation))) {
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
- // Return the OBSERVED running id (may be null for a fresh
682
- // terminal), never the wanted id - the caller tags its cache
683
- // with this so a later strict reuse trusts the process, not a
684
- // wish.
685
+ // 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 (strict
1453
- // reuse via _openBranch) and closes the popup. stopPropagation keeps the
1454
- // click from toggling selection or switching the row.
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';
@@ -1839,23 +1845,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1839
1845
  }
1840
1846
  /** Open a specific conversation branch in its own terminal.
1841
1847
  *
1842
- * Strict reuse: only a terminal already running THIS conversation is
1843
- * refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
1844
- * several branches of one project can be open independently and side by
1845
- * side - opening branch B never disturbs branch A's terminal. Honours the
1846
- * global skip-permissions toggle like a normal resume. */
1848
+ * Reuse only a terminal already running THIS conversation; otherwise launch
1849
+ * a fresh ``claude --resume <id>``. So several branches of one project can
1850
+ * be open independently and side by side - opening branch B never disturbs
1851
+ * branch A's terminal, and never refocuses a terminal running a different
1852
+ * conversation. Honours the global skip-permissions toggle like a normal
1853
+ * resume. */
1847
1854
  async _openBranch(sessionId) {
1848
1855
  const active = this._activeSession;
1849
1856
  if (!active || !sessionId) {
1850
1857
  return;
1851
1858
  }
1852
- // Opening the row's CURRENT conversation behaves like a normal row resume
1853
- // (lenient): a fresh terminal already running it is reused, avoiding a
1854
- // `claude --resume` "already in use" collision. Opening a DIFFERENT branch
1855
- // is strict so the user lands in that specific conversation, even if some
1856
- // other (fresh) terminal sits at the same cwd.
1857
- const strict = sessionId !== active.session_id;
1858
- await this._resumeInTerminal({ ...active, session_id: sessionId }, false, strict);
1859
+ await this._resumeInTerminal({ ...active, session_id: sessionId });
1859
1860
  }
1860
1861
  /** Poll fast for a freshly forked branch to materialise, then refresh.
1861
1862
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.26",
3
+ "version": "1.2.33",
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. The reuse path must refuse to focus a terminal
512
- * running a DIFFERENT known conversation (the switch-then-click bug), and
513
- * opening a specific branch must launch its own terminal (strict) so
514
- * several branches stay open independently.
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 and a strict flag', () => {
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,\s*strict: boolean/
557
+ /_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined\s*\)/
543
558
  );
544
559
  });
545
560
 
546
- it('reuse rejects a terminal running a known different conversation', () => {
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
- /sameConversation = runningId === wantedSessionId/
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(/!strict && unknownConversation/);
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, not the wanted one', () => {
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('_openBranch is strict for a different branch, lenient for the current', () => {
580
- // Strict on a non-current branch lands the user in THAT conversation;
581
- // lenient on the current avoids a `claude --resume` "already in use"
582
- // collision with a fresh terminal already running it (review finding 2).
583
- expect(openBranch).toMatch(
584
- /const strict = sessionId !== active\.session_id/
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\(\s*\{ \.\.\.active, session_id: sessionId \},\s*false,\s*strict\s*\)/
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
- session,
568
- forceDangerous,
569
- strict
570
- ).finally(() => {
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 conversation the
579
- * caller wants; otherwise launch a fresh ``claude --resume <id>``.
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
- * ``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.
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 (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;
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 (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.
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: undefined
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 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.
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
- const sameConversation = runningId === wantedSessionId;
804
- const unknownConversation = runningId === null;
805
- if (!(sameConversation || (!strict && unknownConversation))) {
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
- // 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.
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 (strict
1675
- // reuse via _openBranch) and closes the popup. stopPropagation keeps the
1676
- // click from toggling selection or switching the row.
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';
@@ -2101,27 +2103,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
2101
2103
 
2102
2104
  /** Open a specific conversation branch in its own terminal.
2103
2105
  *
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. */
2106
+ * Reuse only a terminal already running THIS conversation; otherwise launch
2107
+ * a fresh ``claude --resume <id>``. So several branches of one project can
2108
+ * be open independently and side by side - opening branch B never disturbs
2109
+ * branch A's terminal, and never refocuses a terminal running a different
2110
+ * conversation. Honours the global skip-permissions toggle like a normal
2111
+ * resume. */
2109
2112
  private async _openBranch(sessionId: string): Promise<void> {
2110
2113
  const active = this._activeSession;
2111
2114
  if (!active || !sessionId) {
2112
2115
  return;
2113
2116
  }
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
- );
2117
+ await this._resumeInTerminal({ ...active, session_id: sessionId });
2125
2118
  }
2126
2119
 
2127
2120
  /** 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
- padding: 0 6px;
526
- border: 1px solid var(--jp-brand-color1);
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 {