jupyterlab_claude_code_extension 1.2.5 → 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
@@ -108,9 +108,16 @@ 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;
114
121
  /** Switch the active row's project to another conversation branch.
115
122
  * The backend touches the branch JSONL's mtime; a refresh then shows
116
123
  * the selected conversation as the row's current one. */
@@ -129,6 +136,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
129
136
  private _contextMenu;
130
137
  private _branchSubmenu;
131
138
  private _lastBranches;
139
+ private _lastBranchesCurrent;
132
140
  private _newSessionMenu;
133
141
  private _activeSession;
134
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');
@@ -1043,9 +1055,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
1043
1055
  }
1044
1056
  });
1045
1057
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1046
- label: () => `More... (${this._lastBranches.length} total)`,
1058
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1047
1059
  execute: () => {
1048
- void this._showBranchPopup(this._lastBranches);
1060
+ void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1049
1061
  }
1050
1062
  });
1051
1063
  this._commands.addCommand('claude-code-sessions:remove', {
@@ -1081,7 +1093,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1081
1093
  // sessions/branches fetch.
1082
1094
  this._branchSubmenu = new Menu({ commands: this._commands });
1083
1095
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1084
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1096
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1085
1097
  this._contextMenu = new Menu({ commands: this._commands });
1086
1098
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1087
1099
  this._rebuildContextMenu(false);
@@ -1133,9 +1145,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1133
1145
  try {
1134
1146
  const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1135
1147
  this._lastBranches = data.branches;
1148
+ this._lastBranchesCurrent = data.current;
1136
1149
  this._branchSubmenu.clearItems();
1137
- // The submenu shows only the 5 most recent; the full list lives
1138
- // 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.
1139
1154
  for (const b of data.branches.slice(0, 5)) {
1140
1155
  this._branchSubmenu.addItem({
1141
1156
  command: 'claude-code-sessions:switch-branch',
@@ -1145,12 +1160,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1145
1160
  }
1146
1161
  });
1147
1162
  }
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
- }
1163
+ this._branchSubmenu.addItem({ type: 'separator' });
1164
+ this._branchSubmenu.addItem({
1165
+ command: 'claude-code-sessions:switch-branch-more'
1166
+ });
1154
1167
  hasBranches = data.branches.length > 0;
1155
1168
  }
1156
1169
  catch (_a) {
@@ -1160,35 +1173,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
1160
1173
  this._rebuildContextMenu(hasBranches);
1161
1174
  this._contextMenu.open(x, y);
1162
1175
  }
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) {
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;
1166
1186
  const body = document.createElement('div');
1167
1187
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1168
1188
  const search = document.createElement('input');
1169
1189
  search.type = 'search';
1170
- search.placeholder = 'Filter branches...';
1190
+ search.placeholder = 'Filter sessions...';
1171
1191
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1172
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);
1173
1200
  const list = document.createElement('div');
1174
1201
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1175
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);
1176
1209
  const bodyWidget = new Widget({ node: body });
1177
1210
  const dialog = new Dialog({
1178
- title: 'Switch Conversation Branch',
1211
+ title: 'Switch and Manage Sessions',
1179
1212
  body: bodyWidget,
1180
1213
  buttons: [Dialog.cancelButton()]
1181
1214
  });
1182
- const render = () => {
1215
+ const visibleMatches = () => {
1183
1216
  const needle = search.value.trim().toLowerCase();
1184
- list.replaceChildren();
1185
- const matches = branches.filter(b => !needle ||
1217
+ return items.filter(b => !needle ||
1186
1218
  b.label.toLowerCase().includes(needle) ||
1187
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();
1188
1254
  if (matches.length === 0) {
1189
1255
  const empty = document.createElement('div');
1190
1256
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1191
- empty.textContent = 'No matching branches.';
1257
+ empty.textContent = items.length
1258
+ ? 'No matching sessions.'
1259
+ : 'No other conversations.';
1192
1260
  list.appendChild(empty);
1193
1261
  return;
1194
1262
  }
@@ -1196,6 +1264,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1196
1264
  const row = document.createElement('div');
1197
1265
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1198
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);
1199
1282
  const label = document.createElement('span');
1200
1283
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1201
1284
  label.textContent = this._branchDisplayName(b);
@@ -1205,17 +1288,96 @@ export class ClaudeCodeSessionsWidget extends Widget {
1205
1288
  time.textContent = this._formatRelativeTime(b.file_mtime);
1206
1289
  row.appendChild(time);
1207
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
+ }
1208
1304
  dialog.dispose();
1209
1305
  void this._switchBranch(b.session_id);
1210
1306
  });
1211
1307
  list.appendChild(row);
1212
1308
  }
1213
1309
  };
1214
- 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
+ });
1215
1348
  render();
1349
+ updateControls();
1216
1350
  void dialog.launch();
1217
1351
  search.focus();
