jupyterlab_claude_code_extension 1.2.5 → 1.2.9

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
@@ -8,10 +8,21 @@
8
8
  [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
9
  [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
10
 
11
- Browse, resume, and manage your Claude Code sessions from a JupyterLab side panel. One click reactivates the right terminal, no duplicate tabs, with a live indicator showing which sessions are currently active.
11
+ A full Claude Code launcher and manager for JupyterLab. Start, resume, fork, switch, and clean up Claude Code CLI sessions from a side panel - one click lands you in the right terminal with Claude already running, no duplicate tabs, no UUID hunting, with a live indicator showing which sessions are active right now.
12
12
 
13
13
  ![Claude Code Sessions panel](.resources/screenshot.png)
14
14
 
15
+ ## Why this extension
16
+
17
+ One principle: **Anthropic knows best how to build the agent harness; we know best how to make it work in JupyterLab.**
18
+
19
+ Chat-panel extensions re-implement the agent loop and trail the real tool. This one runs the genuine, unmodified Claude Code CLI in JupyterLab terminals - skills, subagents, MCP, hooks, plan mode, every release the day it lands. The extension owns the JupyterLab side:
20
+
21
+ - **Launching** - new, resumed, or forked sessions, with or without permission prompts, no wrapper shell, correctly sized before Claude draws its first frame
22
+ - **Finding** - every Claude project in one panel: favourites, search, live activity
23
+ - **Reusing** - clicking a session focuses its existing terminal, never a duplicate
24
+ - **Managing** - parallel conversations: switch, fork with a name, delete - no `--resume` pickers, no raw UUIDs
25
+
15
26
  ## Features
16
27
 
17
28
  - **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
@@ -20,7 +31,9 @@ Browse, resume, and manage your Claude Code sessions from a JupyterLab side pane
20
31
  - **Favorites** - star projects you keep coming back to via the right-click menu
21
32
  - **Remove** - drop a project's Claude history from the panel via the right-click menu; the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
22
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; removed files honour the same trash setting
23
- - **Switch conversation branch** - a right-click submenu lists a project's other conversations (5 most recent, with a searchable "More..." popup for longer lists) and switches the row's current one; rows with multiple conversations show the count in brackets after the name, e.g. `workspace (2)`
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 over the full list where conversations can also be deleted - select one, many, or all via checkboxes, then confirm with a two-step Delete button (removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
35
+ - **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
+ - **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
24
37
  - **Search** - fuzzy filter toggled by the funnel button next to refresh
25
38
  - **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
26
39
  - **Hover tooltip** with project path, last activity, message count, branch, and session id
package/lib/icons.d.ts CHANGED
@@ -5,4 +5,5 @@ export declare const refreshIcon: LabIcon;
5
5
  export declare const removeIcon: LabIcon;
6
6
  export declare const shieldIcon: LabIcon;
7
7
  export declare const addIcon: LabIcon;
8
+ export declare const branchIcon: LabIcon;
8
9
  export declare const filterIcon: LabIcon;
package/lib/icons.js CHANGED
@@ -58,6 +58,15 @@ export const addIcon = new LabIcon({
58
58
  name: 'jupyterlab_claude_code_extension:add',
59
59
  svgstr: addSvgStr
60
60
  });
61
+ // Git-branch glyph (Octicons git-branch-16, MIT) - marks rows that carry
62
+ // parallel conversations and the Branch Session menu entries.
63
+ const branchSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
64
+ <path class="jp-icon3" fill="#616161" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
65
+ </svg>`;
66
+ export const branchIcon = new LabIcon({
67
+ name: 'jupyterlab_claude_code_extension:branch',
68
+ svgstr: branchSvgStr
69
+ });
61
70
  // Funnel copied verbatim from @jupyterlab/ui-components'
62
71
  // `search/filter.svg` - the same image the file browser's filter
63
72
  // toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
package/lib/types.d.ts CHANGED
@@ -60,6 +60,13 @@ export interface ISwitchResponse {
60
60
  requested: string;
61
61
  current: string | null;
62
62
  }
63
+ export interface IDeleteBranchesRequest {
64
+ encoded_path: string;
65
+ session_ids: string[];
66
+ }
67
+ export interface IDeleteBranchesResponse {
68
+ removed_count: number;
69
+ }
63
70
  export interface ILaunchTerminalRequest {
64
71
  project_path: string;
65
72
  /** Omit to start a brand-new claude session instead of resuming one. */
package/lib/widget.d.ts CHANGED
@@ -108,9 +108,30 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
108
108
  * the project has more than one conversation. On a fetch failure the
109
109
  * menu opens without the submenu. */
110
110
  private _openContextMenu;
111
- /** Popup with the project's full branch list - browse and filter when
112
- * the list is too large for the submenu. Clicking an entry switches. */
111
+ /** Popup with the project's full branch list - browse, filter, switch
112
+ * and manage. Clicking an entry switches while nothing is selected;
113
+ * checkbox selection (one, many, or select-all) arms a two-step Delete
114
+ * button that removes the chosen sessions. The current conversation is
115
+ * shown first, badged and untouchable. */
113
116
  private _showBranchPopup;
117
+ /** Delete the given branch sessions of the active row's project.
118
+ * Returns the removed count, or null on failure (after notifying).
119
+ * Always resyncs the panel so the row's conversation count drops. */
120
+ private _deleteBranches;
121
+ /** Fork the active row's current conversation into a new named branch.
122
+ *
123
+ * Asks for a name, then launches a terminal running
124
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
125
+ * the uuid is generated here so the forked JSONL is known up front. Once
126
+ * claude materialises the file (polled via sessions/set-title) the chosen
127
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
128
+ * so the recency resolution makes it the row's current conversation
129
+ * without an explicit switch.
130
+ */
131
+ private _branchSession;
132
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
133
+ * does not), then refresh so the row shows the named fork as current. */
134
+ private _stampForkTitle;
114
135
  /** Switch the active row's project to another conversation branch.
115
136
  * The backend touches the branch JSONL's mtime; a refresh then shows
116
137
  * the selected conversation as the row's current one. */
@@ -129,6 +150,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
129
150
  private _contextMenu;
130
151
  private _branchSubmenu;
131
152
  private _lastBranches;
153
+ private _lastBranchesCurrent;
132
154
  private _newSessionMenu;
133
155
  private _activeSession;
134
156
  private _activeRowEl;
package/lib/widget.js CHANGED
@@ -1,10 +1,11 @@
1
- import { Clipboard, Dialog, Notification, showDialog } from '@jupyterlab/apputils';
1
+ import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
2
2
  import { ServerConnection } from '@jupyterlab/services';
3
3
  import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
4
4
  import { CommandRegistry } from '@lumino/commands';
5
+ import { UUID } from '@lumino/coreutils';
5
6
  import { Menu, Widget } from '@lumino/widgets';
6
7
  import { requestAPI } from './request';
7
- import { addIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
8
+ import { addIcon, branchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
8
9
  const POLL_INTERVAL_MS = 30000;
9
10
  const DEFAULT_RECENT_LIMIT = 10;
10
11
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
@@ -65,6 +66,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
65
66
  this._sessions = null;
66
67
  this._expanded = loadExpanded();
67
68
  this._lastBranches = [];
69
+ this._lastBranchesCurrent = '';
68
70
  this._activeSession = null;
69
71
  this._activeRowEl = null;
70
72
  this._pollHandle = null;
@@ -783,6 +785,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
783
785
  const row = document.createElement('div');
784
786
  row.className = 'jp-ClaudeSessionsPanel-row';
785
787
  row.title = this._buildRowTooltip(session);
788
+ // Age emphasis: active within the last minute reads bright, idle for
789
+ // over a week dims; the state decays/promotes on the next refresh.
790
+ if (session.file_mtime) {
791
+ const age = Date.now() - session.file_mtime;
792
+ if (age < 60000) {
793
+ row.classList.add('jp-mod-recentlyActive');
794
+ }
795
+ else if (age > 7 * 86400000) {
796
+ row.classList.add('jp-mod-stale');
797
+ }
798
+ }
786
799
  const removing = this._removingPaths.has(session.encoded_path);
787
800
  if (removing) {
788
801
  row.classList.add('jp-mod-busy');
@@ -806,20 +819,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
806
819
  }
807
820
  const name = document.createElement('span');
808
821
  name.className = 'jp-ClaudeSessionsPanel-name';
809
- // Conversation count in brackets - only when the project has branches.
810
- name.textContent =
811
- session.extra_sessions > 0
812
- ? `${this._lookupName(session)} (${session.extra_sessions + 1})`
813
- : this._lookupName(session);
814
- row.appendChild(name);
815
- if (session.file_mtime) {
816
- const time = document.createElement('span');
817
- time.className = 'jp-ClaudeSessionsPanel-rowTime';
818
- time.textContent = this._formatRelativeTime(session.file_mtime);
819
- row.appendChild(time);
822
+ name.textContent = this._lookupName(session);
823
+ // Branch icon + total conversation count - only when the project has
824
+ // branches. Lives inside the name span so it hugs the label text
825
+ // instead of being flexed to the row's right edge.
826
+ if (session.extra_sessions > 0) {
827
+ const badge = document.createElement('span');
828
+ badge.className = 'jp-ClaudeSessionsPanel-branchBadge';
829
+ const icon = document.createElement('span');
830
+ icon.className = 'jp-ClaudeSessionsPanel-branchBadgeIcon';
831
+ branchIcon.element({ container: icon });
832
+ badge.appendChild(icon);
833
+ badge.appendChild(document.createTextNode(String(session.extra_sessions + 1)));
834
+ name.appendChild(badge);
820
835
  }
836
+ row.appendChild(name);
821
837
  // No star in the Favorites section - every row there is a favorite
822
838
  // by definition; stars are an indicator only useful in Recent/All.
839
+ // Star sits before the time so the fixed-width time column stays the
840
+ // rightmost alignment anchor across all rows.
823
841
  if (session.favourite && sectionKey !== 'favourites') {
824
842
  const star = document.createElement('span');
825
843
  star.className = 'jp-ClaudeSessionsPanel-favStar';
@@ -827,6 +845,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
827
845
  starFilledIcon.element({ container: star });
828
846
  row.appendChild(star);
829
847
  }
848
+ if (session.file_mtime) {
849
+ const time = document.createElement('span');
850
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
851
+ time.textContent = this._formatRelativeTime(session.file_mtime);
852
+ row.appendChild(time);
853
+ }
830
854
  row.addEventListener('click', () => {
831
855
  if (removing) {
832
856
  return;
@@ -1043,11 +1067,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1043
1067
  }
1044
1068
  });
1045
1069
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1046
- label: () => `More... (${this._lastBranches.length} total)`,
1070
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1047
1071
  execute: () => {
1048
- void this._showBranchPopup(this._lastBranches);
1072
+ void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1049
1073
  }
1050
1074
  });
1075
+ this._commands.addCommand('claude-code-sessions:branch-session', {
1076
+ label: 'Branch Session...',
1077
+ icon: branchIcon,
1078
+ execute: () => void this._branchSession(false)
1079
+ });
1080
+ this._commands.addCommand('claude-code-sessions:branch-session-dangerous', {
1081
+ label: 'Branch Session (Skip Permissions)...',
1082
+ icon: shieldIcon,
1083
+ execute: () => void this._branchSession(true)
1084
+ });
1051
1085
  this._commands.addCommand('claude-code-sessions:remove', {
1052
1086
  label: 'Remove from Claude',
1053
1087
  icon: removeIcon,
@@ -1081,7 +1115,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1081
1115
  // sessions/branches fetch.
1082
1116
  this._branchSubmenu = new Menu({ commands: this._commands });
1083
1117
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1084
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1118
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1085
1119
  this._contextMenu = new Menu({ commands: this._commands });
1086
1120
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1087
1121
  this._rebuildContextMenu(false);
@@ -1119,6 +1153,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1119
1153
  submenu: this._branchSubmenu
1120
1154
  });
1121
1155
  }
1156
+ this._contextMenu.addItem({
1157
+ command: 'claude-code-sessions:branch-session'
1158
+ });
1159
+ this._contextMenu.addItem({
1160
+ command: 'claude-code-sessions:branch-session-dangerous'
1161
+ });
1122
1162
  this._contextMenu.addItem({
1123
1163
  command: 'claude-code-sessions:cleanup-parallel'
1124
1164
  });
@@ -1133,9 +1173,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1133
1173
  try {
1134
1174
  const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1135
1175
  this._lastBranches = data.branches;
1176
+ this._lastBranchesCurrent = data.current;
1136
1177
  this._branchSubmenu.clearItems();
1137
- // The submenu shows only the 5 most recent; the full list lives
1138
- // behind "More..." in a searchable popup.
1178
+ this._branchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
1179
+ // The submenu shows only the 5 most recent inline (fewest clicks
1180
+ // for often-used sessions); the full list plus management lives
1181
+ // behind the always-present "Manage Sessions..." popup.
1139
1182
  for (const b of data.branches.slice(0, 5)) {
1140
1183
  this._branchSubmenu.addItem({
1141
1184
  command: 'claude-code-sessions:switch-branch',
@@ -1145,12 +1188,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1145
1188
  }
1146
1189
  });
1147
1190
  }
1148
- if (data.branches.length > 5) {
1149
- this._branchSubmenu.addItem({ type: 'separator' });
1150
- this._branchSubmenu.addItem({
1151
- command: 'claude-code-sessions:switch-branch-more'
1152
- });
1153
- }
1191
+ this._branchSubmenu.addItem({ type: 'separator' });
1192
+ this._branchSubmenu.addItem({
1193
+ command: 'claude-code-sessions:switch-branch-more'
1194
+ });
1154
1195
  hasBranches = data.branches.length > 0;
1155
1196
  }
1156
1197
  catch (_a) {
@@ -1160,35 +1201,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
1160
1201
  this._rebuildContextMenu(hasBranches);
1161
1202
  this._contextMenu.open(x, y);
1162
1203
  }
1163
- /** Popup with the project's full branch list - browse and filter when
1164
- * the list is too large for the submenu. Clicking an entry switches. */
1165
- _showBranchPopup(branches) {
1204
+ /** Popup with the project's full branch list - browse, filter, switch
1205
+ * and manage. Clicking an entry switches while nothing is selected;
1206
+ * checkbox selection (one, many, or select-all) arms a two-step Delete
1207
+ * button that removes the chosen sessions. The current conversation is
1208
+ * shown first, badged and untouchable. */
1209
+ _showBranchPopup(branches, current) {
1210
+ // Local working copy so deletions can refresh the list in place.
1211
+ let items = [...branches];
1212
+ const selected = new Set();
1213
+ let confirmArmed = false;
1166
1214
  const body = document.createElement('div');
1167
1215
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1168
1216
  const search = document.createElement('input');
1169
1217
  search.type = 'search';
1170
- search.placeholder = 'Filter branches...';
1218
+ search.placeholder = 'Filter sessions...';
1171
1219
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1172
1220
  body.appendChild(search);
1221
+ const selectAllBar = document.createElement('label');
1222
+ selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1223
+ const selectAll = document.createElement('input');
1224
+ selectAll.type = 'checkbox';
1225
+ selectAllBar.appendChild(selectAll);
1226
+ selectAllBar.appendChild(document.createTextNode('Select all'));
1227
+ body.appendChild(selectAllBar);
1173
1228
  const list = document.createElement('div');
1174
1229
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1175
1230
  body.appendChild(list);
1231
+ const footer = document.createElement('div');
1232
+ footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1233
+ const deleteBtn = document.createElement('button');
1234
+ deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1235
+ footer.appendChild(deleteBtn);
1236
+ body.appendChild(footer);
1176
1237
  const bodyWidget = new Widget({ node: body });
1177
1238
  const dialog = new Dialog({
1178
- title: 'Switch Conversation Branch',
1239
+ title: 'Switch and Manage Sessions',
1179
1240
  body: bodyWidget,
1180
1241
  buttons: [Dialog.cancelButton()]
1181
1242
  });
1182
- const render = () => {
1243
+ const visibleMatches = () => {
1183
1244
  const needle = search.value.trim().toLowerCase();
1184
- list.replaceChildren();
1185
- const matches = branches.filter(b => !needle ||
1245
+ return items.filter(b => !needle ||
1186
1246
  b.label.toLowerCase().includes(needle) ||
1187
1247
  b.session_id.toLowerCase().includes(needle));
1248
+ };
1249
+ // Any selection change disarms a pending confirm.
1250
+ const updateControls = () => {
1251
+ confirmArmed = false;
1252
+ deleteBtn.disabled = selected.size === 0;
1253
+ deleteBtn.textContent = `Delete (${selected.size})`;
1254
+ deleteBtn.classList.remove('jp-mod-confirm');
1255
+ const visible = visibleMatches();
1256
+ const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
1257
+ selectAll.checked =
1258
+ visible.length > 0 && visibleSelected === visible.length;
1259
+ selectAll.indeterminate =
1260
+ visibleSelected > 0 && visibleSelected < visible.length;
1261
+ };
1262
+ const render = () => {
1263
+ list.replaceChildren();
1264
+ // The current conversation leads the list - badged, unselectable,
1265
+ // undeletable; only the extras below it are manageable.
1266
+ const currentRow = document.createElement('div');
1267
+ currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1268
+ currentRow.title = `Session id: ${current}`;
1269
+ const currentLabel = document.createElement('span');
1270
+ currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1271
+ const currentName = this._activeSession
1272
+ ? this._lookupName(this._activeSession)
1273
+ : current.slice(0, 8);
1274
+ currentLabel.textContent = `${currentName} (${current.slice(0, 8)})`;
1275
+ currentRow.appendChild(currentLabel);
1276
+ const badge = document.createElement('span');
1277
+ badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1278
+ badge.textContent = 'current';
1279
+ currentRow.appendChild(badge);
1280
+ list.appendChild(currentRow);
1281
+ const matches = visibleMatches();
1188
1282
  if (matches.length === 0) {
1189
1283
  const empty = document.createElement('div');
1190
1284
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1191
- empty.textContent = 'No matching branches.';
1285
+ empty.textContent = items.length
1286
+ ? 'No matching sessions.'
1287
+ : 'No other conversations.';
1192
1288
  list.appendChild(empty);
1193
1289
  return;
1194
1290
  }
@@ -1196,6 +1292,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1196
1292
  const row = document.createElement('div');
1197
1293
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1198
1294
  row.title = `Session id: ${b.session_id}`;
1295
+ const check = document.createElement('input');
1296
+ check.type = 'checkbox';
1297
+ check.checked = selected.has(b.session_id);
1298
+ // The checkbox is its own click zone - ticking must not switch.
1299
+ check.addEventListener('click', e => {
1300
+ e.stopPropagation();
1301
+ if (check.checked) {
1302
+ selected.add(b.session_id);
1303
+ }
1304
+ else {
1305
+ selected.delete(b.session_id);
1306
+ }
1307
+ updateControls();
1308
+ });
1309
+ row.appendChild(check);
1199
1310
  const label = document.createElement('span');
1200
1311
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1201
1312
  label.textContent = this._branchDisplayName(b);
@@ -1205,17 +1316,179 @@ export class ClaudeCodeSessionsWidget extends Widget {
1205
1316
  time.textContent = this._formatRelativeTime(b.file_mtime);
1206
1317
  row.appendChild(time);
1207
1318
  row.addEventListener('click', () => {
1319
+ // Selection mode: while anything is ticked, row clicks toggle
1320
+ // selection - no accidental switch mid-selection.
1321
+ if (selected.size > 0) {
1322
+ if (selected.has(b.session_id)) {
1323
+ selected.delete(b.session_id);
1324
+ }
1325
+ else {
1326
+ selected.add(b.session_id);
1327
+ }
1328
+ check.checked = selected.has(b.session_id);
1329
+ updateControls();
1330
+ return;
1331
+ }
1208
1332
  dialog.dispose();
1209
1333
  void this._switchBranch(b.session_id);
1210
1334
  });
1211
1335
  list.appendChild(row);
1212
1336
  }
1213
1337
  };
1214
- search.addEventListener('input', render);
1338
+ selectAll.addEventListener('change', () => {
1339
+ // Select-all acts on the visible (filtered) rows only.
1340
+ const visible = visibleMatches();
1341
+ if (selectAll.checked) {
1342
+ visible.forEach(b => selected.add(b.session_id));
1343
+ }
1344
+ else {
1345
+ visible.forEach(b => selected.delete(b.session_id));
1346
+ }
1347
+ render();
1348
+ updateControls();
1349
+ });
1350
+ deleteBtn.addEventListener('click', () => {
1351
+ if (selected.size === 0) {
1352
+ return;
1353
+ }
1354
+ if (!confirmArmed) {
1355
+ // Two-step delete: first click arms, second click executes.
1356
+ confirmArmed = true;
1357
+ deleteBtn.textContent = `Confirm delete (${selected.size})`;
1358
+ deleteBtn.classList.add('jp-mod-confirm');
1359
+ return;
1360
+ }
1361
+ void this._deleteBranches([...selected]).then(deleted => {
1362
+ if (deleted === null) {
1363
+ return;
1364
+ }
1365
+ items = items.filter(b => !selected.has(b.session_id));
1366
+ selected.clear();
1367
+ this._lastBranches = items;
1368
+ render();
1369
+ updateControls();
1370
+ });
1371
+ });
1372
+ search.addEventListener('input', () => {
1373
+ render();
1374
+ updateControls();
1375
+ });
1215
1376
  render();
1377
+ updateControls();
1216
1378
  void dialog.launch();
1217
1379
  search.focus();
1218
1380
  }
1381
+ /** Delete the given branch sessions of the active row's project.
1382
+ * Returns the removed count, or null on failure (after notifying).
1383
+ * Always resyncs the panel so the row's conversation count drops. */
1384
+ async _deleteBranches(sessionIds) {
1385
+ const session = this._activeSession;
1386
+ if (!session) {
1387
+ return null;
1388
+ }
1389
+ try {
1390
+ const result = await requestAPI('sessions/delete-branches', this._serverSettings, {
1391
+ method: 'POST',
1392
+ body: JSON.stringify({
1393
+ encoded_path: session.encoded_path,
1394
+ session_ids: sessionIds
1395
+ })
1396
+ });
1397
+ return result.removed_count;
1398
+ }
1399
+ catch (err) {
1400
+ Notification.error(`Delete failed: ${String(err)}`, {
1401
+ autoClose: 4000
1402
+ });
1403
+ return null;
1404
+ }
1405
+ finally {
1406
+ await this._fetch();
1407
+ }
1408
+ }
1409
+ /** Fork the active row's current conversation into a new named branch.
1410
+ *
1411
+ * Asks for a name, then launches a terminal running
1412
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
1413
+ * the uuid is generated here so the forked JSONL is known up front. Once
1414
+ * claude materialises the file (polled via sessions/set-title) the chosen
1415
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
1416
+ * so the recency resolution makes it the row's current conversation
1417
+ * without an explicit switch.
1418
+ */
1419
+ async _branchSession(forceDangerous) {
1420
+ const session = this._activeSession;
1421
+ if (!session) {
1422
+ return;
1423
+ }
1424
+ const named = await InputDialog.getText({
1425
+ title: 'Branch Session',
1426
+ label: 'Name for the new session',
1427
+ placeholder: this._lookupName(session)
1428
+ });
1429
+ if (!named.button.accept || !named.value || !named.value.trim()) {
1430
+ return;
1431
+ }
1432
+ const title = named.value.trim();
1433
+ const forkId = UUID.uuid4();
1434
+ const spinner = this._showLaunchSpinner();
1435
+ try {
1436
+ const launched = await requestAPI('launch-terminal', this._serverSettings, {
1437
+ method: 'POST',
1438
+ body: JSON.stringify({
1439
+ project_path: session.project_path,
1440
+ session_id: session.session_id,
1441
+ fork_session_id: forkId,
1442
+ dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
1443
+ })
1444
+ });
1445
+ const widget = await this._app.commands.execute('terminal:open', {
1446
+ name: launched.terminal_name
1447
+ });
1448
+ if (widget === null || widget === void 0 ? void 0 : widget.id) {
1449
+ this._terminalsByPath.set(session.project_path, widget);
1450
+ this._wireTerminalDisposal(session.project_path, widget);
1451
+ this._focusTerminal(widget);
1452
+ }
1453
+ }
1454
+ catch (err) {
1455
+ this._showError(err);
1456
+ return;
1457
+ }
1458
+ finally {
1459
+ spinner.dispose();
1460
+ }
1461
+ // Stamp the name in the background once the forked JSONL appears -
1462
+ // claude writes it on its first record, typically within seconds.
1463
+ void this._stampForkTitle(session.encoded_path, forkId, title);
1464
+ }
1465
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
1466
+ * does not), then refresh so the row shows the named fork as current. */
1467
+ async _stampForkTitle(encodedPath, sessionId, title) {
1468
+ for (let attempt = 0; attempt < 30; attempt++) {
1469
+ try {
1470
+ await requestAPI('sessions/set-title', this._serverSettings, {
1471
+ method: 'POST',
1472
+ body: JSON.stringify({
1473
+ encoded_path: encodedPath,
1474
+ session_id: sessionId,
1475
+ title
1476
+ })
1477
+ });
1478
+ await this._fetch();
1479
+ return;
1480
+ }
1481
+ catch (err) {
1482
+ const notYet = err instanceof ServerConnection.ResponseError &&
1483
+ err.response.status === 404;
1484
+ if (!notYet) {
1485
+ break;
1486
+ }
1487
+ await new Promise(resolve => setTimeout(resolve, 1000));
1488
+ }
1489
+ }
1490
+ Notification.warning(`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`, { autoClose: 6000 });
1491
+ }
1219
1492
  /** Switch the active row's project to another conversation branch.
1220
1493
  * The backend touches the branch JSONL's mtime; a refresh then shows
1221
1494
  * the selected conversation as the row's current one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.5",
3
+ "version": "1.2.9",
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",