jupyterlab_claude_code_extension 1.1.29 → 1.2.2

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,6 +20,7 @@ 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
24
  - **Search** - fuzzy filter toggled by the funnel button next to refresh
24
25
  - **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
25
26
  - **Hover tooltip** with project path, last activity, message count, branch, and session id
package/lib/types.d.ts CHANGED
@@ -42,6 +42,24 @@ export interface ICleanupRequest {
42
42
  export interface ICleanupResponse {
43
43
  removed_count: number;
44
44
  }
45
+ export interface IBranch {
46
+ session_id: string;
47
+ file_mtime: number;
48
+ label: string;
49
+ }
50
+ export interface IBranchesResponse {
51
+ current: string;
52
+ total: number;
53
+ branches: IBranch[];
54
+ }
55
+ export interface ISwitchRequest {
56
+ encoded_path: string;
57
+ session_id: string;
58
+ }
59
+ export interface ISwitchResponse {
60
+ requested: string;
61
+ current: string | null;
62
+ }
45
63
  export interface ILaunchTerminalRequest {
46
64
  project_path: string;
47
65
  /** Omit to start a brand-new claude session instead of resuming one. */
package/lib/widget.d.ts CHANGED
@@ -95,6 +95,21 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
95
95
  private _setRefreshSpinning;
96
96
  private _setActiveRow;
97
97
  private _setupContextMenu;
98
+ /** Rebuild the context menu's items. Lumino submenu-type items have no
99
+ * ``isVisible`` hook, so the menu is rebuilt per open and the branch
100
+ * submenu inserted only when the row actually has branches. */
101
+ private _rebuildContextMenu;
102
+ /** Open the row context menu, populating the branch submenu first when
103
+ * the project has more than one conversation. On a fetch failure the
104
+ * menu opens without the submenu. */
105
+ 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. */
108
+ private _showBranchPopup;
109
+ /** Switch the active row's project to another conversation branch.
110
+ * The backend touches the branch JSONL's mtime; a refresh then shows
111
+ * the selected conversation as the row's current one. */
112
+ private _switchBranch;
98
113
  private _startPolling;
99
114
  private _stopPolling;
100
115
  private readonly _app;
@@ -107,6 +122,8 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
107
122
  private _expanded;
108
123
  private _commands;
109
124
  private _contextMenu;
125
+ private _branchSubmenu;
126
+ private _lastBranches;
110
127
  private _newSessionMenu;
111
128
  private _activeSession;
112
129
  private _activeRowEl;
package/lib/widget.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Clipboard, Dialog, Notification, showDialog } from '@jupyterlab/apputils';
2
+ import { ServerConnection } from '@jupyterlab/services';
2
3
  import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
3
4
  import { CommandRegistry } from '@lumino/commands';
4
5
  import { Menu, Widget } from '@lumino/widgets';
@@ -63,6 +64,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
63
64
  this._searchEl = null;
64
65
  this._sessions = null;
65
66
  this._expanded = loadExpanded();
67
+ this._lastBranches = [];
66
68
  this._activeSession = null;
67
69
  this._activeRowEl = null;
68
70
  this._pollHandle = null;
@@ -804,7 +806,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
804
806
  }
805
807
  const name = document.createElement('span');
806
808
  name.className = 'jp-ClaudeSessionsPanel-name';
807
- name.textContent = this._lookupName(session);
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);
808
814
  row.appendChild(name);
809
815
  // No star in the Favorites section - every row there is a favorite
810
816
  // by definition; stars are an indicator only useful in Recent/All.
@@ -828,7 +834,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
828
834
  }
829
835
  this._activeSession = session;
830
836
  this._setActiveRow(row);
831
- this._contextMenu.open(e.clientX, e.clientY);
837
+ void this._openContextMenu(session, e.clientX, e.clientY);
832
838
  });
833
839
  return row;
834
840
  }
@@ -845,6 +851,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
845
851
  if (s.message_count) {
846
852
  lines.push(`Messages: ${s.message_count}`);
847
853
  }
