jupyterlab_claude_code_extension 1.2.25 → 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/README.md CHANGED
@@ -32,6 +32,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
32
32
  - **Remove** - drop a project's Claude history from the panel via the right-click menu; a confirmation dialog names the project and warns it removes the whole project and all its conversations before anything is touched, then the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
33
33
  - **Clean up parallel sessions** - when a project has accumulated extra sessions beyond the main one, a right-click menu item (showing the count in brackets) removes them all, keeping only the main session; a confirmation dialog naming the project and the count appears first, and removed files honour the same trash setting
34
34
  - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup - a scrollable table over the full list with the current conversation pinned at the top and accented. Conversations can also be deleted here - select one, many, or all via checkboxes, then click Delete; they move to trash immediately (no confirmation, with an "N moved to trash" message; removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
35
+ - **Open branched conversation** - a separate right-click "Open Branched Conversation" submenu (and an "Open" button on every row of the Manage Sessions popup) opens any conversation directly in its own terminal, so several branches of one project can run side by side; clicking a row reuses an open terminal only when it is already running that exact conversation, so switching a branch and clicking lands you in the right one rather than the original
35
36
  - **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
36
37
  - **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
37
38
  - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
package/lib/widget.d.ts CHANGED
@@ -47,6 +47,20 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
47
47
  private _remove;
48
48
  private _cleanupParallel;
49
49
  private _resumeInTerminal;
50
+ /**
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>``.
55
+ *
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.
63
+ */
50
64
  private _doResumeInTerminal;
51
65
  /** Absolute path of the file browser's current folder; falls back to the
52
66
  * server root when no file browser is available. */
@@ -143,6 +157,25 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
143
157
  * The backend touches the branch JSONL's mtime; a refresh then shows
144
158
  * the selected conversation as the row's current one. */
145
159
  private _switchBranch;
160
+ /** Open a specific conversation branch in its own terminal.
161
+ *
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
+ private _openBranch;
169
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
170
+ *
171
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
172
+ * just-requested branch does not exist at launch and would otherwise only
173
+ * surface on the next 30s poll. This watches the project's branch list
174
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
175
+ * or in the list) - then does one full refresh and stops - or until a
176
+ * bounded number of attempts elapses. The panel is never updated before
177
+ * the branch genuinely exists. */
178
+ private _watchForBranch;
146
179
  private _startPolling;
147
180
  private _stopPolling;
148
181
  private readonly _app;
@@ -157,6 +190,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
157
190
  private _commands;
158
191
  private _contextMenu;
159
192
  private _branchSubmenu;
193
+ private _openBranchSubmenu;
160
194
  private _branchSessionMenu;
161
195
  private _lastBranches;
162
196
  private _lastBranchesCurrent;
package/lib/widget.js CHANGED
@@ -7,6 +7,10 @@ import { Menu, Widget } from '@lumino/widgets';
7
7
  import { requestAPI } from './request';
8
8
  import { addIcon, branchIcon, switchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
9
9
  const POLL_INTERVAL_MS = 30000;
10
+ // After a fork is requested, watch for its lazily-written JSONL at this fast
11
+ // cadence (bounded) so a new branch surfaces in seconds, not on the slow poll.
12
+ const BRANCH_WATCH_INTERVAL_MS = 2000;
13
+ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
10
14
  const DEFAULT_RECENT_LIMIT = 10;
11
15
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
12
16
  const DEFAULT_PRESENTATION_MODE = 'folder';
@@ -72,7 +76,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
72
76
  this._activeRowEl = null;
73
77
  this._pollHandle = null;
74
78
  this._removingPaths = new Set();
79
+ // Microcache of the most-recent terminal per project, tagged with the
80
+ // conversation it is running so reuse can tell a project's branches apart.
75
81
  this._terminalsByPath = new Map();
82
+ // In-flight launches, keyed per CONVERSATION (path + session id) so two
83
+ // different branches of one project can open independently and concurrently.
76
84
  this._pendingByPath = new Map();
77
85
  this._presentationMode = DEFAULT_PRESENTATION_MODE;
78
86
  this._recentLimit = DEFAULT_RECENT_LIMIT;
@@ -442,43 +450,71 @@ export class ClaudeCodeSessionsWidget extends Widget {
442
450
  }
443
451
  // -------------------------------------------------------------- terminal
444
452
  async _resumeInTerminal(session, forceDangerous = false) {
445
- // Coalesce concurrent clicks on the same row - subsequent clicks attach
446
- // to the in-flight promise instead of creating their own terminal.
447
- const inFlight = this._pendingByPath.get(session.project_path);
453
+ var _a;
454
+ // Coalesce concurrent clicks on the SAME conversation - subsequent clicks
455
+ // attach to the in-flight promise instead of creating their own terminal.
456
+ // The key is per-conversation, so opening a different branch of the same
457
+ // project launches independently rather than coalescing onto this one.
458
+ const key = `${session.project_path}\n${(_a = session.session_id) !== null && _a !== void 0 ? _a : ''}`;
459
+ const inFlight = this._pendingByPath.get(key);
448
460
  if (inFlight) {
449
461
  return inFlight;
450
462
  }
451
463
  const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
452
- this._pendingByPath.delete(session.project_path);
464
+ this._pendingByPath.delete(key);
453
465
  });
454
- this._pendingByPath.set(session.project_path, promise);
466
+ this._pendingByPath.set(key, promise);
455
467
  return promise;
456
468
  }