1218
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
+ }
1219
1381
  /** Switch the active row's project to another conversation branch.
1220
1382
  * The backend touches the branch JSONL's mtime; a refresh then shows
1221
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.5",
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,21 @@ 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\}\)`/
157
168
  );
158
169
  });
159
170
 
@@ -183,6 +194,70 @@ describe('launch spinner dismiss contract', () => {
183
194
  );
184
195
  });
185
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
+
186
261
  it('opens the menu without the submenu when the fetch fails', () => {
187
262
  expect(openMenu).toMatch(/catch[\s\S]*?hasBranches = false/);
188
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');
@@ -1214,9 +1226,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1214
1226
  });
1215
1227
 
1216
1228
  this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1217
- label: () => `More... (${this._lastBranches.length} total)`,
1229
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1218
1230
  execute: () => {
1219
- void this._showBranchPopup(this._lastBranches);
1231
+ void this._showBranchPopup(
1232
+ this._lastBranches,
1233
+ this._lastBranchesCurrent
1234
+ );
1220
1235
  }
1221
1236
  });
1222
1237
 
@@ -1257,7 +1272,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1257
1272
  // sessions/branches fetch.
1258
1273
  this._branchSubmenu = new Menu({ commands: this._commands });
1259
1274
  this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1260
- this._branchSubmenu.title.label = 'Switch Conversation Branch';
1275
+ this._branchSubmenu.title.label = 'Switch and Manage Sessions';
1261
1276
 
1262
1277
  this._contextMenu = new Menu({ commands: this._commands });
1263
1278
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
@@ -1321,9 +1336,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1321
1336
  { cache: 'no-store' }
1322
1337
  );
1323
1338
  this._lastBranches = data.branches;
1339
+ this._lastBranchesCurrent = data.current;
1324
1340
  this._branchSubmenu.clearItems();
1325
- // The submenu shows only the 5 most recent; the full list lives
1326
- // 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.
1327
1345
  for (const b of data.branches.slice(0, 5)) {
1328
1346
  this._branchSubmenu.addItem({
1329
1347
  command: 'claude-code-sessions:switch-branch',
@@ -1333,12 +1351,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
1333
1351
  }
1334
1352
  });
1335
1353
  }
1336
- if (data.branches.length > 5) {
1337
- this._branchSubmenu.addItem({ type: 'separator' });
1338
- this._branchSubmenu.addItem({
1339
- command: 'claude-code-sessions:switch-branch-more'
1340
- });
1341
- }
1354
+ this._branchSubmenu.addItem({ type: 'separator' });
1355
+ this._branchSubmenu.addItem({
1356
+ command: 'claude-code-sessions:switch-branch-more'
1357
+ });
1342
1358
  hasBranches = data.branches.length > 0;
1343
1359
  } catch {
1344
1360
  hasBranches = false;
@@ -1348,42 +1364,106 @@ export class ClaudeCodeSessionsWidget extends Widget {
1348
1364
  this._contextMenu.open(x, y);
1349
1365
  }
1350
1366
 
1351
- /** Popup with the project's full branch list - browse and filter when
1352
- * the list is too large for the submenu. Clicking an entry switches. */
1353
- 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
+
1354
1378
  const body = document.createElement('div');
1355
1379
  body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1356
1380
 
1357
1381
  const search = document.createElement('input');
1358
1382
  search.type = 'search';
1359
- search.placeholder = 'Filter branches...';
1383
+ search.placeholder = 'Filter sessions...';
1360
1384
  search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1361
1385
  body.appendChild(search);
1362
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
+
1363
1395
  const list = document.createElement('div');
1364
1396
  list.className = 'jp-ClaudeSessionsPanel-branchList';
1365
1397
  body.appendChild(list);
1366
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
+
1367
1406
  const bodyWidget = new Widget({ node: body });
1368
1407
  const dialog = new Dialog({
1369
- title: 'Switch Conversation Branch',
1408
+ title: 'Switch and Manage Sessions',
1370
1409
  body: bodyWidget,
1371
1410
  buttons: [Dialog.cancelButton()]
1372
1411
  });
1373
1412
 
1374
- const render = () => {
1413
+ const visibleMatches = (): IBranch[] => {
1375
1414
  const needle = search.value.trim().toLowerCase();
1376
- list.replaceChildren();
1377
- const matches = branches.filter(
1415
+ return items.filter(
1378
1416
  b =>
1379
1417
  !needle ||
1380
1418
  b.label.toLowerCase().includes(needle) ||
1381
1419
  b.session_id.toLowerCase().includes(needle)
1382
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();
1383
1461
  if (matches.length === 0) {
1384
1462
  const empty = document.createElement('div');
1385
1463
  empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1386
- empty.textContent = 'No matching branches.';
1464
+ empty.textContent = items.length
1465
+ ? 'No matching sessions.'
1466
+ : 'No other conversations.';
1387
1467
  list.appendChild(empty);
1388
1468
  return;
1389
1469
  }
@@ -1392,6 +1472,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
1392
1472
  row.className = 'jp-ClaudeSessionsPanel-branchRow';
1393
1473
  row.title = `Session id: ${b.session_id}`;
1394
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
+
1395
1490
  const label = document.createElement('span');
1396
1491
  label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1397
1492
  label.textContent = this._branchDisplayName(b);
@@ -1403,19 +1498,102 @@ export class ClaudeCodeSessionsWidget extends Widget {
1403
1498
  row.appendChild(time);
1404
1499
 
1405
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
+ }
1406
1513
  dialog.dispose();
1407
1514
  void this._switchBranch(b.session_id);
1408
1515
  });
1409
1516
  list.appendChild(row);
1410
1517
  }
1411
1518
  };
1412
- 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
+ });
1413
1559
  render();
1560
+ updateControls();
1414
1561
 
1415
1562
  void dialog.launch();
1416
1563
  search.focus();
1417
1564
  }
1418
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
+
1419
1597
  /** Switch the active row's project to another conversation branch.
1420
1598
  * The backend touches the branch JSONL's mtime; a refresh then shows
1421
1599
  * the selected conversation as the row's current one. */
@@ -1493,6 +1671,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1493
1671
  private _contextMenu!: Menu;
1494
1672
  private _branchSubmenu!: Menu;
1495
1673
  private _lastBranches: IBranch[] = [];
1674
+ private _lastBranchesCurrent = '';
1496
1675
  private _newSessionMenu!: Menu;
1497
1676
  private _activeSession: ISession | null = null;
1498
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
+ }