854
+ if (s.extra_sessions > 0) {
855
+ lines.push(`Conversations: ${s.extra_sessions + 1}`);
856
+ }
848
857
  if (s.git_branch) {
849
858
  lines.push(`Branch: ${s.git_branch}`);
850
859
  }
@@ -1012,6 +1021,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
1012
1021
  }
1013
1022
  }
1014
1023
  });
1024
+ this._commands.addCommand('claude-code-sessions:switch-branch', {
1025
+ label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
1026
+ execute: args => {
1027
+ var _a;
1028
+ const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
1029
+ if (sessionId) {
1030
+ void this._switchBranch(sessionId);
1031
+ }
1032
+ }
1033
+ });
1034
+ this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1035
+ label: () => `More... (${this._lastBranches.length} total)`,
1036
+ execute: () => {
1037
+ void this._showBranchPopup(this._lastBranches);
1038
+ }
1039
+ });
1015
1040
  this._commands.addCommand('claude-code-sessions:remove', {
1016
1041
  label: 'Remove from Claude',
1017
1042
  icon: removeIcon,
@@ -1040,8 +1065,28 @@ export class ClaudeCodeSessionsWidget extends Widget {
1040
1065
  this._newSessionMenu.addItem({
1041
1066
  command: 'claude-code-sessions:new-session-dangerous'
1042
1067
  });
1068
+ // Submenu listing the project's other conversations ("branches") -
1069
+ // items are rebuilt on every context-menu open from a fresh
1070
+ // sessions/branches fetch.
1071
+ this._branchSubmenu = new Menu({ commands: this._commands });
1072
+ this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1073
+ this._branchSubmenu.title.label = 'Switch Conversation Branch';
1043
1074
  this._contextMenu = new Menu({ commands: this._commands });
1044
1075
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1076
+ this._rebuildContextMenu(false);
1077
+ this._contextMenu.aboutToClose.connect(() => {
1078
+ // Only clear the visual highlight - DO NOT null _activeSession.
1079
+ // Lumino fires aboutToClose BEFORE the activated item's command runs,
1080
+ // so the command callback still needs to read _activeSession. The
1081
+ // field is overwritten on the next contextmenu open.
1082
+ this._setActiveRow(null);
1083
+ });
1084
+ }
1085
+ /** Rebuild the context menu's items. Lumino submenu-type items have no
1086
+ * ``isVisible`` hook, so the menu is rebuilt per open and the branch
1087
+ * submenu inserted only when the row actually has branches. */
1088
+ _rebuildContextMenu(withBranches) {
1089
+ this._contextMenu.clearItems();
1045
1090
  this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
1046
1091
  this._contextMenu.addItem({
1047
1092
  command: 'claude-code-sessions:resume-dangerous'
@@ -1057,17 +1102,141 @@ export class ClaudeCodeSessionsWidget extends Widget {
1057
1102
  });
1058
1103
  this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
1059
1104
  this._contextMenu.addItem({ type: 'separator' });
1105
+ if (withBranches) {
1106
+ this._contextMenu.addItem({
1107
+ type: 'submenu',
1108
+ submenu: this._branchSubmenu
1109
+ });
1110
+ }
1060
1111
  this._contextMenu.addItem({
1061
1112
  command: 'claude-code-sessions:cleanup-parallel'
1062
1113
  });
1063
1114
  this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
1064
- this._contextMenu.aboutToClose.connect(() => {
1065
- // Only clear the visual highlight - DO NOT null _activeSession.
1066
- // Lumino fires aboutToClose BEFORE the activated item's command runs,
1067
- // so the command callback still needs to read _activeSession. The
1068
- // field is overwritten on the next contextmenu open.
1069
- this._setActiveRow(null);
1115
+ }
1116
+ /** Open the row context menu, populating the branch submenu first when
1117
+ * the project has more than one conversation. On a fetch failure the
1118
+ * menu opens without the submenu. */
1119
+ async _openContextMenu(session, x, y) {
1120
+ let hasBranches = false;
1121
+ if (session.extra_sessions > 0) {
1122
+ try {
1123
+ const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1124
+ this._lastBranches = data.branches;
1125
+ this._branchSubmenu.clearItems();
1126
+ // The submenu shows only the 5 most recent; the full list lives
1127
+ // behind "More..." in a searchable popup.
1128
+ for (const b of data.branches.slice(0, 5)) {
1129
+ this._branchSubmenu.addItem({
1130
+ command: 'claude-code-sessions:switch-branch',
1131
+ args: {
1132
+ session_id: b.session_id,
1133
+ label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1134
+ }
1135
+ });
1136
+ }
1137
+ if (data.branches.length > 5) {
1138
+ this._branchSubmenu.addItem({ type: 'separator' });
1139
+ this._branchSubmenu.addItem({
1140
+ command: 'claude-code-sessions:switch-branch-more'
1141
+ });
1142
+ }
1143
+ hasBranches = data.branches.length > 0;
1144
+ }
1145
+ catch (_a) {
1146
+ hasBranches = false;
1147
+ }
1148
+ }
1149
+ this._rebuildContextMenu(hasBranches);
1150
+ this._contextMenu.open(x, y);
1151
+ }
1152
+ /** Popup with the project's full branch list - browse and filter when
1153
+ * the list is too large for the submenu. Clicking an entry switches. */
1154
+ _showBranchPopup(branches) {
1155
+ const body = document.createElement('div');
1156
+ body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1157
+ const search = document.createElement('input');
1158
+ search.type = 'search';
1159
+ search.placeholder = 'Filter branches...';
1160
+ search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1161
+ body.appendChild(search);
1162
+ const list = document.createElement('div');
1163
+ list.className = 'jp-ClaudeSessionsPanel-branchList';
1164
+ body.appendChild(list);
1165
+ const bodyWidget = new Widget({ node: body });
1166
+ const dialog = new Dialog({
1167
+ title: 'Switch Conversation Branch',
1168
+ body: bodyWidget,
1169
+ buttons: [Dialog.cancelButton()]
1070
1170
  });
1171
+ const render = () => {
1172
+ const needle = search.value.trim().toLowerCase();
1173
+ list.replaceChildren();
1174
+ const matches = branches.filter(b => !needle ||
1175
+ b.label.toLowerCase().includes(needle) ||
1176
+ b.session_id.toLowerCase().includes(needle));
1177
+ if (matches.length === 0) {
1178
+ const empty = document.createElement('div');
1179
+ empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1180
+ empty.textContent = 'No matching branches.';
1181
+ list.appendChild(empty);
1182
+ return;
1183
+ }
1184
+ for (const b of matches) {
1185
+ const row = document.createElement('div');
1186
+ row.className = 'jp-ClaudeSessionsPanel-branchRow';
1187
+ row.title = `Session id: ${b.session_id}`;
1188
+ const label = document.createElement('span');
1189
+ label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1190
+ label.textContent = b.label;
1191
+ row.appendChild(label);
1192
+ const time = document.createElement('span');
1193
+ time.className = 'jp-ClaudeSessionsPanel-branchTime';
1194
+ time.textContent = this._formatRelativeTime(b.file_mtime);
1195
+ row.appendChild(time);
1196
+ row.addEventListener('click', () => {
1197
+ dialog.dispose();
1198
+ void this._switchBranch(b.session_id);
1199
+ });
1200
+ list.appendChild(row);
1201
+ }
1202
+ };
1203
+ search.addEventListener('input', render);
1204
+ render();
1205
+ void dialog.launch();
1206
+ search.focus();
1207
+ }
1208
+ /** Switch the active row's project to another conversation branch.
1209
+ * The backend touches the branch JSONL's mtime; a refresh then shows
1210
+ * the selected conversation as the row's current one. */
1211
+ async _switchBranch(sessionId) {
1212
+ const session = this._activeSession;
1213
+ if (!session) {
1214
+ return;
1215
+ }
1216
+ try {
1217
+ const result = await requestAPI('sessions/switch', this._serverSettings, {
1218
+ method: 'POST',
1219
+ body: JSON.stringify({
1220
+ encoded_path: session.encoded_path,
1221
+ session_id: sessionId
1222
+ })
1223
+ });
1224
+ if (result.current !== result.requested) {
1225
+ // The branch's recorded cwd is inconsistent with the project dir,
1226
+ // so the recency resolution cannot make it current.
1227
+ Notification.warning('Branch cannot become current - its recorded folder does not match the project.', { autoClose: 4000 });
1228
+ }
1229
+ }
1230
+ catch (err) {
1231
+ const notFound = err instanceof ServerConnection.ResponseError &&
1232
+ err.response.status === 404;
1233
+ Notification.error(notFound
1234
+ ? 'Branch no longer exists - the session list has been refreshed.'
1235
+ : `Branch switch failed: ${err}`, { autoClose: 4000 });
1236
+ }
1237
+ finally {
1238
+ await this._fetch();
1239
+ }
1071
1240
  }
1072
1241
  // --------------------------------------------------------------- polling
1073
1242
  _startPolling() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.1.29",
3
+ "version": "1.2.2",
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",
@@ -122,6 +122,73 @@ describe('launch spinner dismiss contract', () => {
122
122
  });
123
123
  });
124
124
 
125
+ /**
126
+ * Contract for branch switching: the context menu is rebuilt on every
127
+ * open (Lumino submenu items have no isVisible hook), the branch
128
+ * submenu is repopulated from a fresh sessions/branches fetch, the
129
+ * row name carries the conversation count only when branches exist,
130
+ * and _switchBranch always resyncs the list - including after a 404
131
+ * for a branch that vanished between menu display and click.
132
+ */
133
+ describe('branch switching contract', () => {
134
+ const openMenu = (widgetSrc.match(
135
+ /private async _openContextMenu[\s\S]*?\n \}/
136
+ ) ?? [''])[0];
137
+ const switchBranch = (widgetSrc.match(
138
+ /private async _switchBranch[\s\S]*?\n \}/
139
+ ) ?? [''])[0];
140
+
141
+ it('shows the conversation count only when the project has branches', () => {
142
+ expect(widgetSrc).toMatch(
143
+ /session\.extra_sessions > 0\s*\?\s*`\$\{this\._lookupName\(session\)\} \(\$\{session\.extra_sessions \+ 1\}\)`/
144
+ );
145
+ });
146
+
147
+ it('rebuilds submenu items from a fresh branches fetch on open', () => {
148
+ expect(openMenu).toMatch(/sessions\/branches\?encoded_path=/);
149
+ expect(openMenu).toMatch(/_branchSubmenu\.clearItems\(\)/);
150
+ expect(openMenu).toMatch(/_rebuildContextMenu\(hasBranches\)/);
151
+ });
152
+
153
+ it('caps the submenu at 5 and adds More... beyond that', () => {
154
+ expect(openMenu).toMatch(/\.slice\(0, 5\)/);
155
+ expect(openMenu).toMatch(
156
+ /branches\.length > 5[\s\S]*?switch-branch-more/
157
+ );
158
+ });
159
+
160
+ it('More... popup filters by label or session id and switches on click', () => {
161
+ const popup = (widgetSrc.match(
162
+ /private _showBranchPopup[\s\S]*?\n \}/
163
+ ) ?? [''])[0];
164
+ expect(popup).toMatch(/createElement\('input'\)/);
165
+ expect(popup).toMatch(/label\.toLowerCase\(\)\.includes\(needle\)/);
166
+ expect(popup).toMatch(/session_id\.toLowerCase\(\)\.includes\(needle\)/);
167
+ expect(popup).toMatch(
168
+ /dialog\.dispose\(\);\s*void this\._switchBranch\(b\.session_id\)/
169
+ );
170
+ });
171
+
172
+ it('opens the menu without the submenu when the fetch fails', () => {
173
+ expect(openMenu).toMatch(/catch[\s\S]*?hasBranches = false/);
174
+ });
175
+
176
+ it('resyncs the session list after every switch attempt', () => {
177
+ expect(switchBranch).toMatch(/finally[\s\S]*?await this\._fetch\(\)/);
178
+ });
179
+
180
+ it('reports a removed branch distinctly via the 404 status', () => {
181
+ expect(switchBranch).toMatch(/status === 404/);
182
+ expect(switchBranch).toMatch(/Branch no longer exists/);
183
+ });
184
+
185
+ it('warns when the resolved current differs from the requested branch', () => {
186
+ expect(switchBranch).toMatch(
187
+ /result\.current !== result\.requested[\s\S]*?Notification\.warning/
188
+ );
189
+ });
190
+ });
191
+
125
192
  it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
