jupyterlab_claude_code_extension 1.2.25 → 1.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,21 @@ 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 running the conversation the
52
+ * caller wants; otherwise launch a fresh ``claude --resume <id>``.
53
+ *
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.
64
+ */
50
65
  private _doResumeInTerminal;
51
66
  /** Absolute path of the file browser's current folder; falls back to the
52
67
  * server root when no file browser is available. */
@@ -143,6 +158,24 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
143
158
  * The backend touches the branch JSONL's mtime; a refresh then shows
144
159
  * the selected conversation as the row's current one. */
145
160
  private _switchBranch;
161
+ /** Open a specific conversation branch in its own terminal.
162
+ *
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. */
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;
@@ -441,44 +449,75 @@ export class ClaudeCodeSessionsWidget extends Widget {
441
449
  }
442
450
  }
443
451
  // -------------------------------------------------------------- terminal
444
- 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);
452
+ async _resumeInTerminal(session, forceDangerous = false, strict = false) {
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
- const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
452
- this._pendingByPath.delete(session.project_path);
463
+ const promise = this._doResumeInTerminal(session, forceDangerous, strict).finally(() => {
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
  }
457
- async _doResumeInTerminal(session, forceDangerous) {
469
+ /**
470
+ * Reuse an open terminal only when it is running the conversation the
471
+ * caller wants; otherwise launch a fresh ``claude --resume <id>``.
472
+ *
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.
483
+ */
484
+ async _doResumeInTerminal(session, forceDangerous, strict) {
485
+ var _a;
458
486
  try {
459
- // Always prefer reusing an open terminal for this project. The
487
+ // Always prefer reusing an open terminal for this conversation. The
460
488
  // skip-permissions flag can only be applied to a fresh pty, never
461
489
  // retroactively. So if the user wants dangerous mode but an open
462
490
  // terminal already exists, show a modal asking them to close it
463
491
  // first - we won't auto-close, won't silently reuse the wrong mode.
464
- // 1. In-memory microcache.
492
+ // 1. In-memory microcache (most-recent terminal for this project).
465
493
  const cached = this._terminalsByPath.get(session.project_path);
466
- if (cached && !cached.isDisposed) {
467
- if (forceDangerous) {
468
- await this._showCloseExistingDialog();
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;
469
503
  }
470
- this._focusTerminal(cached);
471
- return;
472
504
  }
473
505
  // 2. Walk every live terminal widget JL knows about.
474
- const found = await this._findTerminalForCwd(session.project_path);
506
+ const found = await this._findTerminalForCwd(session.project_path, session.session_id, strict);
475
507
  if (found) {
476
- this._terminalsByPath.set(session.project_path, found);
477
- this._wireTerminalDisposal(session.project_path, 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.
512
+ this._terminalsByPath.set(session.project_path, {
513
+ widget: found.widget,
514
+ sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
515
+ });
516
+ this._wireTerminalDisposal(session.project_path, found.widget);
478
517
  if (forceDangerous) {
479
518
  await this._showCloseExistingDialog();
480
519
  }
481
- this._focusTerminal(found);
520
+ this._focusTerminal(found.widget);
482
521
  return;
483
522
  }
484
523
  // 3. No matching terminal - spawn a new one with `claude --resume <id>`
@@ -502,7 +541,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
502
541
  name: launched.terminal_name
503
542
  });
504
543
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
505
- this._terminalsByPath.set(session.project_path, widget);
544
+ this._terminalsByPath.set(session.project_path, {
545
+ widget,
546
+ sessionId: session.session_id
547
+ });
506
548
  this._wireTerminalDisposal(session.project_path, widget);
507
549
  this._focusTerminal(widget);
508
550
  }
@@ -552,7 +594,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
552
594
  name: launched.terminal_name
553
595
  });
554
596
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
555
- this._terminalsByPath.set(projectPath, widget);
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
+ this._terminalsByPath.set(projectPath, {
600
+ widget,
601
+ sessionId: undefined
602
+ });
556
603
  this._wireTerminalDisposal(projectPath, widget);
557
604
  this._focusTerminal(widget);
558
605
  }
@@ -592,8 +639,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
592
639
  }
593
640
  });
594
641
  }
595
- async _findTerminalForCwd(projectPath) {
596
- var _a, _b;
642
+ async _findTerminalForCwd(projectPath, wantedSessionId, strict) {
643
+ var _a, _b, _c;
597
644
  if (!this._terminalTracker) {
598
645
  return null;
599
646
  }
@@ -617,10 +664,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
617
664
  if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
618
665
  continue;
619
666
  }
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.
672
+ 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
+ continue;
677
+ }
620
678
  const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