469
+ /**
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>``.
474
+ *
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.
482
+ */
457
483
  async _doResumeInTerminal(session, forceDangerous) {
484
+ var _a;
458
485
  try {
459
- // Always prefer reusing an open terminal for this project. The
486
+ // Always prefer reusing an open terminal for this conversation. The
460
487
  // skip-permissions flag can only be applied to a fresh pty, never
461
488
  // retroactively. So if the user wants dangerous mode but an open
462
489
  // terminal already exists, show a modal asking them to close it
463
490
  // first - we won't auto-close, won't silently reuse the wrong mode.
464
- // 1. In-memory microcache.
491
+ // 1. In-memory microcache (most-recent terminal for this project).
492
+ // Reuse it only when it is running the wanted conversation.
465
493
  const cached = this._terminalsByPath.get(session.project_path);
466
- if (cached && !cached.isDisposed) {
494
+ if (cached &&
495
+ !cached.widget.isDisposed &&
496
+ cached.sessionId === session.session_id) {
467
497
  if (forceDangerous) {
468
498
  await this._showCloseExistingDialog();
469
499
  }
470
- this._focusTerminal(cached);
500
+ this._focusTerminal(cached.widget);
471
501
  return;
472
502
  }
473
503
  // 2. Walk every live terminal widget JL knows about.
474
- const found = await this._findTerminalForCwd(session.project_path);
504
+ const found = await this._findTerminalForCwd(session.project_path, session.session_id);
475
505
  if (found) {
476
- this._terminalsByPath.set(session.project_path, found);
477
- this._wireTerminalDisposal(session.project_path, found);
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.
509
+ this._terminalsByPath.set(session.project_path, {
510
+ widget: found.widget,
511
+ sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
512
+ });
513
+ this._wireTerminalDisposal(session.project_path, found.widget);
478
514
  if (forceDangerous) {
479
515
  await this._showCloseExistingDialog();
480
516
  }
481
- this._focusTerminal(found);
517
+ this._focusTerminal(found.widget);
482
518
  return;
483
519
  }
484
520
  // 3. No matching terminal - spawn a new one with `claude --resume <id>`
@@ -502,7 +538,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
502
538
  name: launched.terminal_name
503
539
  });
504
540
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
505
- this._terminalsByPath.set(session.project_path, widget);
541
+ this._terminalsByPath.set(session.project_path, {
542
+ widget,
543
+ sessionId: session.session_id
544
+ });
506
545
  this._wireTerminalDisposal(session.project_path, widget);
507
546
  this._focusTerminal(widget);
508
547
  }
@@ -539,12 +578,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
539
578
  if (!projectPath) {
540
579
  return;
541
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();
542
586
  const spinner = this._showLaunchSpinner();
543
587
  try {
544
588
  const launched = await requestAPI('launch-terminal', this._serverSettings, {
545
589
  method: 'POST',
546
590
  body: JSON.stringify({
547
591
  project_path: projectPath,
592
+ new_session_id: newId,
548
593
  dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
549
594
  })
550
595
  });
@@ -552,7 +597,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
552
597
  name: launched.terminal_name
553
598
  });
554
599
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
555
- this._terminalsByPath.set(projectPath, widget);
600
+ this._terminalsByPath.set(projectPath, {
601
+ widget,
602
+ sessionId: newId
603
+ });
556
604
  this._wireTerminalDisposal(projectPath, widget);
557
605
  this._focusTerminal(widget);
558
606
  }
@@ -592,8 +640,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
592
640
  }
593
641
  });
594
642
  }
595
- async _findTerminalForCwd(projectPath) {
596
- var _a, _b;
643
+ async _findTerminalForCwd(projectPath, wantedSessionId) {
644
+ var _a, _b, _c;
597
645
  if (!this._terminalTracker) {
598
646
  return null;
599
647
  }
@@ -617,10 +665,26 @@ export class ClaudeCodeSessionsWidget extends Widget {
617
665
  if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
618
666
  continue;
619
667
  }
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.
675
+ const runningId = (_c = data.session_id) !== null && _c !== void 0 ? _c : null;
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) {
680
+ continue;
681
+ }
620
682
  const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
621
683
  for (const cwd of cwds) {
622
684
  if ((cwd || '').replace(/\/+$/, '') === target) {
623
- return widget;
685
+ // runningId equals the wanted id here (gated above), so the caller
686
+ // tags its cache with a confirmed conversation id.
687
+ return { widget, runningId };
624
688
  }
625
689
  }
626
690
  }
@@ -667,7 +731,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
667
731
  return;
668
732
  }
669
733
  widget.disposed.connect(() => {
670
- if (this._terminalsByPath.get(projectPath) === widget) {
734
+ var _a;
735
+ if (((_a = this._terminalsByPath.get(projectPath)) === null || _a === void 0 ? void 0 : _a.widget) === widget) {
671
736
  this._terminalsByPath.delete(projectPath);
672
737
  }
673
738
  });
@@ -1127,6 +1192,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1127
1192
  void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1128
1193
  }
1129
1194
  });