126
193
  expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
127
194
  expect(widgetSrc).not.toMatch(/spinner\.resolve\(\)/);
package/src/types.ts CHANGED
@@ -51,6 +51,28 @@ export interface ICleanupResponse {
51
51
  removed_count: number;
52
52
  }
53
53
 
54
+ export interface IBranch {
55
+ session_id: string;
56
+ file_mtime: number;
57
+ label: string;
58
+ }
59
+
60
+ export interface IBranchesResponse {
61
+ current: string;
62
+ total: number;
63
+ branches: IBranch[];
64
+ }
65
+
66
+ export interface ISwitchRequest {
67
+ encoded_path: string;
68
+ session_id: string;
69
+ }
70
+
71
+ export interface ISwitchResponse {
72
+ requested: string;
73
+ current: string | null;
74
+ }
75
+
54
76
  export interface ILaunchTerminalRequest {
55
77
  project_path: string;
56
78
  /** Omit to start a brand-new claude session instead of resuming one. */
package/src/widget.ts CHANGED
@@ -24,12 +24,15 @@ import {
24
24
  starFilledIcon
25
25
  } from './icons';
26
26
  import {
27
+ IBranch,
28
+ IBranchesResponse,
27
29
  IFavouriteResponse,
28
30
  ILaunchTerminalResponse,
29
31
  ICleanupResponse,
30
32
  IRemoveResponse,
31
33
  ISession,
32
- ISessionsListResponse
34
+ ISessionsListResponse,
35
+ ISwitchResponse
33
36
  } from './types';
34
37
 
35
38
  const POLL_INTERVAL_MS = 30_000;
@@ -948,7 +951,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
948
951
 
949
952
  const name = document.createElement('span');
950
953
  name.className = 'jp-ClaudeSessionsPanel-name';
951
- name.textContent = this._lookupName(session);
954
+ // Conversation count in brackets - only when the project has branches.
955
+ name.textContent =
956
+ session.extra_sessions > 0
957
+ ? `${this._lookupName(session)} (${session.extra_sessions + 1})`
958
+ : this._lookupName(session);
952
959
  row.appendChild(name);
953
960
 
954
961
  // No star in the Favorites section - every row there is a favorite
@@ -974,7 +981,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
974
981
  }