621
679
  for (const cwd of cwds) {
622
680
  if ((cwd || '').replace(/\/+$/, '') === target) {
623
- return widget;
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
+ return { widget, runningId };
624
686
  }
625
687
  }
626
688
  }
@@ -667,7 +729,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
667
729
  return;
668
730
  }
669
731
  widget.disposed.connect(() => {
670
- if (this._terminalsByPath.get(projectPath) === widget) {
732
+ var _a;
733
+ if (((_a = this._terminalsByPath.get(projectPath)) === null || _a === void 0 ? void 0 : _a.widget) === widget) {
671
734
  this._terminalsByPath.delete(projectPath);
672
735
  }
673
736
  });
@@ -1127,6 +1190,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1127
1190
  void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1128
1191
  }
1129
1192
  });
1193
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1194
+ label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
1195
+ icon: terminalIcon,
1196
+ execute: args => {
1197
+ var _a;
1198
+ const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
1199
+ if (sessionId) {
1200
+ void this._openBranch(sessionId);
1201
+ }
1202
+ }
1203
+ });
1130
1204
  this._commands.addCommand('claude-code-sessions:branch-session', {
1131
1205
  label: 'Normal',
1132
1206
  execute: () => void this._branchSession(false)
@@ -1171,6 +1245,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
1171
1245
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1172
1246
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1173
1247
  this._branchSubmenu.title.icon = switchIcon;
1248
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1249
+ // switch submenu, which only changes which branch the row points at).
1250
+ // Several branches can be open at once, independently.
1251
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1252
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1253
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1254
+ this._openBranchSubmenu.title.icon = terminalIcon;
1174
1255
  // Submenu grouping the two branch-session launch modes.
1175
1256
  this._branchSessionMenu = new Menu({ commands: this._commands });
1176
1257
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1217,6 +1298,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1217
1298
  });
1218
1299
  this._contextMenu.addItem({ type: 'separator' });