1195
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1196
+ label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
1197
+ icon: terminalIcon,
1198
+ execute: args => {
1199
+ var _a;
1200
+ const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
1201
+ if (sessionId) {
1202
+ void this._openBranch(sessionId);
1203
+ }
1204
+ }
1205
+ });
1130
1206
  this._commands.addCommand('claude-code-sessions:branch-session', {
1131
1207
  label: 'Normal',
1132
1208
  execute: () => void this._branchSession(false)
@@ -1171,6 +1247,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
1171
1247
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1172
1248
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1173
1249
  this._branchSubmenu.title.icon = switchIcon;
1250
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1251
+ // switch submenu, which only changes which branch the row points at).
1252
+ // Several branches can be open at once, independently.
1253
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1254
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1255
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1256
+ this._openBranchSubmenu.title.icon = terminalIcon;
1174
1257
  // Submenu grouping the two branch-session launch modes.
1175
1258
  this._branchSessionMenu = new Menu({ commands: this._commands });
1176
1259
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1217,6 +1300,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1217
1300
  });
1218
1301
  this._contextMenu.addItem({ type: 'separator' });
1219
1302
  if (withBranches) {
1303
+ this._contextMenu.addItem({
1304
+ type: 'submenu',
1305
+ submenu: this._openBranchSubmenu
1306
+ });
1220
1307
  this._contextMenu.addItem({
1221
1308
  type: 'submenu',
1222
1309
  submenu: this._branchSubmenu
@@ -1259,6 +1346,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1259
1346
  this._branchSubmenu.addItem({
1260
1347
  command: 'claude-code-sessions:switch-branch-more'
1261
1348
  });
1349
+ // Open submenu: same top-5 branches, but each launches its own
1350
+ // terminal directly. Falls through to the Manage Sessions popup for
1351
+ // the full list (from which any conversation can also be opened).
1352
+ this._openBranchSubmenu.clearItems();
1353
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1354
+ for (const b of data.branches.slice(0, 5)) {
1355
+ this._openBranchSubmenu.addItem({
1356
+ command: 'claude-code-sessions:open-branch',
1357
+ args: {
1358
+ session_id: b.session_id,
1359
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1360
+ }
1361
+ });
1362
+ }
1363
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1364
+ this._openBranchSubmenu.addItem({
1365
+ command: 'claude-code-sessions:switch-branch-more'
1366
+ });
1262
1367
  hasBranches = data.branches.length > 0;
1263
1368
  }
1264
1369
  catch (_a) {
@@ -1346,6 +1451,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
1346
1451
  body: bodyWidget,
1347
1452
  buttons: [Dialog.cancelButton()]
1348
1453
  });
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.
1458
+ const openButton = (sessionId) => {
1459
+ const btn = document.createElement('button');
1460
+ btn.type = 'button';
1461
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1462
+ btn.textContent = 'Open';
1463
+ btn.title = 'Open this conversation in its own terminal';
1464
+ btn.addEventListener('click', e => {
1465
+ e.stopPropagation();
1466
+ dialog.dispose();
1467
+ void this._openBranch(sessionId);
1468
+ });
1469
+ return btn;
1470
+ };
1349
1471
  const visibleMatches = () => {
1350
1472
  const needle = search.value.trim().toLowerCase();
1351
1473
  return items.filter(b => !needle ||
@@ -1378,6 +1500,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1378
1500
  const currentRow = document.createElement('div');
1379
1501
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1380
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');
1381
1506
  // Empty select cell keeps the name column aligned with branch rows.
1382
1507
  const currentSelect = document.createElement('span');
1383
1508
  currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
@@ -1393,6 +1518,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1393
1518
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1394
1519
  badge.textContent = 'current';
1395
1520
  currentRow.appendChild(badge);
1521
+ currentRow.appendChild(openButton(current));
1396
1522
  currentRow.appendChild(this._branchCopyButton(current));
1397
1523
  list.appendChild(currentRow);
1398
1524
  const matches = visibleMatches();
@@ -1450,6 +1576,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1450
1576
  time.className = 'jp-ClaudeSessionsPanel-branchTime';
1451
1577
  time.textContent = this._formatRelativeTime(b.file_mtime);
1452
1578
  row.appendChild(time);
1579
+ row.appendChild(openButton(b.session_id));
1453
1580
  row.appendChild(this._branchCopyButton(b.session_id));
1454
1581
  row.addEventListener('click', () => {
1455
1582
  // Selection mode: while anything is ticked, row clicks toggle
@@ -1660,7 +1787,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1660
1787
  name: launched.terminal_name
1661
1788
  });
1662
1789
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
1663
- this._terminalsByPath.set(session.project_path, widget);
1790
+ // The terminal runs the FORK (forkId), so tag it with that id - a
1791
+ // later click on the now-current forked row reuses this terminal.
1792
+ this._terminalsByPath.set(session.project_path, {
1793
+ widget,
1794
+ sessionId: forkId
1795
+ });
1664
1796
  this._wireTerminalDisposal(session.project_path, widget);
1665
1797
  this._focusTerminal(widget);
1666
1798
  }
@@ -1673,8 +1805,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1673
1805
  spinner.dispose();
1674
1806
  }
1675
1807
  // No post-hoc title write: claude owns the name via ``-n`` and stamps it
1676
- // on the fork's first turn. The branch surfaces on the next poll once
1677
- // claude materialises its JSONL.
1808
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
1809
+ // so watch for it at a fast cadence and refresh the moment it appears -
1810
+ // the branch surfaces in seconds instead of on the next slow poll.
1811
+ this._watchForBranch(session.encoded_path, forkId);
1678
1812
  }