975
982
  this._activeSession = session;
976
983
  this._setActiveRow(row);
977
- this._contextMenu.open(e.clientX, e.clientY);
984
+ void this._openContextMenu(session, e.clientX, e.clientY);
978
985
  });
979
986
 
980
987
  return row;
@@ -993,6 +1000,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
993
1000
  if (s.message_count) {
994
1001
  lines.push(`Messages: ${s.message_count}`);
995
1002
  }
1003
+ if (s.extra_sessions > 0) {
1004
+ lines.push(`Conversations: ${s.extra_sessions + 1}`);
1005
+ }
996
1006
  if (s.git_branch) {
997
1007
  lines.push(`Branch: ${s.git_branch}`);
998
1008
  }
@@ -1180,6 +1190,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
1180
1190
  }
1181
1191
  });
1182
1192
 
1193
+ this._commands.addCommand('claude-code-sessions:switch-branch', {
1194
+ label: args => String(args.label ?? ''),
1195
+ execute: args => {
1196
+ const sessionId = String(args.session_id ?? '');
1197
+ if (sessionId) {
1198
+ void this._switchBranch(sessionId);
1199
+ }
1200
+ }
1201
+ });
1202
+
1203
+ this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1204
+ label: () => `More... (${this._lastBranches.length} total)`,
1205
+ execute: () => {
1206
+ void this._showBranchPopup(this._lastBranches);
1207
+ }
1208
+ });
1209
+
1183
1210
  this._commands.addCommand('claude-code-sessions:remove', {
1184
1211
  label: 'Remove from Claude',
1185
1212
  icon: removeIcon,
@@ -1212,8 +1239,31 @@ export class ClaudeCodeSessionsWidget extends Widget {
1212
1239
  command: 'claude-code-sessions:new-session-dangerous'
1213
1240
  });
1214
1241
 
1242
+ // Submenu listing the project's other conversations ("branches") -
1243
+ // items are rebuilt on every context-menu open from a fresh
1244
+ // sessions/branches fetch.
1245
+ this._branchSubmenu = new Menu({ commands: this._commands });
1246
+ this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1247
+ this._branchSubmenu.title.label = 'Switch Conversation Branch';
1248
+
1215
1249
  this._contextMenu = new Menu({ commands: this._commands });
1216
1250
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1251
+ this._rebuildContextMenu(false);
1252
+
1253
+ this._contextMenu.aboutToClose.connect(() => {
1254
+ // Only clear the visual highlight - DO NOT null _activeSession.
1255
+ // Lumino fires aboutToClose BEFORE the activated item's command runs,
1256
+ // so the command callback still needs to read _activeSession. The
1257
+ // field is overwritten on the next contextmenu open.
1258
+ this._setActiveRow(null);
1259
+ });
1260
+ }
1261
+
1262
+ /** Rebuild the context menu's items. Lumino submenu-type items have no
1263
+ * ``isVisible`` hook, so the menu is rebuilt per open and the branch
1264
+ * submenu inserted only when the row actually has branches. */
1265
+ private _rebuildContextMenu(withBranches: boolean): void {
1266
+ this._contextMenu.clearItems();
1217
1267
  this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
1218
1268
  this._contextMenu.addItem({
1219
1269
  command: 'claude-code-sessions:resume-dangerous'
@@ -1229,18 +1279,171 @@ export class ClaudeCodeSessionsWidget extends Widget {
1229
1279
  });
1230
1280
  this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
1231
1281
  this._contextMenu.addItem({ type: 'separator' });
1282
+ if (withBranches) {
1283
+ this._contextMenu.addItem({
1284
+ type: 'submenu',
1285
+ submenu: this._branchSubmenu
1286
+ });
1287
+ }
1232
1288
  this._contextMenu.addItem({
1233
1289
  command: 'claude-code-sessions:cleanup-parallel'
1234
1290
  });
1235
1291
  this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
1292
+ }
1236
1293
 
1237
- this._contextMenu.aboutToClose.connect(() => {
1238
- // Only clear the visual highlight - DO NOT null _activeSession.
1239
- // Lumino fires aboutToClose BEFORE the activated item's command runs,
1240
- // so the command callback still needs to read _activeSession. The
1241
- // field is overwritten on the next contextmenu open.
1242
- this._setActiveRow(null);
1294
+ /** Open the row context menu, populating the branch submenu first when
1295
+ * the project has more than one conversation. On a fetch failure the
1296
+ * menu opens without the submenu. */
1297
+ private async _openContextMenu(
1298
+ session: ISession,
1299
+ x: number,
1300
+ y: number
1301
+ ): Promise<void> {
1302
+ let hasBranches = false;
1303
+ if (session.extra_sessions > 0) {
1304
+ try {
1305
+ const data = await requestAPI<IBranchesResponse>(
1306
+ `sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
1307
+ this._serverSettings,
1308
+ { cache: 'no-store' }
1309
+ );
1310
+ this._lastBranches = data.branches;
1311
+ this._branchSubmenu.clearItems();
1312
+ // The submenu shows only the 5 most recent; the full list lives
1313
+ // behind "More..." in a searchable popup.
1314
+ for (const b of data.branches.slice(0, 5)) {
1315
+ this._branchSubmenu.addItem({
1316
+ command: 'claude-code-sessions:switch-branch',
1317
+ args: {
1318
+ session_id: b.session_id,
1319
+ label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1320
+ }
1321
+ });
1322
+ }
1323
+ if (data.branches.length > 5) {
1324
+ this._branchSubmenu.addItem({ type: 'separator' });
1325
+ this._branchSubmenu.addItem({
1326
+ command: 'claude-code-sessions:switch-branch-more'
1327
+ });
1328
+ }
1329
+ hasBranches = data.branches.length > 0;
1330
+ } catch {
1331
+ hasBranches = false;
1332
+ }
1333
+ }
1334
+ this._rebuildContextMenu(hasBranches);
1335
+ this._contextMenu.open(x, y);
1336
+ }
1337
+
1338
+ /** Popup with the project's full branch list - browse and filter when
1339
+ * the list is too large for the submenu. Clicking an entry switches. */
1340
+ private _showBranchPopup(branches: IBranch[]): void {
1341
+ const body = document.createElement('div');
1342
+ body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1343
+
1344
+ const search = document.createElement('input');
1345
+ search.type = 'search';
1346
+ search.placeholder = 'Filter branches...';
1347
+ search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1348
+ body.appendChild(search);
1349
+
1350
+ const list = document.createElement('div');
1351
+ list.className = 'jp-ClaudeSessionsPanel-branchList';
1352
+ body.appendChild(list);
1353
+
1354
+ const bodyWidget = new Widget({ node: body });
1355
+ const dialog = new Dialog({
1356
+ title: 'Switch Conversation Branch',
1357
+ body: bodyWidget,
1358
+ buttons: [Dialog.cancelButton()]
1243
1359
  });
1360
+
1361
+ const render = () => {
1362
+ const needle = search.value.trim().toLowerCase();
1363
+ list.replaceChildren();
1364
+ const matches = branches.filter(
1365
+ b =>
1366
+ !needle ||
1367
+ b.label.toLowerCase().includes(needle) ||
1368
+ b.session_id.toLowerCase().includes(needle)
1369
+ );
1370
+ if (matches.length === 0) {
1371
+ const empty = document.createElement('div');
1372
+ empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1373
+ empty.textContent = 'No matching branches.';
1374
+ list.appendChild(empty);
1375
+ return;
1376
+ }
1377
+ for (const b of matches) {
1378
+ const row = document.createElement('div');
1379
+ row.className = 'jp-ClaudeSessionsPanel-branchRow';
1380
+ row.title = `Session id: ${b.session_id}`;
1381
+
1382
+ const label = document.createElement('span');
1383
+ label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1384
+ label.textContent = b.label;
1385
+ row.appendChild(label);
1386
+
1387
+ const time = document.createElement('span');
1388
+ time.className = 'jp-ClaudeSessionsPanel-branchTime';
1389
+ time.textContent = this._formatRelativeTime(b.file_mtime);
1390
+ row.appendChild(time);
1391
+
1392
+ row.addEventListener('click', () => {
1393
+ dialog.dispose();
1394
+ void this._switchBranch(b.session_id);
1395
+ });
1396
+ list.appendChild(row);
1397
+ }
1398
+ };
1399
+ search.addEventListener('input', render);
1400
+ render();
1401
+
1402
+ void dialog.launch();
1403
+ search.focus();
1404
+ }
1405
+
1406
+ /** Switch the active row's project to another conversation branch.
1407
+ * The backend touches the branch JSONL's mtime; a refresh then shows
1408
+ * the selected conversation as the row's current one. */
1409
+ private async _switchBranch(sessionId: string): Promise<void> {
1410
+ const session = this._activeSession;
1411
+ if (!session) {
1412
+ return;
1413
+ }
1414
+ try {
1415
+ const result = await requestAPI<ISwitchResponse>(
1416
+ 'sessions/switch',
1417
+ this._serverSettings,
1418
+ {
1419
+ method: 'POST',
1420
+ body: JSON.stringify({
1421
+ encoded_path: session.encoded_path,
1422
+ session_id: sessionId
1423
+ })
1424
+ }
1425
+ );
1426
+ if (result.current !== result.requested) {
1427
+ // The branch's recorded cwd is inconsistent with the project dir,
1428
+ // so the recency resolution cannot make it current.
1429
+ Notification.warning(
1430
+ 'Branch cannot become current - its recorded folder does not match the project.',
1431
+ { autoClose: 4000 }
1432
+ );
1433
+ }
1434
+ } catch (err) {
1435
+ const notFound =
1436
+ err instanceof ServerConnection.ResponseError &&
1437
+ err.response.status === 404;
1438
+ Notification.error(
1439
+ notFound
1440
+ ? 'Branch no longer exists - the session list has been refreshed.'
1441
+ : `Branch switch failed: ${err}`,
1442
+ { autoClose: 4000 }
1443
+ );
1444
+ } finally {
1445
+ await this._fetch();
1446
+ }
1244
1447
  }
1245
1448
 
1246
1449
  // --------------------------------------------------------------- polling
@@ -1275,6 +1478,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
1275
1478
  private _expanded: Record<SectionKey, boolean> = loadExpanded();
1276
1479
  private _commands!: CommandRegistry;
1277
1480
  private _contextMenu!: Menu;
1481
+ private _branchSubmenu!: Menu;
1482
+ private _lastBranches: IBranch[] = [];
1278
1483
  private _newSessionMenu!: Menu;
1279
1484
  private _activeSession: ISession | null = null;
1280
1485
  private _activeRowEl: HTMLElement | null = null;
package/style/base.css CHANGED
@@ -301,3 +301,48 @@
301
301
  display: block;
302
302
  margin: 0 auto;
303
303
  }
304
+
305
+ /* Branch-switch popup: search box over a scrollable branch list. */
306
+ .jp-ClaudeSessionsPanel-branchPopup {
307
+ display: flex;
308
+ flex-direction: column;
309
+ gap: 8px;
310
+ min-width: 320px;
311
+ }
312
+
313
+ .jp-ClaudeSessionsPanel-branchSearch {
314
+ width: 100%;
315
+ box-sizing: border-box;
316
+ padding: 4px 6px;
317
+ }
318
+
319
+ .jp-ClaudeSessionsPanel-branchList {
320
+ max-height: 280px;
321
+ overflow-y: auto;
322
+ }
323
+
324
+ .jp-ClaudeSessionsPanel-branchRow {
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: space-between;
328
+ gap: 12px;
329
+ padding: 4px 6px;
330
+ cursor: pointer;
331
+ border-radius: 2px;
332
+ }
333
+
334
+ .jp-ClaudeSessionsPanel-branchRow:hover {
335
+ background: var(--jp-layout-color2);
336
+ }
337
+
338
+ .jp-ClaudeSessionsPanel-branchLabel {
339
+ overflow: hidden;
340
+ text-overflow: ellipsis;
341
+ white-space: nowrap;
342
+ }
343
+
344
+ .jp-ClaudeSessionsPanel-branchTime {
345
+ flex: none;
346
+ color: var(--jp-ui-font-color2);
347
+ font-size: var(--jp-ui-font-size0);
348
+ }