jupyterlab_claude_code_extension 1.2.4 → 1.2.7

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
@@ -20,7 +20,8 @@ Browse, resume, and manage your Claude Code sessions from a JupyterLab side pane
20
20
  - **Favorites** - star projects you keep coming back to via the right-click menu
21
21
  - **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
22
  - **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)`
23
+ - **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 the count in brackets after the name, e.g. `workspace (2)`
24
+ - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`); rows active within the last minute light up in the theme's brand colour, rows idle for over a week dim slightly
24
25
  - **Search** - fuzzy filter toggled by the funnel button next to refresh
25
26
  - **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
26
27
  - **Hover tooltip** with project path, last activity, message count, branch, and session id
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
@@ -92,6 +92,11 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
92
92
  * case the file browser has no way to address it. */
93
93
  private _pathUnderRoot;
94
94
  private _formatRelativeTime;
95
+ /** Branch entry display: conversation name plus short session id in
96
+ * brackets; branches share the project path so only the name and id
97
+ * distinguish them. Skips the suffix when the label already is the
98
+ * short id (the backend's last-resort fallback). */
99
+ private _branchDisplayName;
95
100
  private _setRefreshSpinning;
96
101
  private _setActiveRow;
97
102
  private _setupContextMenu;
@@ -103,9 +108,16 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
103
108
  * the project has more than one conversation. On a fetch failure the
104
109
  * menu opens without the submenu. */
105
110
  private _openContextMenu;
106
- /** Popup with the project's full branch list - browse and filter when
107
- * 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. */
108
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;
109
121
  /** Switch the active row's project to another conversation branch.
110
122
  * The backend touches the branch JSONL's mtime; a refresh then shows
111
123
  * the selected conversation as the row's current one. */
@@ -124,6 +136,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
124
136
  private _contextMenu;
125
137
  private _branchSubmenu;
126
138
  private _lastBranches;
139
+ private _lastBranchesCurrent;
127
140
  private _newSessionMenu;
128
141
  private _activeSession;
129
142
  private _activeRowEl;
package/lib/widget.js CHANGED
@@ -65,6 +65,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
65
65
  this._sessions = null;
66
66
  this._expanded = loadExpanded();
67
67
  this._lastBranches = [];
68
+ this._lastBranchesCurrent = '';
68
69
  this._activeSession = null;
69
70
  this._activeRowEl = null;
70
71
  this._pollHandle = null;
@@ -783,6 +784,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
783
784
  const row = document.createElement('div');
784
785
  row.className = 'jp-ClaudeSessionsPanel-row';
785
786
  row.title = this._buildRowTooltip(session);
787
+ // Age emphasis: active within the last minute reads bright, idle for
788
+ // over a week dims; the state decays/promotes on the next refresh.
789
+ if (session.file_mtime) {
790
+ const age = Date.now() - session.file_mtime;
791
+ if (age < 60000) {
792
+ row.classList.add('jp-mod-recentlyActive');
793
+ }
794
+ else if (age > 7 * 86400000) {
795
+ row.classList.add('jp-mod-stale');
796
+ }
797
+ }
786
798
  const removing = this._removingPaths.has(session.encoded_path);
787
799
  if (removing) {
788
800
  row.classList.add('jp-mod-busy');
@@ -914,6 +926,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
914
926
  }
915
927
  return `${Math.floor(diff / 86400000)}d ago`;
916
928
  }
929
+ /** Branch entry display: conversation name plus short session id in
930
+ * brackets; branches share the project path so only the name and id
931
+ * distinguish them. Skips the suffix when the label already is the
932
+ * short id (the backend's last-resort fallback). */
933
+ _branchDisplayName(b) {
934
+ const shortId = b.session_id.slice(0, 8);
935
+ return b.label === shortId ? b.label : `${b.label} (${shortId})`;
936
+ }
917
937
  _setRefreshSpinning(on) {
918
938
  if (!this._refreshBtn) {
919
939
  return;
@@ -1035,9 +1055,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1035
1055
  }
1036
1056
  });
1037
1057
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1038
- label: () => `More... (${this._lastBranches.length} total)`,
1058
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1039
1059
  execute: () => {
1040
- void this._showBranchPopup(this._lastBranches);
1060
+ void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1041
1061
  }
1042
1062
  });