1219
1300
  if (withBranches) {
1301
+ this._contextMenu.addItem({
1302
+ type: 'submenu',
1303
+ submenu: this._openBranchSubmenu
1304
+ });
1220
1305
  this._contextMenu.addItem({
1221
1306
  type: 'submenu',
1222
1307
  submenu: this._branchSubmenu
@@ -1259,6 +1344,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1259
1344
  this._branchSubmenu.addItem({
1260
1345
  command: 'claude-code-sessions:switch-branch-more'
1261
1346
  });
1347
+ // Open submenu: same top-5 branches, but each launches its own
1348
+ // terminal directly. Falls through to the Manage Sessions popup for
1349
+ // the full list (from which any conversation can also be opened).
1350
+ this._openBranchSubmenu.clearItems();
1351
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1352
+ for (const b of data.branches.slice(0, 5)) {
1353
+ this._openBranchSubmenu.addItem({
1354
+ command: 'claude-code-sessions:open-branch',
1355
+ args: {
1356
+ session_id: b.session_id,
1357
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1358
+ }
1359
+ });
1360
+ }
1361
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1362
+ this._openBranchSubmenu.addItem({
1363
+ command: 'claude-code-sessions:switch-branch-more'
1364
+ });
1262
1365
  hasBranches = data.branches.length > 0;
1263
1366
  }
1264
1367
  catch (_a) {
@@ -1346,6 +1449,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
1346
1449
  body: bodyWidget,
1347
1450
  buttons: [Dialog.cancelButton()]
1348
1451
  });
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.
1455
+ const openButton = (sessionId) => {
1456
+ const btn = document.createElement('button');
1457
+ btn.type = 'button';
1458
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1459
+ btn.textContent = 'Open';
1460
+ btn.title = 'Open this conversation in its own terminal';
1461
+ btn.addEventListener('click', e => {
1462
+ e.stopPropagation();
1463
+ dialog.dispose();
1464
+ void this._openBranch(sessionId);
1465
+ });
1466
+ return btn;
1467
+ };
1349
1468
  const visibleMatches = () => {
1350
1469
  const needle = search.value.trim().toLowerCase();
1351
1470
  return items.filter(b => !needle ||
@@ -1393,6 +1512,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1393
1512
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1394
1513
  badge.textContent = 'current';
1395
1514
  currentRow.appendChild(badge);
1515
+ currentRow.appendChild(openButton(current));
1396
1516
  currentRow.appendChild(this._branchCopyButton(current));
1397
1517
  list.appendChild(currentRow);
1398
1518
  const matches = visibleMatches();
@@ -1450,6 +1570,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1450
1570
  time.className = 'jp-ClaudeSessionsPanel-branchTime';
1451
1571
  time.textContent = this._formatRelativeTime(b.file_mtime);
1452
1572
  row.appendChild(time);
1573
+ row.appendChild(openButton(b.session_id));
1453
1574
  row.appendChild(this._branchCopyButton(b.session_id));
1454
1575
  row.addEventListener('click', () => {
1455
1576
  // Selection mode: while anything is ticked, row clicks toggle
@@ -1660,7 +1781,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1660
1781
  name: launched.terminal_name
1661
1782
  });
1662
1783
  if (widget === null || widget === void 0 ? void 0 : widget.id) {
1663
- this._terminalsByPath.set(session.project_path, widget);
1784
+ // The terminal runs the FORK (forkId), so tag it with that id - a
1785
+ // later click on the now-current forked row reuses this terminal.
1786
+ this._terminalsByPath.set(session.project_path, {
1787
+ widget,
1788
+ sessionId: forkId
1789
+ });
1664
1790
  this._wireTerminalDisposal(session.project_path, widget);
1665
1791
  this._focusTerminal(widget);
1666
1792
  }
@@ -1673,8 +1799,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1673
1799
  spinner.dispose();
1674
1800
  }
1675
1801
  // 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.
1802
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
1803
+ // so watch for it at a fast cadence and refresh the moment it appears -
1804
+ // the branch surfaces in seconds instead of on the next slow poll.
1805
+ this._watchForBranch(session.encoded_path, forkId);
1678
1806
  }
1679
1807
  /** Switch the active row's project to another conversation branch.
1680
1808
  * The backend touches the branch JSONL's mtime; a refresh then shows
@@ -1709,6 +1837,60 @@ export class ClaudeCodeSessionsWidget extends Widget {
1709
1837
  await this._fetch();
1710
1838
  }
1711
1839
  }
1840
+ /** Open a specific conversation branch in its own terminal.
1841
+ *
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. */
1847
+ async _openBranch(sessionId) {
1848
+ const active = this._activeSession;
1849
+ if (!active || !sessionId) {
1850
+ return;
1851
+ }
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
+ }
1860
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
1861
+ *
1862
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
1863
+ * just-requested branch does not exist at launch and would otherwise only
1864
+ * surface on the next 30s poll. This watches the project's branch list
1865
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
1866
+ * or in the list) - then does one full refresh and stops - or until a
1867
+ * bounded number of attempts elapses. The panel is never updated before
1868
+ * the branch genuinely exists. */
1869
+ _watchForBranch(encodedPath, forkId) {
1870
+ let attempts = 0;
1871
+ const tick = async () => {
1872
+ if (this.isDisposed) {
1873
+ return;
1874
+ }
1875
+ attempts += 1;
1876
+ try {
1877
+ const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`, this._serverSettings, { cache: 'no-store' });
1878
+ const appeared = data.current === forkId ||
1879
+ data.branches.some(b => b.session_id === forkId);
1880
+ if (appeared) {
1881
+ await this._fetch();
1882
+ return;
1883
+ }
1884
+ }
1885
+ catch (_a) {
1886
+ // Transient failure - keep watching until the attempt budget runs out.
1887
+ }
1888
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
1889
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1890
+ }
1891
+ };
1892
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1893
+ }
1712
1894
  // --------------------------------------------------------------- polling
1713
1895
  _startPolling() {
1714
1896
  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.26",
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",
@@ -506,6 +506,141 @@ describe('launch spinner dismiss contract', () => {
506
506
  });
507
507
  });
508
508
 
509
+ /**
510
+ * 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.
515
+ */
516
+ describe('conversation-aware reuse + open-branch contract', () => {
517
+ const findTerm = (widgetSrc.match(
518
+ /private async _findTerminalForCwd[\s\S]*?\n \}/
519
+ ) ?? [''])[0];
520
+ const doResume = (widgetSrc.match(
521
+ /private async _doResumeInTerminal[\s\S]*?\n \}/
522
+ ) ?? [''])[0];
523
+ const resume = (widgetSrc.match(
524
+ /private async _resumeInTerminal[\s\S]*?\n \}/
525
+ ) ?? [''])[0];
526
+ const openBranch = (widgetSrc.match(
527
+ /private async _openBranch[\s\S]*?\n \}/
528
+ ) ?? [''])[0];
529
+ const watch = (widgetSrc.match(/private _watchForBranch[\s\S]*?\n \}/) ?? [
530
+ ''
531
+ ])[0];
532
+
533
+ it('terminal-cwd response carries the running conversation id', () => {
534
+ const iface = (widgetSrc.match(
535
+ /interface ITerminalCwdResponse \{[\s\S]*?\}/
536
+ ) ?? [''])[0];
537
+ expect(iface).toMatch(/session_id\?: string \| null/);
538
+ });
539
+
540
+ it('_findTerminalForCwd takes the wanted id and a strict flag', () => {
541
+ expect(findTerm).toMatch(
542
+ /_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined,\s*strict: boolean/
543
+ );
544
+ });
545
+
546
+ it('reuse rejects a terminal running a known different conversation', () => {
547
+ // Same conversation, or unknown (fresh) only in lenient mode, may reuse.
548
+ expect(findTerm).toMatch(/const runningId = data\.session_id \?\? null/);
549
+ 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;/
555
+ );
556
+ });
557
+
558
+ it('microcache reuse is gated on the conversation id', () => {
559
+ expect(doResume).toMatch(/cached\.sessionId === session\.session_id/);
560
+ expect(doResume).toMatch(/!strict && unknownConversation/);
561
+ });
562
+
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).
566
+ expect(doResume).toMatch(
567
+ /widget: found\.widget,\s*sessionId: found\.runningId \?\? undefined/
568
+ );
569
+ expect(findTerm).toMatch(/return \{ widget, runningId \}/);
570
+ });
571
+
572
+ it('in-flight launches are keyed per conversation, not per project', () => {
573
+ expect(resume).toMatch(
574
+ /const key = `\$\{session\.project_path\}\\n\$\{session\.session_id \?\? ''\}`/
575
+ );
576
+ expect(resume).toMatch(/this\._pendingByPath\.(get|set)\(key/);
577
+ });
578
+
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
+ );
586
+ expect(openBranch).toMatch(
587
+ /this\._resumeInTerminal\(\s*\{ \.\.\.active, session_id: sessionId \},\s*false,\s*strict\s*\)/
588
+ );
589
+ });
590
+
591
+ it('open-branch command and submenu are wired up', () => {
592
+ expect(widgetSrc).toMatch(/'claude-code-sessions:open-branch'/);
593
+ expect(widgetSrc).toMatch(
594
+ /this\._openBranchSubmenu\.title\.label = 'Open Branched Conversation'/
595
+ );
596
+ // Top 5 branches populate the open submenu, each via the open command.
597
+ const populate = (widgetSrc.match(
598
+ /this\._openBranchSubmenu\.clearItems[\s\S]*?switch-branch-more'\s*\}\);/
599
+ ) ?? [''])[0];
600
+ expect(populate).toMatch(/data\.branches\.slice\(0, 5\)/);
601
+ expect(populate).toMatch(/command: 'claude-code-sessions:open-branch'/);
602
+ // The context menu shows the open submenu when the row has branches.
603
+ const rebuild = (widgetSrc.match(
604
+ /private _rebuildContextMenu[\s\S]*?\n \}/
605
+ ) ?? [''])[0];
606
+ expect(rebuild).toMatch(/submenu: this\._openBranchSubmenu/);
607
+ });
608
+
609
+ it('popup rows carry an Open button that launches the branch', () => {
610
+ const popup = (widgetSrc.match(
611
+ /private _showBranchPopup[\s\S]*?\n \}\n/
612
+ ) ?? [''])[0];
613
+ expect(popup).toMatch(/jp-ClaudeSessionsPanel-branchOpen/);
614
+ expect(popup).toMatch(/void this\._openBranch\(sessionId\)/);
615
+ // Open appears on both the current row and each branch row.
616
+ expect(popup).toMatch(/openButton\(current\)/);
617
+ expect(popup).toMatch(/openButton\(b\.session_id\)/);
618
+ });
619
+
620
+ it('a new branch is watched for and surfaces fast, not on the slow poll', () => {
621
+ expect(watch).toMatch(/data\.current === forkId/);
622
+ expect(watch).toMatch(/b\.session_id === forkId/);
623
+ expect(watch).toMatch(/await this\._fetch\(\)/);
624
+ expect(watch).toMatch(/BRANCH_WATCH_MAX_ATTEMPTS/);
625
+ // Fork launch arms the watcher.
626
+ const fork = (widgetSrc.match(
627
+ /private async _branchSession[\s\S]*?\n \}/
628
+ ) ?? [''])[0];
629
+ expect(fork).toMatch(
630
+ /this\._watchForBranch\(session\.encoded_path, forkId\)/
631
+ );
632
+ expect(widgetSrc).toMatch(/BRANCH_WATCH_INTERVAL_MS = 2_000/);
633
+ });
634
+
635
+ it('the Open button is styled in base.css', () => {
636
+ const css: string = fs.readFileSync(
637
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
638
+ 'utf-8'
639
+ );
640
+ expect(css).toMatch(/\.jp-ClaudeSessionsPanel-branchOpen \{/);
641
+ });
642
+ });
643
+
509
644
  it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
510
645
  expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
511
646
  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
@@ -544,53 +551,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
544
551
 
545
552
  private async _resumeInTerminal(
546
553
  session: ISession,
547
- forceDangerous: boolean = false
554
+ forceDangerous: boolean = false,
555
+ strict: boolean = false
548
556
  ): Promise<void> {
549
- // Coalesce concurrent clicks on the 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);
557
+ // Coalesce concurrent clicks on the SAME conversation - subsequent clicks
558
+ // attach to the in-flight promise instead of creating their own terminal.
559
+ // The key is per-conversation, so opening a different branch of the same
560
+ // project launches independently rather than coalescing onto this one.
561
+ const key = `${session.project_path}\n${session.session_id ?? ''}`;
562
+ const inFlight = this._pendingByPath.get(key);
552
563
  if (inFlight) {
553
564
  return inFlight;
554
565
  }
555
- const promise = this._doResumeInTerminal(session, forceDangerous).finally(
556
- () => {
557
- this._pendingByPath.delete(session.project_path);
558
- }
559
- );
560
- this._pendingByPath.set(session.project_path, promise);
566
+ const promise = this._doResumeInTerminal(
567
+ session,
568
+ forceDangerous,
569
+ strict
570
+ ).finally(() => {
571
+ this._pendingByPath.delete(key);
572
+ });
573
+ this._pendingByPath.set(key, promise);
561
574
  return promise;
562
575
  }
563
576
 
577
+ /**
578
+ * Reuse an open terminal only when it is running the conversation the
579
+ * caller wants; otherwise launch a fresh ``claude --resume <id>``.
580
+ *
581
+ * ``strict`` selects how a cwd-matching claude terminal whose conversation
582
+ * is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
583
+ * - lenient (row resume): reuse it. A fresh session's id is not in its
584
+ * argv, so this is the only way to refocus a just-started session, and
585
+ * it avoids ``claude --resume`` erroring with "already in use".
586
+ * - strict (Open Branched Conversation): never reuse an unknown terminal -
587
+ * the user picked a specific branch and must land in THAT conversation,
588
+ * so a fresh/foreign terminal is left alone and a new one is launched.
589
+ * A terminal whose resume id is KNOWN and differs is never reused in
590
+ * either mode - that is the switch-then-click bug this fixes.
591
+ */
564
592
  private async _doResumeInTerminal(
565
593
  session: ISession,
566
- forceDangerous: boolean
594
+ forceDangerous: boolean,
595
+ strict: boolean
567
596
  ): Promise<void> {
568
597
  try {
569
- // Always prefer reusing an open terminal for this project. The
598
+ // Always prefer reusing an open terminal for this conversation. The
570
599
  // skip-permissions flag can only be applied to a fresh pty, never
571
600
  // retroactively. So if the user wants dangerous mode but an open
572
601
  // terminal already exists, show a modal asking them to close it
573
602
  // first - we won't auto-close, won't silently reuse the wrong mode.
574
603
 
575
- // 1. In-memory microcache.
604
+ // 1. In-memory microcache (most-recent terminal for this project).
576
605
  const cached = this._terminalsByPath.get(session.project_path);
577
- if (cached && !cached.isDisposed) {
578
- if (forceDangerous) {
579
- await this._showCloseExistingDialog();
606
+ if (cached && !cached.widget.isDisposed) {
607
+ const sameConversation = cached.sessionId === session.session_id;
608
+ const unknownConversation = !cached.sessionId;
609
+ if (sameConversation || (!strict && unknownConversation)) {
610
+ if (forceDangerous) {
611
+ await this._showCloseExistingDialog();
612
+ }
613
+ this._focusTerminal(cached.widget);
614
+ return;
580
615
  }
581
- this._focusTerminal(cached);
582
- return;
583
616
  }
584
617
 
585
618
  // 2. Walk every live terminal widget JL knows about.
586
- const found = await this._findTerminalForCwd(session.project_path);
619
+ const found = await this._findTerminalForCwd(
620
+ session.project_path,
621
+ session.session_id,
622
+ strict
623
+ );
587
624
  if (found) {
588
- this._terminalsByPath.set(session.project_path, found);
589
- this._wireTerminalDisposal(session.project_path, 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.
629
+ this._terminalsByPath.set(session.project_path, {
630
+ widget: found.widget,
631
+ sessionId: found.runningId ?? undefined
632
+ });
633
+ this._wireTerminalDisposal(session.project_path, found.widget);
590
634
  if (forceDangerous) {
591
635
  await this._showCloseExistingDialog();
592
636
  }
593
- this._focusTerminal(found);
637
+ this._focusTerminal(found.widget);
594
638
  return;
595
639
  }
596
640
 
@@ -620,7 +664,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
620
664
  name: launched.terminal_name
621
665
  });
622
666
  if (widget?.id) {
623
- this._terminalsByPath.set(session.project_path, widget);
667
+ this._terminalsByPath.set(session.project_path, {
668
+ widget,
669
+ sessionId: session.session_id
670
+ });
624
671
  this._wireTerminalDisposal(session.project_path, widget);
625
672
  this._focusTerminal(widget);
626
673
  }
@@ -673,7 +720,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
673
720
  name: launched.terminal_name
674
721
  });
675
722
  if (widget?.id) {
676
- this._terminalsByPath.set(projectPath, widget);
723
+ // A fresh session's conversation id is not in its argv, so leave
724
+ // sessionId undefined - reuse treats it as the project's current.
725
+ this._terminalsByPath.set(projectPath, {
726
+ widget,
727
+ sessionId: undefined
728
+ });
677
729
  this._wireTerminalDisposal(projectPath, widget);
678
730
  this._focusTerminal(widget);
679
731
  }
@@ -711,7 +763,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
711
763
  });
712
764
  }
713
765
 
714
- private async _findTerminalForCwd(projectPath: string): Promise<any | null> {
766
+ private async _findTerminalForCwd(
767
+ projectPath: string,
768
+ wantedSessionId: string | undefined,
769
+ strict: boolean
770
+ ): Promise<{ widget: any; runningId: string | null } | null> {
715
771
  if (!this._terminalTracker) {
716
772
  return null;
717
773
  }
@@ -738,10 +794,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
738
794
  if (!data?.has_claude) {
739
795
  continue;
740
796
  }
797
+ // Conversation gate: reuse only a terminal running the wanted
798
+ // conversation. A known, different resume id is never reused (the
799
+ // switch-then-click bug). An unknown id (fresh session, no --resume)
800
+ // is reused in lenient mode but skipped in strict mode, where the
801
+ // user picked a specific branch to open.
802
+ const runningId = data.session_id ?? null;
803
+ const sameConversation = runningId === wantedSessionId;
804
+ const unknownConversation = runningId === null;
805
+ if (!(sameConversation || (!strict && unknownConversation))) {
806
+ continue;
807
+ }
741
808
  const cwds = Array.isArray(data?.cwds) ? data.cwds : [];
742
809
  for (const cwd of cwds) {
743
810
  if ((cwd || '').replace(/\/+$/, '') === target) {
744
- return widget;
811
+ // Return the OBSERVED running id (may be null for a fresh
812
+ // terminal), never the wanted id - the caller tags its cache
813
+ // with this so a later strict reuse trusts the process, not a
814
+ // wish.
815
+ return { widget, runningId };
745
816
  }
746
817
  }
747
818
  } catch (_err) {
@@ -792,7 +863,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
792
863
  return;
793
864
  }
794
865
  widget.disposed.connect(() => {
795
- if (this._terminalsByPath.get(projectPath) === widget) {
866
+ if (this._terminalsByPath.get(projectPath)?.widget === widget) {
796
867
  this._terminalsByPath.delete(projectPath);
797
868
  }
798
869
  });
@@ -1311,6 +1382,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
1311
1382
  }
1312
1383
  });
1313
1384
 
1385
+ this._commands.addCommand('claude-code-sessions:open-branch', {
1386
+ label: args => String(args.label ?? ''),
1387
+ icon: terminalIcon,
1388
+ execute: args => {
1389
+ const sessionId = String(args.session_id ?? '');
1390
+ if (sessionId) {
1391
+ void this._openBranch(sessionId);
1392
+ }
1393
+ }
1394
+ });
1395
+
1314
1396
  this._commands.addCommand('claude-code-sessions:branch-session', {
1315
1397
  label: 'Normal',
1316
1398
  execute: () => void this._branchSession(false)
@@ -1362,6 +1444,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
1362
1444
  this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1363
1445
  this._branchSubmenu.title.icon = switchIcon;
1364
1446
 
1447
+ // Submenu that OPENS a conversation directly in its own terminal (vs the
1448
+ // switch submenu, which only changes which branch the row points at).
1449
+ // Several branches can be open at once, independently.
1450
+ this._openBranchSubmenu = new Menu({ commands: this._commands });
1451
+ this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1452
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1453
+ this._openBranchSubmenu.title.icon = terminalIcon;
1454
+
1365
1455
  // Submenu grouping the two branch-session launch modes.
1366
1456
  this._branchSessionMenu = new Menu({ commands: this._commands });
1367
1457
  this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1411,6 +1501,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1411
1501
  });
1412
1502
  this._contextMenu.addItem({ type: 'separator' });
1413
1503
  if (withBranches) {
1504
+ this._contextMenu.addItem({
1505
+ type: 'submenu',
1506
+ submenu: this._openBranchSubmenu
1507
+ });
1414
1508
  this._contextMenu.addItem({
1415
1509
  type: 'submenu',
1416
1510
  submenu: this._branchSubmenu
@@ -1462,6 +1556,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
1462
1556
  this._branchSubmenu.addItem({
1463
1557
  command: 'claude-code-sessions:switch-branch-more'
1464
1558
  });
1559
+
1560
+ // Open submenu: same top-5 branches, but each launches its own
1561
+ // terminal directly. Falls through to the Manage Sessions popup for
1562
+ // the full list (from which any conversation can also be opened).
1563
+ this._openBranchSubmenu.clearItems();
1564
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1565
+ for (const b of data.branches.slice(0, 5)) {
1566
+ this._openBranchSubmenu.addItem({
1567
+ command: 'claude-code-sessions:open-branch',
1568
+ args: {
1569
+ session_id: b.session_id,
1570
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1571
+ }
1572
+ });
1573
+ }
1574
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1575
+ this._openBranchSubmenu.addItem({
1576
+ command: 'claude-code-sessions:switch-branch-more'
1577
+ });
1465
1578
  hasBranches = data.branches.length > 0;
1466
1579
  } catch {
1467
1580
  hasBranches = false;
@@ -1558,6 +1671,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
1558
1671
  buttons: [Dialog.cancelButton()]
1559
1672
  });
1560
1673
 
1674
+ // Per-row "Open" launches that conversation in its own terminal (strict
1675
+ // reuse via _openBranch) and closes the popup. stopPropagation keeps the
1676
+ // click from toggling selection or switching the row.
1677
+ const openButton = (sessionId: string): HTMLButtonElement => {
1678
+ const btn = document.createElement('button');
1679
+ btn.type = 'button';
1680
+ btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
1681
+ btn.textContent = 'Open';
1682
+ btn.title = 'Open this conversation in its own terminal';
1683
+ btn.addEventListener('click', e => {
1684
+ e.stopPropagation();
1685
+ dialog.dispose();
1686
+ void this._openBranch(sessionId);
1687
+ });
1688
+ return btn;
1689
+ };
1690
+
1561
1691
  const visibleMatches = (): IBranch[] => {
1562
1692
  const needle = search.value.trim().toLowerCase();
1563
1693
  return items.filter(
@@ -1613,6 +1743,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1613
1743
  badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1614
1744
  badge.textContent = 'current';
1615
1745
  currentRow.appendChild(badge);
1746
+ currentRow.appendChild(openButton(current));
1616
1747
  currentRow.appendChild(this._branchCopyButton(current));
1617
1748
  list.appendChild(currentRow);
1618
1749
 
@@ -1677,6 +1808,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1677
1808
  time.textContent = this._formatRelativeTime(b.file_mtime);
1678
1809
  row.appendChild(time);
1679
1810
 
1811
+ row.appendChild(openButton(b.session_id));
1680
1812
  row.appendChild(this._branchCopyButton(b.session_id));
1681
1813
 
1682
1814
  row.addEventListener('click', () => {
@@ -1902,7 +2034,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1902
2034
  name: launched.terminal_name
1903
2035
  });
1904
2036
  if (widget?.id) {
1905
- this._terminalsByPath.set(session.project_path, widget);
2037
+ // The terminal runs the FORK (forkId), so tag it with that id - a
2038
+ // later click on the now-current forked row reuses this terminal.
2039
+ this._terminalsByPath.set(session.project_path, {
2040
+ widget,
2041
+ sessionId: forkId
2042
+ });
1906
2043
  this._wireTerminalDisposal(session.project_path, widget);
1907
2044
  this._focusTerminal(widget);
1908
2045
  }
@@ -1913,8 +2050,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1913
2050
  spinner.dispose();
1914
2051
  }
1915
2052
  // 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.
2053
+ // on the fork's first turn. claude materialises the fork's JSONL lazily,
2054
+ // so watch for it at a fast cadence and refresh the moment it appears -
2055
+ // the branch surfaces in seconds instead of on the next slow poll.
2056
+ this._watchForBranch(session.encoded_path, forkId);
1918
2057
  }
1919
2058
 
1920
2059
  /** Switch the active row's project to another conversation branch.
@@ -1960,6 +2099,70 @@ export class ClaudeCodeSessionsWidget extends Widget {
1960
2099
  }
1961
2100
  }
1962
2101
 
2102
+ /** Open a specific conversation branch in its own terminal.
2103
+ *
2104
+ * Strict reuse: only a terminal already running THIS conversation is
2105
+ * refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
2106
+ * several branches of one project can be open independently and side by
2107
+ * side - opening branch B never disturbs branch A's terminal. Honours the
2108
+ * global skip-permissions toggle like a normal resume. */
2109
+ private async _openBranch(sessionId: string): Promise<void> {
2110
+ const active = this._activeSession;
2111
+ if (!active || !sessionId) {
2112
+ return;
2113
+ }
2114
+ // Opening the row's CURRENT conversation behaves like a normal row resume
2115
+ // (lenient): a fresh terminal already running it is reused, avoiding a
2116
+ // `claude --resume` "already in use" collision. Opening a DIFFERENT branch
2117
+ // is strict so the user lands in that specific conversation, even if some
2118
+ // other (fresh) terminal sits at the same cwd.
2119
+ const strict = sessionId !== active.session_id;
2120
+ await this._resumeInTerminal(
2121
+ { ...active, session_id: sessionId },
2122
+ false,
2123
+ strict
2124
+ );
2125
+ }
2126
+
2127
+ /** Poll fast for a freshly forked branch to materialise, then refresh.
2128
+ *
2129
+ * claude writes a forked session's JSONL lazily (on its first turn), so a
2130
+ * just-requested branch does not exist at launch and would otherwise only
2131
+ * surface on the next 30s poll. This watches the project's branch list
2132
+ * every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
2133
+ * or in the list) - then does one full refresh and stops - or until a
2134
+ * bounded number of attempts elapses. The panel is never updated before
2135
+ * the branch genuinely exists. */
2136
+ private _watchForBranch(encodedPath: string, forkId: string): void {
2137
+ let attempts = 0;
2138
+ const tick = async (): Promise<void> => {
2139
+ if (this.isDisposed) {
2140
+ return;
2141
+ }
2142
+ attempts += 1;
2143
+ try {
2144
+ const data = await requestAPI<IBranchesResponse>(
2145
+ `sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`,
2146
+ this._serverSettings,
2147
+ { cache: 'no-store' }
2148
+ );
2149
+ const appeared =
2150
+ data.current === forkId ||
2151
+ data.branches.some(b => b.session_id === forkId);
2152
+ if (appeared) {
2153
+ await this._fetch();
2154
+ return;
2155
+ }
2156
+ } catch {
2157
+ // Transient failure - keep watching until the attempt budget runs out.
2158
+ }
2159
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
2160
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2161
+ }
2162
+ };
2163
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
2164
+ }
2165
+
1963
2166
  // --------------------------------------------------------------- polling
1964
2167
 
1965
2168
  private _startPolling(): void {
@@ -1994,6 +2197,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1994
2197
  private _commands!: CommandRegistry;
1995
2198
  private _contextMenu!: Menu;
1996
2199
  private _branchSubmenu!: Menu;
2200
+ private _openBranchSubmenu!: Menu;
1997
2201
  private _branchSessionMenu!: Menu;
1998
2202
  private _lastBranches: IBranch[] = [];
1999
2203
  private _lastBranchesCurrent = '';
@@ -2004,7 +2208,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
2004
2208
  private readonly _removingPaths: Set<string> = new Set();
2005
2209
  private readonly _terminalTracker: ITerminalTracker | null;
2006
2210
  private readonly _fileBrowser: IDefaultFileBrowser | null;
2007
- private readonly _terminalsByPath: Map<string, any> = new Map();
2211
+ // Microcache of the most-recent terminal per project, tagged with the
2212
+ // conversation it is running so reuse can tell a project's branches apart.
2213
+ private readonly _terminalsByPath: Map<
2214
+ string,
2215
+ { widget: any; sessionId?: string }
2216
+ > = new Map();
2217
+ // In-flight launches, keyed per CONVERSATION (path + session id) so two
2218
+ // different branches of one project can open independently and concurrently.
2008
2219
  private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
2009
2220
  private readonly _rootDir: string;
2010
2221
  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;