1679
1813
  /** Switch the active row's project to another conversation branch.
1680
1814
  * The backend touches the branch JSONL's mtime; a refresh then shows
@@ -1709,6 +1843,55 @@ export class ClaudeCodeSessionsWidget extends Widget {
1709
1843
  await this._fetch();
1710
1844
  }
1711
1845
  }
1846
+ /** Open a specific conversation branch in its own terminal.
1847
+ *
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. */
1854
+ async _openBranch(sessionId) {
1855
+ const active = this._activeSession;
1856
+ if (!active || !sessionId) {
1857
+ return;
1858
+ }
1859
+ await this._resumeInTerminal({ ...active, session_id: sessionId });
1860
+ }
1861
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
1862
+ *
1863
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
1864
+ * just-requested branch does not exist at launch and would otherwise only
1865
+ * surface on the next 30s poll. This watches the project's branch list
1866
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
1867
+ * or in the list) - then does one full refresh and stops - or until a
1868
+ * bounded number of attempts elapses. The panel is never updated before
1869
+ * the branch genuinely exists. */
1870
+ _watchForBranch(encodedPath, forkId) {
1871
+ let attempts = 0;
1872
+ const tick = async () => {
1873
+ if (this.isDisposed) {
1874
+ return;
1875
+ }
1876
+ attempts += 1;
1877
+ try {
1878
+ const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`, this._serverSettings, { cache: 'no-store' });
1879
+ const appeared = data.current === forkId ||
1880
+ data.branches.some(b => b.session_id === forkId);
1881
+ if (appeared) {
1882
+ await this._fetch();
1883
+ return;
1884
+ }
1885
+ }
1886
+ catch (_a) {
1887
+ // Transient failure - keep watching until the attempt budget runs out.
1888
+ }
1889
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
1890
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1891
+ }
1892
+ };
1893
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1894
+ }
1712
1895
  // --------------------------------------------------------------- polling
1713
1896
  _startPolling() {
1714
1897
  if (this._pollHandle !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.25",
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 \}/
@@ -506,6 +517,148 @@ describe('launch spinner dismiss contract', () => {
506
517
  });
507
518
  });
508
519
 
520
+ /**
521
+ * Contract for conversation-aware terminal reuse and the Open Branched
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).
527
+ */
528
+ describe('conversation-aware reuse + open-branch contract', () => {
529
+ const findTerm = (widgetSrc.match(
530
+ /private async _findTerminalForCwd[\s\S]*?\n \}/
531
+ ) ?? [''])[0];
532
+ const doResume = (widgetSrc.match(
533
+ /private async _doResumeInTerminal[\s\S]*?\n \}/
534
+ ) ?? [''])[0];
535
+ const resume = (widgetSrc.match(
536
+ /private async _resumeInTerminal[\s\S]*?\n \}/
537
+ ) ?? [''])[0];
538
+ const openBranch = (widgetSrc.match(
539
+ /private async _openBranch[\s\S]*?\n \}/
540
+ ) ?? [''])[0];
541
+ const newSession = (widgetSrc.match(
542
+ /private async _newSession[\s\S]*?\n \}/
543
+ ) ?? [''])[0];
544
+ const watch = (widgetSrc.match(/private _watchForBranch[\s\S]*?\n \}/) ?? [
545
+ ''
546
+ ])[0];
547
+
548
+ it('terminal-cwd response carries the running conversation id', () => {
549
+ const iface = (widgetSrc.match(
550
+ /interface ITerminalCwdResponse \{[\s\S]*?\}/
551
+ ) ?? [''])[0];
552
+ expect(iface).toMatch(/session_id\?: string \| null/);
553
+ });
554
+
555
+ it('_findTerminalForCwd takes the wanted id with no strict flag', () => {
556
+ expect(findTerm).toMatch(
557
+ /_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined\s*\)/
558
+ );
559
+ });
560
+
561
+ it('reuse requires a positive conversation-id match (no lenient unknown reuse)', () => {
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).
566
+ expect(findTerm).toMatch(
567
+ /if \(!wantedSessionId \|\| runningId !== wantedSessionId\) \{\s*continue;/
568
+ );
569
+ // The old lenient/strict machinery is gone.
570
+ expect(findTerm).not.toMatch(/unknownConversation/);
571
+ expect(findTerm).not.toMatch(/strict/);
572
+ });
573
+
574
+ it('microcache reuse is gated purely on the conversation id', () => {
575
+ expect(doResume).toMatch(/cached\.sessionId === session\.session_id/);
576
+ expect(doResume).not.toMatch(/unknownConversation/);
577
+ expect(doResume).not.toMatch(/strict/);
578
+ });
579
+
580
+ it('microcache is tagged with the OBSERVED running id', () => {
581
+ expect(doResume).toMatch(
582
+ /widget: found\.widget,\s*sessionId: found\.runningId \?\? undefined/
583
+ );
584
+ expect(findTerm).toMatch(/return \{ widget, runningId \}/);
585
+ });
586
+
587
+ it('in-flight launches are keyed per conversation, not per project', () => {
588
+ expect(resume).toMatch(
589
+ /const key = `\$\{session\.project_path\}\\n\$\{session\.session_id \?\? ''\}`/
590
+ );
591
+ expect(resume).toMatch(/this\._pendingByPath\.(get|set)\(key/);
592
+ });
593
+
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', () => {
603
+ expect(openBranch).toMatch(
604
+ /this\._resumeInTerminal\(\{ \.\.\.active, session_id: sessionId \}\)/
605
+ );
606
+ expect(openBranch).not.toMatch(/strict/);
607
+ });
608
+
609
+ it('open-branch command and submenu are wired up', () => {
610
+ expect(widgetSrc).toMatch(/'claude-code-sessions:open-branch'/);
611
+ expect(widgetSrc).toMatch(
612
+ /this\._openBranchSubmenu\.title\.label = 'Open Branched Conversation'/
613
+ );
614
+ // Top 5 branches populate the open submenu, each via the open command.
615
+ const populate = (widgetSrc.match(
616
+ /this\._openBranchSubmenu\.clearItems[\s\S]*?switch-branch-more'\s*\}\);/
617
+ ) ?? [''])[0];
618
+ expect(populate).toMatch(/data\.branches\.slice\(0, 5\)/);
619
+ expect(populate).toMatch(/command: 'claude-code-sessions:open-branch'/);
620
+ // The context menu shows the open submenu when the row has branches.
621
+ const rebuild = (widgetSrc.match(
622
+ /private _rebuildContextMenu[\s\S]*?\n \}/
623
+ ) ?? [''])[0];
624
+ expect(rebuild).toMatch(/submenu: this\._openBranchSubmenu/);
625
+ });
626
+
627
+ it('popup rows carry an Open button that launches the branch', () => {
628
+ const popup = (widgetSrc.match(
629
+ /private _showBranchPopup[\s\S]*?\n \}\n/
630
+ ) ?? [''])[0];
631
+ expect(popup).toMatch(/jp-ClaudeSessionsPanel-branchOpen/);
632
+ expect(popup).toMatch(/void this\._openBranch\(sessionId\)/);
633
+ // Open appears on both the current row and each branch row.
634
+ expect(popup).toMatch(/openButton\(current\)/);
635
+ expect(popup).toMatch(/openButton\(b\.session_id\)/);
636
+ });
637
+
638
+ it('a new branch is watched for and surfaces fast, not on the slow poll', () => {
639
+ expect(watch).toMatch(/data\.current === forkId/);
640
+ expect(watch).toMatch(/b\.session_id === forkId/);
641
+ expect(watch).toMatch(/await this\._fetch\(\)/);
642
+ expect(watch).toMatch(/BRANCH_WATCH_MAX_ATTEMPTS/);
643
+ // Fork launch arms the watcher.
644
+ const fork = (widgetSrc.match(
645
+ /private async _branchSession[\s\S]*?\n \}/
646
+ ) ?? [''])[0];
647
+ expect(fork).toMatch(
648
+ /this\._watchForBranch\(session\.encoded_path, forkId\)/
649
+ );
650
+ expect(widgetSrc).toMatch(/BRANCH_WATCH_INTERVAL_MS = 2_000/);
651
+ });
652
+
653
+ it('the Open button is styled in base.css', () => {
654
+ const css: string = fs.readFileSync(
655
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
656
+ 'utf-8'
657
+ );
658
+ expect(css).toMatch(/\.jp-ClaudeSessionsPanel-branchOpen \{/);
659
+ });
660
+ });
661
+
509
662
  it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
510
663
  expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
511
664
  expect(widgetSrc).not.toMatch(/spinner\.resolve\(\)/);
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
@@ -546,51 +553,82 @@ export class ClaudeCodeSessionsWidget extends Widget {
546
553
  session: ISession,
547
554
  forceDangerous: boolean = false
548
555
  ): Promise<void> {
549
- // Coalesce concurrent clicks on the same row - subsequent clicks attach
550
- // to the in-flight promise instead of creating their own terminal.
551
- const inFlight = this._pendingByPath.get(session.project_path);
556
+ // Coalesce concurrent clicks on the SAME conversation - subsequent clicks
557
+ // attach to the in-flight promise instead of creating their own terminal.
558
+ // The key is per-conversation, so opening a different branch of the same
559
+ // project launches independently rather than coalescing onto this one.
560
+ const key = `${session.project_path}\n${session.session_id ?? ''}`;
561
+ const inFlight = this._pendingByPath.get(key);
552
562
  if (inFlight) {
553
563
  return inFlight;
554
564
  }
555
565
  const promise = this._doResumeInTerminal(session, forceDangerous).finally(
556
566
  () => {
557
- this._pendingByPath.delete(session.project_path);
567
+ this._pendingByPath.delete(key);
558
568
  }
559
569
  );
560
- this._pendingByPath.set(session.project_path, promise);
570
+ this._pendingByPath.set(key, promise);
561
571
  return promise;
562
572
  }
563
573
 
574
+ /**
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>``.
579
+ *
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.
587
+ */
564
588
  private async _doResumeInTerminal(
565
589
  session: ISession,
566
590
  forceDangerous: boolean
567
591
  ): Promise<void> {
568
592
  try {
569
- // Always prefer reusing an open terminal for this project. The
593
+ // Always prefer reusing an open terminal for this conversation. The
570
594
  // skip-permissions flag can only be applied to a fresh pty, never
571
595
  // retroactively. So if the user wants dangerous mode but an open
572
596
  // terminal already exists, show a modal asking them to close it
573
597
  // first - we won't auto-close, won't silently reuse the wrong mode.
574
598
 
575
- // 1. In-memory microcache.
599
+ // 1. In-memory microcache (most-recent terminal for this project).
600
+ // Reuse it only when it is running the wanted conversation.
576
601
  const cached = this._terminalsByPath.get(session.project_path);
577
- if (cached && !cached.isDisposed) {
602
+ if (
603
+ cached &&
604
+ !cached.widget.isDisposed &&
605
+ cached.sessionId === session.session_id
606
+ ) {
578
607
  if (forceDangerous) {
579
608
  await this._showCloseExistingDialog();
580
609
  }
581
- this._focusTerminal(cached);
610
+ this._focusTerminal(cached.widget);
582
611
  return;
583
612
  }
584
613
 
585
614
  // 2. Walk every live terminal widget JL knows about.
586
- const found = await this._findTerminalForCwd(session.project_path);
615
+ const found = await this._findTerminalForCwd(
616
+ session.project_path,
617
+ session.session_id
618
+ );
587
619
  if (found) {
588
- this._terminalsByPath.set(session.project_path, found);
589
- this._wireTerminalDisposal(session.project_path, found);
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.
623
+ this._terminalsByPath.set(session.project_path, {
624
+ widget: found.widget,
625
+ sessionId: found.runningId ?? undefined
626
+ });
627
+ this._wireTerminalDisposal(session.project_path, found.widget);
590
628
  if (forceDangerous) {
591
629
  await this._showCloseExistingDialog();
592
630
  }
593
- this._focusTerminal(found);
631
+ this._focusTerminal(found.widget);
594
632
  return;
595
633
  }
596
634
 
@@ -620,7 +658,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
620
658
  name: launched.terminal_name
621
659
  });
622
660
  if (widget?.id) {
623
- this._terminalsByPath.set(session.project_path, widget);
661
+ this._terminalsByPath.set(session.project_path, {
662
+ widget,
663
+ sessionId: session.session_id
664
+ });
624
665
  this._wireTerminalDisposal(session.project_path, widget);
625
666
  this._focusTerminal(widget);
626
667
  }
@@ -655,6 +696,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
655
696
  if (!projectPath) {
656
697
  return;
657
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();
658
704
  const spinner = this._showLaunchSpinner();
659
705
  try {
660
706
  const launched = await requestAPI<ILaunchTerminalResponse>(
@@ -664,6 +710,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
664
710
  method: 'POST',
665
711
  body: JSON.stringify({
666
712
  project_path: projectPath,
713
+ new_session_id: newId,
667
714
  dangerously_skip_permissions:
668
715
  forceDangerous || this._dangerouslySkip
669
716
  })
@@ -673,7 +720,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
673
720
  name: launched.terminal_name
674
721
  });
675
722
  if (widget?.id) {
676
- this._terminalsByPath.set(projectPath, widget);
723
+ this._terminalsByPath.set(projectPath, {
724
+ widget,
725
+ sessionId: newId
726
+ });
677
727
  this._wireTerminalDisposal(projectPath, widget);
678
728
  this._focusTerminal(widget);
679
729
  }
@@ -711,7 +761,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
711
761
  });
712
762
  }
713
763
 
714
- private async _findTerminalForCwd(projectPath: string): Promise<any | null> {
764
+ private async _findTerminalForCwd(
765
+ projectPath: string,
766
+ wantedSessionId: string | undefined
767
+ ): Promise<{ widget: any; runningId: string | null } | null> {
715
768
  if (!this._terminalTracker) {
716
769
  return null;
717
770
  }
@@ -738,10 +791,26 @@ export class ClaudeCodeSessionsWidget extends Widget {
738
791
  if (!data?.has_claude) {
739
792
  continue;
740
793
  }
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.
801
+ const runningId = data.session_id ?? null;
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
+ 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
- return widget;
811
+ // runningId equals the wanted id here (gated above), so the caller
812
+ // tags its cache with a confirmed conversation id.
813
+ return { widget, runningId };
745
814
  }
746
815
  }
747
816
  } catch (_err) {
@@ -792,7 +861,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
792
861
  return;
793
862
  }
794
863
  widget.disposed.connect(() => {
795
- if (this._terminalsByPath.get(projectPath) === widget) {
864
+ if (this._terminalsByPath.get(projectPath)?.widget === widget) {
796
865
  this._terminalsByPath.delete(projectPath);
797
866
  }
798
867
  });
@@ -1311,6 +1380,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1311
1380
  }
1312
1381
  });
1313
1382
 
1383
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1384
+ label: args => String(args.label ?? ''),
1385
+ icon: terminalIcon,
1386
+ execute: args => {
1387
+ const sessionId = String(args.session_id ?? '');
1388
+ if (sessionId) {
1389
+ void this._openBranch(sessionId);
1390
+ }
1391
+ }
1392
+ });
1393
+
1314
1394
  this._commands.addCommand('claude-code-sessions:branch-session', {
1315
1395
  label: 'Normal',
1316
1396
  execute: () => void this._branchSession(false)
@@ -1362,6 +1442,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
1362
1442
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1363
1443
  this._branchSubmenu.title.icon = switchIcon;
1364
1444
 
1445
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1446
+ // switch submenu, which only changes which branch the row points at).
1447
+ // Several branches can be open at once, independently.
1448
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1449
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1450
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1451
+ this._openBranchSubmenu.title.icon = terminalIcon;
1452
+
1365
1453
  // Submenu grouping the two branch-session launch modes.
1366
1454
  this._branchSessionMenu = new Menu({ commands: this._commands });
1367
1455
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1411,6 +1499,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1411
1499
  });
1412
1500
  this._contextMenu.addItem({ type: 'separator' });
1413
1501
  if (withBranches) {
1502
+ this._contextMenu.addItem({
1503
+ type: 'submenu',
1504
+ submenu: this._openBranchSubmenu
1505
+ });
1414
1506
  this._contextMenu.addItem({
1415
1507
  type: 'submenu',
1416
1508
  submenu: this._branchSubmenu
@@ -1462,6 +1554,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
1462
1554
  this._branchSubmenu.addItem({
1463
1555
  command: 'claude-code-sessions:switch-branch-more'
1464
1556
  });
1557
+
1558
+ // Open submenu: same top-5 branches, but each launches its own
1559
+ // terminal directly. Falls through to the Manage Sessions popup for
1560
+ // the full list (from which any conversation can also be opened).
1561
+ this._openBranchSubmenu.clearItems();
1562
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1563
+ for (const b of data.branches.slice(0, 5)) {
1564
+ this._openBranchSubmenu.addItem({
1565
+ command: 'claude-code-sessions:open-branch',
1566
+ args: {
1567
+ session_id: b.session_id,
1568
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1569
+ }
1570
+ });
1571
+ }
1572
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1573
+ this._openBranchSubmenu.addItem({
1574
+ command: 'claude-code-sessions:switch-branch-more'
1575
+ });
1465
1576
  hasBranches = data.branches.length > 0;
1466
1577
  } catch {
1467
1578
  hasBranches = false;
@@ -1558,6 +1669,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1558
1669
  buttons: [Dialog.cancelButton()]
1559
1670
  });
1560
1671
 
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.
1676
+ const openButton = (sessionId: string): HTMLButtonElement => {
1677
+ const btn = document.createElement('button');
1678
+ btn.type = 'button';
1679
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1680
+ btn.textContent = 'Open';
1681
+ btn.title = 'Open this conversation in its own terminal';
1682
+ btn.addEventListener('click', e => {
1683
+ e.stopPropagation();
1684
+ dialog.dispose();
1685
+ void this._openBranch(sessionId);
1686
+ });
1687
+ return btn;
1688
+ };
1689
+
1561
1690
  const visibleMatches = (): IBranch[] => {
1562
1691
  const needle = search.value.trim().toLowerCase();
1563
1692
  return items.filter(
@@ -1598,6 +1727,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1598
1727
  const currentRow = document.createElement('div');
1599
1728
  currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1600
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');
1601
1733
  // Empty select cell keeps the name column aligned with branch rows.
1602
1734
  const currentSelect = document.createElement('span');
1603
1735
  currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
@@ -1613,6 +1745,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1613
1745
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1614
1746
  badge.textContent = 'current';
1615
1747
  currentRow.appendChild(badge);
1748
+ currentRow.appendChild(openButton(current));
1616
1749
  currentRow.appendChild(this._branchCopyButton(current));
1617
1750
  list.appendChild(currentRow);
1618
1751
 
@@ -1677,6 +1810,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1677
1810
  time.textContent = this._formatRelativeTime(b.file_mtime);
1678
1811
  row.appendChild(time);
1679
1812
 
1813
+ row.appendChild(openButton(b.session_id));
1680
1814
  row.appendChild(this._branchCopyButton(b.session_id));
1681
1815
 
1682
1816
  row.addEventListener('click', () => {
@@ -1902,7 +2036,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1902
2036
  name: launched.terminal_name
1903
2037
  });
1904
2038
  if (widget?.id) {
1905
- this._terminalsByPath.set(session.project_path, widget);
2039
+ // The terminal runs the FORK (forkId), so tag it with that id - a
2040
+ // later click on the now-current forked row reuses this terminal.
2041
+ this._terminalsByPath.set(session.project_path, {
2042
+ widget,
2043
+ sessionId: forkId
2044
+ });
1906
2045
  this._wireTerminalDisposal(session.project_path, widget);
1907
2046
  this._focusTerminal(widget);
1908
2047
  }
@@ -1913,8 +2052,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1913
2052
  spinner.dispose();
1914
2053
  }
1915
2054
  // No post-hoc title write: claude owns the name via ``-n`` and stamps it
1916
- // on the fork's first turn. The branch surfaces on the next poll once
1917
- // claude materialises its JSONL.
2055
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
2056
+ // so watch for it at a fast cadence and refresh the moment it appears -
2057
+ // the branch surfaces in seconds instead of on the next slow poll.
2058
+ this._watchForBranch(session.encoded_path, forkId);
1918
2059
  }
1919
2060
 
1920
2061
  /** Switch the active row's project to another conversation branch.
@@ -1960,6 +2101,61 @@ export class ClaudeCodeSessionsWidget extends Widget {
1960
2101
  }
1961
2102
  }
1962
2103
 
2104
+ /** Open a specific conversation branch in its own terminal.
2105
+ *
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. */
2112
+ private async _openBranch(sessionId: string): Promise<void> {
2113
+ const active = this._activeSession;
2114
+ if (!active || !sessionId) {
2115
+ return;
2116
+ }
2117
+ await this._resumeInTerminal({ ...active, session_id: sessionId });
2118
+ }
2119
+
2120
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
2121
+ *
2122
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
2123
+ * just-requested branch does not exist at launch and would otherwise only
2124
+ * surface on the next 30s poll. This watches the project's branch list
2125
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
2126
+ * or in the list) - then does one full refresh and stops - or until a
2127
+ * bounded number of attempts elapses. The panel is never updated before
2128
+ * the branch genuinely exists. */
2129
+ private _watchForBranch(encodedPath: string, forkId: string): void {
2130
+ let attempts = 0;
2131
+ const tick = async (): Promise<void> => {
2132
+ if (this.isDisposed) {
2133
+ return;
2134
+ }
2135
+ attempts += 1;
2136
+ try {
2137
+ const data = await requestAPI<IBranchesResponse>(
2138
+ `sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`,
2139
+ this._serverSettings,
2140
+ { cache: 'no-store' }
2141
+ );
2142
+ const appeared =
2143
+ data.current === forkId ||
2144
+ data.branches.some(b => b.session_id === forkId);
2145
+ if (appeared) {
2146
+ await this._fetch();
2147
+ return;
2148
+ }
2149
+ } catch {
2150
+ // Transient failure - keep watching until the attempt budget runs out.
2151
+ }
2152
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
2153
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2154
+ }
2155
+ };
2156
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2157
+ }
2158
+
1963
2159
  // --------------------------------------------------------------- polling
1964
2160
 
1965
2161
  private _startPolling(): void {
@@ -1994,6 +2190,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1994
2190
  private _commands!: CommandRegistry;
1995
2191
  private _contextMenu!: Menu;
1996
2192
  private _branchSubmenu!: Menu;
2193
+ private _openBranchSubmenu!: Menu;
1997
2194
  private _branchSessionMenu!: Menu;
1998
2195
  private _lastBranches: IBranch[] = [];
1999
2196
  private _lastBranchesCurrent = '';
@@ -2004,7 +2201,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
2004
2201
  private readonly _removingPaths: Set<string> = new Set();
2005
2202
  private readonly _terminalTracker: ITerminalTracker | null;
2006
2203
  private readonly _fileBrowser: IDefaultFileBrowser | null;
2007
- private readonly _terminalsByPath: Map<string, any> = new Map();
2204
+ // Microcache of the most-recent terminal per project, tagged with the
2205
+ // conversation it is running so reuse can tell a project's branches apart.
2206
+ private readonly _terminalsByPath: Map<
2207
+ string,
2208
+ { widget: any; sessionId?: string }
2209
+ > = new Map();
2210
+ // In-flight launches, keyed per CONVERSATION (path + session id) so two
2211
+ // different branches of one project can open independently and concurrently.
2008
2212
  private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
2009
2213
  private readonly _rootDir: string;
2010
2214
  private _presentationMode: PresentationMode = DEFAULT_PRESENTATION_MODE;
package/style/base.css CHANGED
@@ -463,6 +463,25 @@
463
463
  color: var(--jp-ui-font-color1);
464
464
  }
465
465
 
466
+ /* Per-row "Open" launches the conversation in its own terminal. Compact
467
+ brand-outlined button matching the popup design language (2px radius). */
468
+ .jp-ClaudeSessionsPanel-branchOpen {
469
+ flex: none;
470
+ padding: 0 8px;
471
+ border: 1px solid var(--jp-brand-color1);
472
+ border-radius: 2px;
473
+ background: transparent;
474
+ color: var(--jp-brand-color1);
475
+ font-size: var(--jp-ui-font-size0);
476
+ cursor: pointer;
477
+ transition: background 120ms ease;
478
+ }
479
+
480
+ .jp-ClaudeSessionsPanel-branchOpen:hover {
481
+ background: var(--jp-brand-color1);
482
+ color: var(--jp-ui-inverse-font-color1);
483
+ }
484
+
466
485
  .jp-ClaudeSessionsPanel-branchTime {
467
486
  flex: none;
468
487
  width: 4em;
@@ -503,14 +522,9 @@
503
522
 
504
523
  .jp-ClaudeSessionsPanel-branchCurrentBadge {
505
524
  flex: none;
506
- padding: 0 6px;
507
- border: 1px solid var(--jp-brand-color1);
508
- border-radius: 8px;
509
- color: var(--jp-brand-color1);
525
+ white-space: nowrap;
526
+ color: var(--jp-ui-font-color2);
510
527
  font-size: var(--jp-ui-font-size0);
511
- font-weight: 600;
512
- text-transform: uppercase;
513
- letter-spacing: 0.5px;
514
528
  }
515
529
 
516
530
  .jp-ClaudeSessionsPanel-branchSelectAll {