1043
1063
  this._commands.addCommand('claude-code-sessions:remove', {
@@ -1073,7 +1093,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1073
1093
  // sessions/branches fetch.
1074
1094
  this._branchSubmenu = new Menu({ commands: this._commands });
1075
1095
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1076
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1096
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1077
1097
  this._contextMenu = new Menu({ commands: this._commands });
1078
1098
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1079
1099
  this._rebuildContextMenu(false);
@@ -1125,24 +1145,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
1125
1145
  try {
1126
1146
  const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1127
1147
  this._lastBranches = data.branches;
1148
+ this._lastBranchesCurrent = data.current;
1128
1149
  this._branchSubmenu.clearItems();
1129
- // The submenu shows only the 5 most recent; the full list lives
1130
- // behind "More..." in a searchable popup.
1150
+ this._branchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
1151
+ // The submenu shows only the 5 most recent inline (fewest clicks
1152
+ // for often-used sessions); the full list plus management lives
1153
+ // behind the always-present "Manage Sessions..." popup.
1131
1154
  for (const b of data.branches.slice(0, 5)) {
1132
1155
  this._branchSubmenu.addItem({
1133
1156
  command: 'claude-code-sessions:switch-branch',
1134
1157
  args: {
1135
1158
  session_id: b.session_id,
1136
- label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1159
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1137
1160
  }
1138
1161
  });
1139
1162
  }
1140
- if (data.branches.length > 5) {
1141
- this._branchSubmenu.addItem({ type: 'separator' });
1142
- this._branchSubmenu.addItem({
1143
- command: 'claude-code-sessions:switch-branch-more'
1144
- });
1145
- }
1163
+ this._branchSubmenu.addItem({ type: 'separator' });
1164
+ this._branchSubmenu.addItem({
1165
+ command: 'claude-code-sessions:switch-branch-more'
1166
+ });
1146
1167
  hasBranches = data.branches.length > 0;
1147
1168
  }
1148
1169
  catch (_a) {
@@ -1152,35 +1173,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
1152
1173
  this._rebuildContextMenu(hasBranches);
1153
1174
  this._contextMenu.open(x, y);
1154
1175
  }
1155
- /** Popup with the project's full branch list - browse and filter when
1156
- * the list is too large for the submenu. Clicking an entry switches. */
1157
- _showBranchPopup(branches) {
1176
+ /** Popup with the project's full branch list - browse, filter, switch
1177
+ * and manage. Clicking an entry switches while nothing is selected;
1178
+ * checkbox selection (one, many, or select-all) arms a two-step Delete
1179
+ * button that removes the chosen sessions. The current conversation is
1180
+ * shown first, badged and untouchable. */
1181
+ _showBranchPopup(branches, current) {
1182
+ // Local working copy so deletions can refresh the list in place.
1183
+ let items = [...branches];
1184
+ const selected = new Set();
1185
+ let confirmArmed = false;
1158
1186
  const body = document.createElement('div');
1159
1187
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1160
1188
  const search = document.createElement('input');
1161
1189
  search.type = 'search';
1162
- search.placeholder = 'Filter branches...';
1190
+ search.placeholder = 'Filter sessions...';
1163
1191
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1164
1192
  body.appendChild(search);
1193
+ const selectAllBar = document.createElement('label');
1194
+ selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1195
+ const selectAll = document.createElement('input');
1196
+ selectAll.type = 'checkbox';
1197
+ selectAllBar.appendChild(selectAll);
1198
+ selectAllBar.appendChild(document.createTextNode('Select all'));
1199
+ body.appendChild(selectAllBar);
1165
1200
  const list = document.createElement('div');
1166
1201
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1167
1202
  body.appendChild(list);
1203
+ const footer = document.createElement('div');
1204
+ footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1205
+ const deleteBtn = document.createElement('button');
1206
+ deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1207
+ footer.appendChild(deleteBtn);
1208
+ body.appendChild(footer);
1168
1209
  const bodyWidget = new Widget({ node: body });
1169
1210
  const dialog = new Dialog({
1170
- title: 'Switch Conversation Branch',
1211
+ title: 'Switch and Manage Sessions',
1171
1212
  body: bodyWidget,
1172
1213
  buttons: [Dialog.cancelButton()]
1173
1214
  });
1174
- const render = () => {
1215
+ const visibleMatches = () => {
1175
1216
  const needle = search.value.trim().toLowerCase();
1176
- list.replaceChildren();
1177
- const matches = branches.filter(b => !needle ||
1217
+ return items.filter(b => !needle ||
1178
1218
  b.label.toLowerCase().includes(needle) ||
1179
1219
  b.session_id.toLowerCase().includes(needle));
1220
+ };
1221
+ // Any selection change disarms a pending confirm.
1222
+ const updateControls = () => {
1223
+ confirmArmed = false;
1224
+ deleteBtn.disabled = selected.size === 0;
1225
+ deleteBtn.textContent = `Delete (${selected.size})`;
1226
+ deleteBtn.classList.remove('jp-mod-confirm');
1227
+ const visible = visibleMatches();
1228
+ const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
1229
+ selectAll.checked =
1230
+ visible.length > 0 && visibleSelected === visible.length;
1231
+ selectAll.indeterminate =
1232
+ visibleSelected > 0 && visibleSelected < visible.length;
1233
+ };
1234
+ const render = () => {
1235
+ list.replaceChildren();
1236
+ // The current conversation leads the list - badged, unselectable,
1237
+ // undeletable; only the extras below it are manageable.
1238
+ const currentRow = document.createElement('div');
1239
+ currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1240
+ currentRow.title = `Session id: ${current}`;
1241
+ const currentLabel = document.createElement('span');
1242
+ currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1243
+ const currentName = this._activeSession
1244
+ ? this._lookupName(this._activeSession)
1245
+ : current.slice(0, 8);
1246
+ currentLabel.textContent = `${currentName} (${current.slice(0, 8)})`;
1247
+ currentRow.appendChild(currentLabel);
1248
+ const badge = document.createElement('span');
1249
+ badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1250
+ badge.textContent = 'current';
1251
+ currentRow.appendChild(badge);
1252
+ list.appendChild(currentRow);
1253
+ const matches = visibleMatches();
1180
1254
  if (matches.length === 0) {
1181
1255
  const empty = document.createElement('div');
1182
1256
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1183
- empty.textContent = 'No matching branches.';
1257
+ empty.textContent = items.length
1258
+ ? 'No matching sessions.'
1259
+ : 'No other conversations.';
1184
1260
  list.appendChild(empty);
1185
1261
  return;
1186
1262
  }
@@ -1188,26 +1264,120 @@ export class ClaudeCodeSessionsWidget extends Widget {
1188
1264
  const row = document.createElement('div');
1189
1265
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1190
1266
  row.title = `Session id: ${b.session_id}`;
1267
+ const check = document.createElement('input');
1268
+ check.type = 'checkbox';
1269
+ check.checked = selected.has(b.session_id);
1270
+ // The checkbox is its own click zone - ticking must not switch.
1271
+ check.addEventListener('click', e => {
1272
+ e.stopPropagation();
1273
+ if (check.checked) {
1274
+ selected.add(b.session_id);
1275
+ }
1276
+ else {
1277
+ selected.delete(b.session_id);
1278
+ }
1279
+ updateControls();
1280
+ });
1281
+ row.appendChild(check);
1191
1282
  const label = document.createElement('span');
1192
1283
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1193
- label.textContent = b.label;
1284
+ label.textContent = this._branchDisplayName(b);
1194
1285
  row.appendChild(label);
1195
1286
  const time = document.createElement('span');
1196
1287
  time.className = 'jp-ClaudeSessionsPanel-branchTime';
1197
1288
  time.textContent = this._formatRelativeTime(b.file_mtime);
1198
1289
  row.appendChild(time);
1199
1290
  row.addEventListener('click', () => {
1291
+ // Selection mode: while anything is ticked, row clicks toggle
1292
+ // selection - no accidental switch mid-selection.
1293
+ if (selected.size > 0) {
1294
+ if (selected.has(b.session_id)) {
1295
+ selected.delete(b.session_id);
1296
+ }
1297
+ else {
1298
+ selected.add(b.session_id);
1299
+ }
1300
+ check.checked = selected.has(b.session_id);
1301
+ updateControls();
1302
+ return;
1303
+ }
1200
1304
  dialog.dispose();
1201
1305
  void this._switchBranch(b.session_id);
1202
1306
  });
1203
1307
  list.appendChild(row);
1204
1308
  }
1205
1309
  };
1206
- search.addEventListener('input', render);
1310
+ selectAll.addEventListener('change', () => {
1311
+ // Select-all acts on the visible (filtered) rows only.
1312
+ const visible = visibleMatches();
1313
+ if (selectAll.checked) {
1314
+ visible.forEach(b => selected.add(b.session_id));
1315
+ }
1316
+ else {
1317
+ visible.forEach(b => selected.delete(b.session_id));
1318
+ }
1319
+ render();
1320
+ updateControls();
1321
+ });
1322
+ deleteBtn.addEventListener('click', () => {
1323
+ if (selected.size === 0) {
1324
+ return;
1325
+ }
1326
+ if (!confirmArmed) {
1327
+ // Two-step delete: first click arms, second click executes.
1328
+ confirmArmed = true;
1329
+ deleteBtn.textContent = `Confirm delete (${selected.size})`;
1330
+ deleteBtn.classList.add('jp-mod-confirm');
1331
+ return;
1332
+ }
1333
+ void this._deleteBranches([...selected]).then(deleted => {
1334
+ if (deleted === null) {
1335
+ return;
1336
+ }
1337
+ items = items.filter(b => !selected.has(b.session_id));
1338
+ selected.clear();
1339
+ this._lastBranches = items;
1340
+ render();
1341
+ updateControls();
1342
+ });
1343
+ });
1344
+ search.addEventListener('input', () => {
1345
+ render();
1346
+ updateControls();
1347
+ });
1207
1348
  render();
1349
+ updateControls();
1208
1350
  void dialog.launch();
1209
1351
  search.focus();
1210
1352
  }
1353
+ /** Delete the given branch sessions of the active row's project.
1354
+ * Returns the removed count, or null on failure (after notifying).
1355
+ * Always resyncs the panel so the row's conversation count drops. */
1356
+ async _deleteBranches(sessionIds) {
1357
+ const session = this._activeSession;
1358
+ if (!session) {
1359
+ return null;
1360
+ }
1361
+ try {
1362
+ const result = await requestAPI('sessions/delete-branches', this._serverSettings, {
1363
+ method: 'POST',
1364
+ body: JSON.stringify({
1365
+ encoded_path: session.encoded_path,
1366
+ session_ids: sessionIds
1367
+ })
1368
+ });
1369
+ return result.removed_count;
1370
+ }
1371
+ catch (err) {
1372
+ Notification.error(`Delete failed: ${String(err)}`, {
1373
+ autoClose: 4000
1374
+ });
1375
+ return null;
1376
+ }
1377
+ finally {
1378
+ await this._fetch();
1379
+ }
1380
+ }
1211
1381
  /** Switch the active row's project to another conversation branch.
1212
1382
  * The backend touches the branch JSONL's mtime; a refresh then shows
1213
1383
  * 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.4",
3
+ "version": "1.2.7",
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",
@@ -150,10 +150,35 @@ describe('launch spinner dismiss contract', () => {
150
150
  expect(openMenu).toMatch(/_rebuildContextMenu\(hasBranches\)/);
151
151
  });
152
152
 
153
- it('caps the submenu at 5 and adds More... beyond that', () => {
153
+ it('caps the inline submenu at 5 most recent', () => {
154
154
  expect(openMenu).toMatch(/\.slice\(0, 5\)/);
155
+ });
156
+
157
+ it('always adds the Manage Sessions entry, no >5 gate', () => {
158
+ // The popup is the management hub - it must be reachable even for
159
+ // projects with 2-5 conversations, so the entry is unconditional.
160
+ expect(openMenu).toMatch(/switch-branch-more/);
161
+ expect(openMenu).not.toMatch(/branches\.length > 5/);
162
+ expect(widgetSrc).toMatch(/`Manage Sessions\.\.\. \(\$\{/);
163
+ });
164
+
165
+ it('titles the submenu Switch and Manage Sessions with the count', () => {
155
166
  expect(openMenu).toMatch(
156
- /branches\.length > 5[\s\S]*?switch-branch-more/
167
+ /title\.label = `Switch and Manage Sessions \(\$\{data\.branches\.length\}\)`/
168
+ );
169
+ });
170
+
171
+ it('branch entries render name plus short id via _branchDisplayName', () => {
172
+ const display = (widgetSrc.match(
173
+ /private _branchDisplayName[\s\S]*?\n \}/
174
+ ) ?? [''])[0];
175
+ expect(display).toMatch(/session_id\.slice\(0, 8\)/);
176
+ expect(display).toMatch(/`\$\{b\.label\} \(\$\{shortId\}\)`/);
177
+ expect(widgetSrc).toMatch(
178
+ /label: `\$\{this\._branchDisplayName\(b\)\} - \$\{this\._formatRelativeTime\(b\.file_mtime\)\}`/
179
+ );
180
+ expect(widgetSrc).toMatch(
181
+ /label\.textContent = this\._branchDisplayName\(b\)/
157
182
  );
158
183
  });
159
184
 
@@ -169,6 +194,70 @@ describe('launch spinner dismiss contract', () => {
169
194
  );
170
195
  });
171
196
 
197
+ it('popup leads with the current row, badged and without checkbox', () => {
198
+ const popup = (widgetSrc.match(
199
+ /private _showBranchPopup[\s\S]*?\n \}/
200
+ ) ?? [''])[0];
201
+ expect(popup).toMatch(/jp-mod-current/);
202
+ expect(popup).toMatch(/branchCurrentBadge/);
203
+ // The current row is appended before any checkbox is created.
204
+ const currentIdx = popup.indexOf('jp-mod-current');
205
+ const checkboxIdx = popup.indexOf("check.type = 'checkbox'");
206
+ expect(currentIdx).toBeGreaterThan(-1);
207
+ expect(checkboxIdx).toBeGreaterThan(currentIdx);
208
+ });
209
+
210
+ it('checkbox is its own click zone and selection gates row switch', () => {
211
+ const popup = (widgetSrc.match(
212
+ /private _showBranchPopup[\s\S]*?\n \}/
213
+ ) ?? [''])[0];
214
+ expect(popup).toMatch(/stopPropagation\(\)/);
215
+ // Selection mode: row click toggles while anything is selected.
216
+ expect(popup).toMatch(
217
+ /if \(selected\.size > 0\) \{[\s\S]*?return;[\s\S]*?dialog\.dispose\(\)/
218
+ );
219
+ });
220
+
221
+ it('select-all toggles the visible (filtered) rows only', () => {
222
+ const popup = (widgetSrc.match(
223
+ /private _showBranchPopup[\s\S]*?\n \}/
224
+ ) ?? [''])[0];
225
+ expect(popup).toMatch(
226
+ /selectAll\.addEventListener\('change'[\s\S]*?visibleMatches\(\)/
227
+ );
228
+ });
229
+
230
+ it('delete is two-step and any selection change disarms it', () => {
231
+ const popup = (widgetSrc.match(
232
+ /private _showBranchPopup[\s\S]*?\n \}/
233
+ ) ?? [''])[0];
234
+ expect(popup).toMatch(/`Confirm delete \(\$\{selected\.size\}\)`/);
235
+ // updateControls (run on every selection change) resets the armed
236
+ // state and the button label.
237
+ expect(popup).toMatch(
238
+ /const updateControls = \(\) => \{\s*confirmArmed = false/
239
+ );
240
+ });
241
+
242
+ it('delete posts to delete-branches and resyncs the panel', () => {
243
+ const del = (widgetSrc.match(
244
+ /private async _deleteBranches[\s\S]*?\n \}/
245
+ ) ?? [''])[0];
246
+ expect(del).toMatch(/sessions\/delete-branches/);
247
+ expect(del).toMatch(/finally[\s\S]*?await this\._fetch\(\)/);
248
+ });
249
+
250
+ it('rows carry age emphasis classes at the 60s and 7d thresholds', () => {
251
+ expect(widgetSrc).toMatch(/age < 60_000[\s\S]*?jp-mod-recentlyActive/);
252
+ expect(widgetSrc).toMatch(/age > 7 \* 86_400_000[\s\S]*?jp-mod-stale/);
253
+ const css: string = fs.readFileSync(
254
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
255
+ 'utf-8'
256
+ );
257
+ expect(css).toMatch(/jp-mod-recentlyActive[\s\S]*?--jp-brand-color1/);
258
+ expect(css).toMatch(/jp-mod-stale \{\s*opacity/);
259
+ });
260
+
172
261
  it('opens the menu without the submenu when the fetch fails', () => {
173
262
  expect(openMenu).toMatch(/catch[\s\S]*?hasBranches = false/);
174
263
  });
package/src/types.ts CHANGED
@@ -73,6 +73,15 @@ export interface ISwitchResponse {
73
73
  current: string | null;
74
74
  }
75
75
 
76
+ export interface IDeleteBranchesRequest {
77
+ encoded_path: string;
78
+ session_ids: string[];
79
+ }
80
+
81
+ export interface IDeleteBranchesResponse {
82
+ removed_count: number;
83
+ }
84
+
76
85
  export interface ILaunchTerminalRequest {
77
86
  project_path: string;
78
87
  /** Omit to start a brand-new claude session instead of resuming one. */
package/src/widget.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import {
27
27
  IBranch,
28
28
  IBranchesResponse,
29
+ IDeleteBranchesResponse,
29
30
  IFavouriteResponse,
30
31
  ILaunchTerminalResponse,
31
32
  ICleanupResponse,
@@ -928,6 +929,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
928
929
  row.className = 'jp-ClaudeSessionsPanel-row';
929
930
  row.title = this._buildRowTooltip(session);
930
931
 
932
+ // Age emphasis: active within the last minute reads bright, idle for
933
+ // over a week dims; the state decays/promotes on the next refresh.
934
+ if (session.file_mtime) {
935
+ const age = Date.now() - session.file_mtime;
936
+ if (age < 60_000) {
937
+ row.classList.add('jp-mod-recentlyActive');
938
+ } else if (age > 7 * 86_400_000) {
939
+ row.classList.add('jp-mod-stale');
940
+ }
941
+ }
942
+
931
943
  const removing = this._removingPaths.has(session.encoded_path);
932
944
  if (removing) {
933
945
  row.classList.add('jp-mod-busy');
@@ -1068,6 +1080,15 @@ export class ClaudeCodeSessionsWidget extends Widget {
1068
1080
  return `${Math.floor(diff / 86_400_000)}d ago`;
1069
1081
  }
1070
1082
 
1083
+ /** Branch entry display: conversation name plus short session id in
1084
+ * brackets; branches share the project path so only the name and id
1085
+ * distinguish them. Skips the suffix when the label already is the
1086
+ * short id (the backend's last-resort fallback). */
1087
+ private _branchDisplayName(b: IBranch): string {
1088
+ const shortId = b.session_id.slice(0, 8);
1089
+ return b.label === shortId ? b.label : `${b.label} (${shortId})`;
1090
+ }
1091
+
1071
1092
  private _setRefreshSpinning(on: boolean): void {
1072
1093
  if (!this._refreshBtn) {
1073
1094
  return;
@@ -1205,9 +1226,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1205
1226
  });
1206
1227
 
1207
1228
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1208
- label: () => `More... (${this._lastBranches.length} total)`,
1229
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1209
1230
  execute: () => {
1210
- void this._showBranchPopup(this._lastBranches);
1231
+ void this._showBranchPopup(
1232
+ this._lastBranches,
1233
+ this._lastBranchesCurrent
1234
+ );
1211
1235
  }
1212
1236
  });
1213
1237
 
@@ -1248,7 +1272,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1248
1272
  // sessions/branches fetch.
1249
1273
  this._branchSubmenu = new Menu({ commands: this._commands });
1250
1274
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1251
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1275
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1252
1276
 
1253
1277
  this._contextMenu = new Menu({ commands: this._commands });
1254
1278
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1312,24 +1336,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
1312
1336
  { cache: 'no-store' }
1313
1337
  );
1314
1338
  this._lastBranches = data.branches;
1339
+ this._lastBranchesCurrent = data.current;
1315
1340
  this._branchSubmenu.clearItems();
1316
- // The submenu shows only the 5 most recent; the full list lives
1317
- // behind "More..." in a searchable popup.
1341
+ this._branchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
1342
+ // The submenu shows only the 5 most recent inline (fewest clicks
1343
+ // for often-used sessions); the full list plus management lives
1344
+ // behind the always-present "Manage Sessions..." popup.
1318
1345
  for (const b of data.branches.slice(0, 5)) {
1319
1346
  this._branchSubmenu.addItem({
1320
1347
  command: 'claude-code-sessions:switch-branch',
1321
1348
  args: {
1322
1349
  session_id: b.session_id,
1323
- label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1350
+ label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
1324
1351
  }
1325
1352
  });
1326
1353
  }
1327
- if (data.branches.length > 5) {
1328
- this._branchSubmenu.addItem({ type: 'separator' });
1329
- this._branchSubmenu.addItem({
1330
- command: 'claude-code-sessions:switch-branch-more'
1331
- });
1332
- }
1354
+ this._branchSubmenu.addItem({ type: 'separator' });
1355
+ this._branchSubmenu.addItem({
1356
+ command: 'claude-code-sessions:switch-branch-more'
1357
+ });
1333
1358
  hasBranches = data.branches.length > 0;
1334
1359
  } catch {
1335
1360
  hasBranches = false;
@@ -1339,42 +1364,106 @@ export class ClaudeCodeSessionsWidget extends Widget {
1339
1364
  this._contextMenu.open(x, y);
1340
1365
  }
1341
1366
 
1342
- /** Popup with the project's full branch list - browse and filter when
1343
- * the list is too large for the submenu. Clicking an entry switches. */
1344
- private _showBranchPopup(branches: IBranch[]): void {
1367
+ /** Popup with the project's full branch list - browse, filter, switch
1368
+ * and manage. Clicking an entry switches while nothing is selected;
1369
+ * checkbox selection (one, many, or select-all) arms a two-step Delete
1370
+ * button that removes the chosen sessions. The current conversation is
1371
+ * shown first, badged and untouchable. */
1372
+ private _showBranchPopup(branches: IBranch[], current: string): void {
1373
+ // Local working copy so deletions can refresh the list in place.
1374
+ let items = [...branches];
1375
+ const selected = new Set<string>();
1376
+ let confirmArmed = false;
1377
+
1345
1378
  const body = document.createElement('div');
1346
1379
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1347
1380
 
1348
1381
  const search = document.createElement('input');
1349
1382
  search.type = 'search';
1350
- search.placeholder = 'Filter branches...';
1383
+ search.placeholder = 'Filter sessions...';
1351
1384
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1352
1385
  body.appendChild(search);
1353
1386
 
1387
+ const selectAllBar = document.createElement('label');
1388
+ selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
1389
+ const selectAll = document.createElement('input');
1390
+ selectAll.type = 'checkbox';
1391
+ selectAllBar.appendChild(selectAll);
1392
+ selectAllBar.appendChild(document.createTextNode('Select all'));
1393
+ body.appendChild(selectAllBar);
1394
+
1354
1395
  const list = document.createElement('div');
1355
1396
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1356
1397
  body.appendChild(list);
1357
1398
 
1399
+ const footer = document.createElement('div');
1400
+ footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
1401
+ const deleteBtn = document.createElement('button');
1402
+ deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
1403
+ footer.appendChild(deleteBtn);
1404
+ body.appendChild(footer);
1405
+
1358
1406
  const bodyWidget = new Widget({ node: body });
1359
1407
  const dialog = new Dialog({
1360
- title: 'Switch Conversation Branch',
1408
+ title: 'Switch and Manage Sessions',
1361
1409
  body: bodyWidget,
1362
1410
  buttons: [Dialog.cancelButton()]
1363
1411
  });
1364
1412
 
1365
- const render = () => {
1413
+ const visibleMatches = (): IBranch[] => {
1366
1414
  const needle = search.value.trim().toLowerCase();
1367
- list.replaceChildren();
1368
- const matches = branches.filter(
1415
+ return items.filter(
1369
1416
  b =>
1370
1417
  !needle ||
1371
1418
  b.label.toLowerCase().includes(needle) ||
1372
1419
  b.session_id.toLowerCase().includes(needle)
1373
1420
  );
1421
+ };
1422
+
1423
+ // Any selection change disarms a pending confirm.
1424
+ const updateControls = () => {
1425
+ confirmArmed = false;
1426
+ deleteBtn.disabled = selected.size === 0;
1427
+ deleteBtn.textContent = `Delete (${selected.size})`;
1428
+ deleteBtn.classList.remove('jp-mod-confirm');
1429
+ const visible = visibleMatches();
1430
+ const visibleSelected = visible.filter(b =>
1431
+ selected.has(b.session_id)
1432
+ ).length;
1433
+ selectAll.checked =
1434
+ visible.length > 0 && visibleSelected === visible.length;
1435
+ selectAll.indeterminate =
1436
+ visibleSelected > 0 && visibleSelected < visible.length;
1437
+ };
1438
+
1439
+ const render = () => {
1440
+ list.replaceChildren();
1441
+
1442
+ // The current conversation leads the list - badged, unselectable,
1443
+ // undeletable; only the extras below it are manageable.
1444
+ const currentRow = document.createElement('div');
1445
+ currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
1446
+ currentRow.title = `Session id: ${current}`;
1447
+ const currentLabel = document.createElement('span');
1448
+ currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
1449
+ const currentName = this._activeSession
1450
+ ? this._lookupName(this._activeSession)
1451
+ : current.slice(0, 8);
1452
+ currentLabel.textContent = `${currentName} (${current.slice(0, 8)})`;
1453
+ currentRow.appendChild(currentLabel);
1454
+ const badge = document.createElement('span');
1455
+ badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
1456
+ badge.textContent = 'current';
1457
+ currentRow.appendChild(badge);
1458
+ list.appendChild(currentRow);
1459
+
1460
+ const matches = visibleMatches();
1374
1461
  if (matches.length === 0) {
1375
1462
  const empty = document.createElement('div');
1376
1463
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1377
- empty.textContent = 'No matching branches.';
1464
+ empty.textContent = items.length
1465
+ ? 'No matching sessions.'
1466
+ : 'No other conversations.';
1378
1467
  list.appendChild(empty);
1379
1468
  return;
1380
1469
  }
@@ -1383,9 +1472,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
1383
1472
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1384
1473
  row.title = `Session id: ${b.session_id}`;
1385
1474
 
1475
+ const check = document.createElement('input');
1476
+ check.type = 'checkbox';
1477
+ check.checked = selected.has(b.session_id);
1478
+ // The checkbox is its own click zone - ticking must not switch.
1479
+ check.addEventListener('click', e => {
1480
+ e.stopPropagation();
1481
+ if (check.checked) {
1482
+ selected.add(b.session_id);
1483
+ } else {
1484
+ selected.delete(b.session_id);
1485
+ }
1486
+ updateControls();
1487
+ });
1488
+ row.appendChild(check);
1489
+
1386
1490
  const label = document.createElement('span');
1387
1491
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1388
- label.textContent = b.label;
1492
+ label.textContent = this._branchDisplayName(b);
1389
1493
  row.appendChild(label);
1390
1494
 
1391
1495
  const time = document.createElement('span');
@@ -1394,19 +1498,102 @@ export class ClaudeCodeSessionsWidget extends Widget {
1394
1498
  row.appendChild(time);
1395
1499
 
1396
1500
  row.addEventListener('click', () => {
1501
+ // Selection mode: while anything is ticked, row clicks toggle
1502
+ // selection - no accidental switch mid-selection.
1503
+ if (selected.size > 0) {
1504
+ if (selected.has(b.session_id)) {
1505
+ selected.delete(b.session_id);
1506
+ } else {
1507
+ selected.add(b.session_id);
1508
+ }
1509
+ check.checked = selected.has(b.session_id);
1510
+ updateControls();
1511
+ return;
1512
+ }
1397
1513
  dialog.dispose();
1398
1514
  void this._switchBranch(b.session_id);
1399
1515
  });
1400
1516
  list.appendChild(row);
1401
1517
  }
1402
1518
  };
1403
- search.addEventListener('input', render);
1519
+
1520
+ selectAll.addEventListener('change', () => {
1521
+ // Select-all acts on the visible (filtered) rows only.
1522
+ const visible = visibleMatches();
1523
+ if (selectAll.checked) {
1524
+ visible.forEach(b => selected.add(b.session_id));
1525
+ } else {
1526
+ visible.forEach(b => selected.delete(b.session_id));
1527
+ }
1528
+ render();
1529
+ updateControls();
1530
+ });
1531
+
1532
+ deleteBtn.addEventListener('click', () => {
1533
+ if (selected.size === 0) {
1534
+ return;
1535
+ }
1536
+ if (!confirmArmed) {
1537
+ // Two-step delete: first click arms, second click executes.
1538
+ confirmArmed = true;
1539
+ deleteBtn.textContent = `Confirm delete (${selected.size})`;
1540
+ deleteBtn.classList.add('jp-mod-confirm');
1541
+ return;
1542
+ }
1543
+ void this._deleteBranches([...selected]).then(deleted => {
1544
+ if (deleted === null) {
1545
+ return;
1546
+ }
1547
+ items = items.filter(b => !selected.has(b.session_id));
1548
+ selected.clear();
1549
+ this._lastBranches = items;
1550
+ render();
1551
+ updateControls();
1552
+ });
1553
+ });
1554
+
1555
+ search.addEventListener('input', () => {
1556
+ render();
1557
+ updateControls();
1558
+ });
1404
1559
  render();
1560
+ updateControls();
1405
1561
 
1406
1562
  void dialog.launch();
1407
1563
  search.focus();
1408
1564
  }
1409
1565
 
1566
+ /** Delete the given branch sessions of the active row's project.
1567
+ * Returns the removed count, or null on failure (after notifying).
1568
+ * Always resyncs the panel so the row's conversation count drops. */
1569
+ private async _deleteBranches(sessionIds: string[]): Promise<number | null> {
1570
+ const session = this._activeSession;
1571
+ if (!session) {
1572
+ return null;
1573
+ }
1574
+ try {
1575
+ const result = await requestAPI<IDeleteBranchesResponse>(
1576
+ 'sessions/delete-branches',
1577
+ this._serverSettings,
1578
+ {
1579
+ method: 'POST',
1580
+ body: JSON.stringify({
1581
+ encoded_path: session.encoded_path,
1582
+ session_ids: sessionIds
1583
+ })
1584
+ }
1585
+ );
1586
+ return result.removed_count;
1587
+ } catch (err) {
1588
+ Notification.error(`Delete failed: ${String(err)}`, {
1589
+ autoClose: 4000
1590
+ });
1591
+ return null;
1592
+ } finally {
1593
+ await this._fetch();
1594
+ }
1595
+ }
1596
+
1410
1597
  /** Switch the active row's project to another conversation branch.
1411
1598
  * The backend touches the branch JSONL's mtime; a refresh then shows
1412
1599
  * the selected conversation as the row's current one. */
@@ -1484,6 +1671,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1484
1671
  private _contextMenu!: Menu;
1485
1672
  private _branchSubmenu!: Menu;
1486
1673
  private _lastBranches: IBranch[] = [];
1674
+ private _lastBranchesCurrent = '';
1487
1675
  private _newSessionMenu!: Menu;
1488
1676
  private _activeSession: ISession | null = null;
1489
1677
  private _activeRowEl: HTMLElement | null = null;
package/style/base.css CHANGED
@@ -347,6 +347,72 @@
347
347
  font-size: var(--jp-ui-font-size0);
348
348
  }
349
349
 
350
+ .jp-ClaudeSessionsPanel-branchRow input[type='checkbox'] {
351
+ flex: none;
352
+ margin: 0;
353
+ cursor: pointer;
354
+ }
355
+
356
+ /* The current conversation leads the popup list - badged, untouchable. */
357
+ .jp-ClaudeSessionsPanel-branchRow.jp-mod-current {
358
+ cursor: default;
359
+ color: var(--jp-ui-font-color2);
360
+ }
361
+
362
+ .jp-ClaudeSessionsPanel-branchRow.jp-mod-current:hover {
363
+ background: transparent;
364
+ }
365
+
366
+ .jp-ClaudeSessionsPanel-branchCurrentBadge {
367
+ flex: none;
368
+ padding: 0 6px;
369
+ border: 1px solid var(--jp-border-color2);
370
+ border-radius: 8px;
371
+ color: var(--jp-ui-font-color2);
372
+ font-size: var(--jp-ui-font-size0);
373
+ }
374
+
375
+ .jp-ClaudeSessionsPanel-branchSelectAll {
376
+ display: flex;
377
+ align-items: center;
378
+ gap: 8px;
379
+ padding: 0 6px;
380
+ cursor: pointer;
381
+ color: var(--jp-ui-font-color2);
382
+ font-size: var(--jp-ui-font-size0);
383
+ user-select: none;
384
+ }
385
+
386
+ .jp-ClaudeSessionsPanel-branchSelectAll input[type='checkbox'] {
387
+ margin: 0;
388
+ cursor: pointer;
389
+ }
390
+
391
+ .jp-ClaudeSessionsPanel-branchFooter {
392
+ display: flex;
393
+ justify-content: flex-end;
394
+ }
395
+
396
+ .jp-ClaudeSessionsPanel-branchDelete {
397
+ padding: 4px 12px;
398
+ border: 1px solid var(--jp-border-color1);
399
+ border-radius: 2px;
400
+ background: var(--jp-layout-color1);
401
+ color: var(--jp-ui-font-color1);
402
+ cursor: pointer;
403
+ }
404
+
405
+ .jp-ClaudeSessionsPanel-branchDelete:disabled {
406
+ opacity: 0.5;
407
+ cursor: default;
408
+ }
409
+
410
+ /* Armed state of the two-step delete - second click executes. */
411
+ .jp-ClaudeSessionsPanel-branchDelete.jp-mod-confirm {
412
+ border-color: var(--jp-error-color1);
413
+ color: var(--jp-error-color1);
414
+ }
415
+
350
416
  /* Last-activity time on session rows - dim, right of the name. */
351
417
  .jp-ClaudeSessionsPanel-rowTime {
352
418
  flex: 0 0 auto;
@@ -354,3 +420,13 @@
354
420
  color: var(--jp-ui-font-color2);
355
421
  font-size: var(--jp-ui-font-size0);
356
422
  }
423
+
424
+ /* Age emphasis: a row active within the last minute reads bright, a row
425
+ idle for over a week dims; both decay/promote on the next refresh. */
426
+ .jp-ClaudeSessionsPanel-row.jp-mod-recentlyActive .jp-ClaudeSessionsPanel-name {
427
+ color: var(--jp-brand-color1);
428
+ }
429
+
430
+ .jp-ClaudeSessionsPanel-row.jp-mod-stale {
431
+ opacity: 0.65;
432
+ }