jupyterlab_claude_code_extension 1.1.31 → 1.2.4

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,8 +806,18 @@ 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);
815
+ if (session.file_mtime) {
816
+ const time = document.createElement('span');
817
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
818
+ time.textContent = this._formatRelativeTime(session.file_mtime);
819
+ row.appendChild(time);
820
+ }
809
821
  // No star in the Favorites section - every row there is a favorite
810
822
  // by definition; stars are an indicator only useful in Recent/All.
811
823
  if (session.favourite && sectionKey !== 'favourites') {
@@ -828,7 +840,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
828
840
  }
829
841
  this._activeSession = session;
830
842
  this._setActiveRow(row);
831
- this._contextMenu.open(e.clientX, e.clientY);
843
+ void this._openContextMenu(session, e.clientX, e.clientY);
832
844
  });
833
845
  return row;
834
846
  }
@@ -845,6 +857,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
845
857
  if (s.message_count) {
846
858
  lines.push(`Messages: ${s.message_count}`);
847
859
  }
860
+ if (s.extra_sessions > 0) {
861
+ lines.push(`Conversations: ${s.extra_sessions + 1}`);
862
+ }
848
863
  if (s.git_branch) {
849
864
  lines.push(`Branch: ${s.git_branch}`);
850
865
  }
@@ -889,7 +904,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
889
904
  }
890
905
  const diff = Date.now() - epochMs;
891
906
  if (diff < 60000) {
892
- return 'just now';
907
+ return 'now';
893
908
  }
894
909
  if (diff < 3600000) {
895
910
  return `${Math.floor(diff / 60000)}m ago`;
@@ -897,10 +912,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
897
912
  if (diff < 86400000) {
898
913
  return `${Math.floor(diff / 3600000)}h ago`;
899
914
  }
900
- if (diff < 30 * 86400000) {
901
- return `${Math.floor(diff / 86400000)}d ago`;
902
- }
903
- return new Date(epochMs).toLocaleDateString();
915
+ return `${Math.floor(diff / 86400000)}d ago`;
904
916
  }
905
917
  _setRefreshSpinning(on) {
906
918
  if (!this._refreshBtn) {
@@ -1012,6 +1024,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
1012
1024
  }
1013
1025
  }
1014
1026
  });
1027
+ this._commands.addCommand('claude-code-sessions:switch-branch', {
1028
+ label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
1029
+ execute: args => {
1030
+ var _a;
1031
+ const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
1032
+ if (sessionId) {
1033
+ void this._switchBranch(sessionId);
1034
+ }
1035
+ }
1036
+ });
1037
+ this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1038
+ label: () => `More... (${this._lastBranches.length} total)`,
1039
+ execute: () => {
1040
+ void this._showBranchPopup(this._lastBranches);
1041
+ }
1042
+ });
1015
1043
  this._commands.addCommand('claude-code-sessions:remove', {
1016
1044
  label: 'Remove from Claude',
1017
1045
  icon: removeIcon,
@@ -1040,8 +1068,28 @@ export class ClaudeCodeSessionsWidget extends Widget {
1040
1068
  this._newSessionMenu.addItem({
1041
1069
  command: 'claude-code-sessions:new-session-dangerous'
1042
1070
  });
1071
+ // Submenu listing the project's other conversations ("branches") -
1072
+ // items are rebuilt on every context-menu open from a fresh
1073
+ // sessions/branches fetch.
1074
+ this._branchSubmenu = new Menu({ commands: this._commands });
1075
+ this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1076
+ this._branchSubmenu.title.label = 'Switch Conversation Branch';
1043
1077
  this._contextMenu = new Menu({ commands: this._commands });
1044
1078
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1079
+ this._rebuildContextMenu(false);
1080
+ this._contextMenu.aboutToClose.connect(() => {
1081
+ // Only clear the visual highlight - DO NOT null _activeSession.
1082
+ // Lumino fires aboutToClose BEFORE the activated item's command runs,
1083
+ // so the command callback still needs to read _activeSession. The
1084
+ // field is overwritten on the next contextmenu open.
1085
+ this._setActiveRow(null);
1086
+ });
1087
+ }
1088
+ /** Rebuild the context menu's items. Lumino submenu-type items have no
1089
+ * ``isVisible`` hook, so the menu is rebuilt per open and the branch
1090
+ * submenu inserted only when the row actually has branches. */
1091
+ _rebuildContextMenu(withBranches) {
1092
+ this._contextMenu.clearItems();
1045
1093
  this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
1046
1094
  this._contextMenu.addItem({
1047
1095
  command: 'claude-code-sessions:resume-dangerous'
@@ -1057,17 +1105,141 @@ export class ClaudeCodeSessionsWidget extends Widget {
1057
1105
  });
1058
1106
  this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
1059
1107
  this._contextMenu.addItem({ type: 'separator' });
1108
+ if (withBranches) {
1109
+ this._contextMenu.addItem({
1110
+ type: 'submenu',
1111
+ submenu: this._branchSubmenu
1112
+ });
1113
+ }
1060
1114
  this._contextMenu.addItem({
1061
1115
  command: 'claude-code-sessions:cleanup-parallel'
1062
1116
  });
1063
1117
  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);
1118
+ }
1119
+ /** Open the row context menu, populating the branch submenu first when
1120
+ * the project has more than one conversation. On a fetch failure the
1121
+ * menu opens without the submenu. */
1122
+ async _openContextMenu(session, x, y) {
1123
+ let hasBranches = false;
1124
+ if (session.extra_sessions > 0) {
1125
+ try {
1126
+ const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
1127
+ this._lastBranches = data.branches;
1128
+ this._branchSubmenu.clearItems();
1129
+ // The submenu shows only the 5 most recent; the full list lives
1130
+ // behind "More..." in a searchable popup.
1131
+ for (const b of data.branches.slice(0, 5)) {
1132
+ this._branchSubmenu.addItem({
1133
+ command: 'claude-code-sessions:switch-branch',
1134
+ args: {
1135
+ session_id: b.session_id,
1136
+ label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1137
+ }
1138
+ });
1139
+ }
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
+ }
1146
+ hasBranches = data.branches.length > 0;
1147
+ }
1148
+ catch (_a) {
1149
+ hasBranches = false;
1150
+ }
1151
+ }
1152
+ this._rebuildContextMenu(hasBranches);
1153
+ this._contextMenu.open(x, y);
1154
+ }
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) {
1158
+ const body = document.createElement('div');
1159
+ body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1160
+ const search = document.createElement('input');
1161
+ search.type = 'search';
1162
+ search.placeholder = 'Filter branches...';
1163
+ search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1164
+ body.appendChild(search);
1165
+ const list = document.createElement('div');
1166
+ list.className = 'jp-ClaudeSessionsPanel-branchList';
1167
+ body.appendChild(list);
1168
+ const bodyWidget = new Widget({ node: body });
1169
+ const dialog = new Dialog({
1170
+ title: 'Switch Conversation Branch',
1171
+ body: bodyWidget,
1172
+ buttons: [Dialog.cancelButton()]
1070
1173
  });
1174
+ const render = () => {
1175
+ const needle = search.value.trim().toLowerCase();
1176
+ list.replaceChildren();
1177
+ const matches = branches.filter(b => !needle ||
1178
+ b.label.toLowerCase().includes(needle) ||
1179
+ b.session_id.toLowerCase().includes(needle));
1180
+ if (matches.length === 0) {
1181
+ const empty = document.createElement('div');
1182
+ empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1183
+ empty.textContent = 'No matching branches.';
1184
+ list.appendChild(empty);
1185
+ return;
1186
+ }
1187
+ for (const b of matches) {
1188
+ const row = document.createElement('div');
1189
+ row.className = 'jp-ClaudeSessionsPanel-branchRow';
1190
+ row.title = `Session id: ${b.session_id}`;
1191
+ const label = document.createElement('span');
1192
+ label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1193
+ label.textContent = b.label;
1194
+ row.appendChild(label);
1195
+ const time = document.createElement('span');
1196
+ time.className = 'jp-ClaudeSessionsPanel-branchTime';
1197
+ time.textContent = this._formatRelativeTime(b.file_mtime);
1198
+ row.appendChild(time);
1199
+ row.addEventListener('click', () => {
1200
+ dialog.dispose();
1201
+ void this._switchBranch(b.session_id);
1202
+ });
1203
+ list.appendChild(row);
1204
+ }
1205
+ };
1206
+ search.addEventListener('input', render);
1207
+ render();
1208
+ void dialog.launch();
1209
+ search.focus();
1210
+ }
1211
+ /** Switch the active row's project to another conversation branch.
1212
+ * The backend touches the branch JSONL's mtime; a refresh then shows
1213
+ * the selected conversation as the row's current one. */
1214
+ async _switchBranch(sessionId) {
1215
+ const session = this._activeSession;
1216
+ if (!session) {
1217
+ return;
1218
+ }
1219
+ try {
1220
+ const result = await requestAPI('sessions/switch', this._serverSettings, {
1221
+ method: 'POST',
1222
+ body: JSON.stringify({
1223
+ encoded_path: session.encoded_path,
1224
+ session_id: sessionId
1225
+ })
1226
+ });
1227
+ if (result.current !== result.requested) {
1228
+ // The branch's recorded cwd is inconsistent with the project dir,
1229
+ // so the recency resolution cannot make it current.
1230
+ Notification.warning('Branch cannot become current - its recorded folder does not match the project.', { autoClose: 4000 });
1231
+ }
1232
+ }
1233
+ catch (err) {
1234
+ const notFound = err instanceof ServerConnection.ResponseError &&
1235
+ err.response.status === 404;
1236
+ Notification.error(notFound
1237
+ ? 'Branch no longer exists - the session list has been refreshed.'
1238
+ : `Branch switch failed: ${err}`, { autoClose: 4000 });
1239
+ }
1240
+ finally {
1241
+ await this._fetch();
1242
+ }
1071
1243
  }
1072
1244
  // --------------------------------------------------------------- polling
1073
1245
  _startPolling() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.1.31",
3
+ "version": "1.2.4",
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,90 @@ 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('shows last activity on session rows via the shared formatter', () => {
186
+ expect(widgetSrc).toMatch(
187
+ /jp-ClaudeSessionsPanel-rowTime[\s\S]*?_formatRelativeTime\(session\.file_mtime\)/
188
+ );
189
+ });
190
+
191
+ it('formats relative time as now / m / h / d ago, no date fallback', () => {
192
+ const fmt = (widgetSrc.match(
193
+ /private _formatRelativeTime[\s\S]*?\n \}/
194
+ ) ?? [''])[0];
195
+ expect(fmt).toMatch(/return 'now'/);
196
+ expect(fmt).toMatch(/m ago/);
197
+ expect(fmt).toMatch(/h ago/);
198
+ expect(fmt).toMatch(/d ago/);
199
+ expect(fmt).not.toMatch(/toLocaleDateString/);
200
+ });
201
+
202
+ it('warns when the resolved current differs from the requested branch', () => {
203
+ expect(switchBranch).toMatch(
204
+ /result\.current !== result\.requested[\s\S]*?Notification\.warning/
205
+ );
206
+ });
207
+ });
208
+
125
209
  it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
126
210
  expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
127
211
  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,9 +951,20 @@ 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
 
961
+ if (session.file_mtime) {
962
+ const time = document.createElement('span');
963
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
964
+ time.textContent = this._formatRelativeTime(session.file_mtime);
965
+ row.appendChild(time);
966
+ }
967
+
954
968
  // No star in the Favorites section - every row there is a favorite
955
969
  // by definition; stars are an indicator only useful in Recent/All.
956
970
  if (session.favourite && sectionKey !== 'favourites') {
@@ -974,7 +988,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
974
988
  }
975
989
  this._activeSession = session;
976
990
  this._setActiveRow(row);
977
- this._contextMenu.open(e.clientX, e.clientY);
991
+ void this._openContextMenu(session, e.clientX, e.clientY);
978
992
  });
979
993
 
980
994
  return row;
@@ -993,6 +1007,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
993
1007
  if (s.message_count) {
994
1008
  lines.push(`Messages: ${s.message_count}`);
995
1009
  }
1010
+ if (s.extra_sessions > 0) {
1011
+ lines.push(`Conversations: ${s.extra_sessions + 1}`);
1012
+ }
996
1013
  if (s.git_branch) {
997
1014
  lines.push(`Branch: ${s.git_branch}`);
998
1015
  }
@@ -1040,7 +1057,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1040
1057
  }
1041
1058
  const diff = Date.now() - epochMs;
1042
1059
  if (diff < 60_000) {
1043
- return 'just now';
1060
+ return 'now';
1044
1061
  }
1045
1062
  if (diff < 3_600_000) {
1046
1063
  return `${Math.floor(diff / 60_000)}m ago`;
@@ -1048,10 +1065,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
1048
1065
  if (diff < 86_400_000) {
1049
1066
  return `${Math.floor(diff / 3_600_000)}h ago`;
1050
1067
  }
1051
- if (diff < 30 * 86_400_000) {
1052
- return `${Math.floor(diff / 86_400_000)}d ago`;
1053
- }
1054
- return new Date(epochMs).toLocaleDateString();
1068
+ return `${Math.floor(diff / 86_400_000)}d ago`;
1055
1069
  }
1056
1070
 
1057
1071
  private _setRefreshSpinning(on: boolean): void {
@@ -1180,6 +1194,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
1180
1194
  }
1181
1195
  });
1182
1196
 
1197
+ this._commands.addCommand('claude-code-sessions:switch-branch', {
1198
+ label: args => String(args.label ?? ''),
1199
+ execute: args => {
1200
+ const sessionId = String(args.session_id ?? '');
1201
+ if (sessionId) {
1202
+ void this._switchBranch(sessionId);
1203
+ }
1204
+ }
1205
+ });
1206
+
1207
+ this._commands.addCommand('claude-code-sessions:switch-branch-more', {
1208
+ label: () => `More... (${this._lastBranches.length} total)`,
1209
+ execute: () => {
1210
+ void this._showBranchPopup(this._lastBranches);
1211
+ }
1212
+ });
1213
+
1183
1214
  this._commands.addCommand('claude-code-sessions:remove', {
1184
1215
  label: 'Remove from Claude',
1185
1216
  icon: removeIcon,
@@ -1212,8 +1243,31 @@ export class ClaudeCodeSessionsWidget extends Widget {
1212
1243
  command: 'claude-code-sessions:new-session-dangerous'
1213
1244
  });
1214
1245
 
1246
+ // Submenu listing the project's other conversations ("branches") -
1247
+ // items are rebuilt on every context-menu open from a fresh
1248
+ // sessions/branches fetch.
1249
+ this._branchSubmenu = new Menu({ commands: this._commands });
1250
+ this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
1251
+ this._branchSubmenu.title.label = 'Switch Conversation Branch';
1252
+
1215
1253
  this._contextMenu = new Menu({ commands: this._commands });
1216
1254
  this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
1255
+ this._rebuildContextMenu(false);
1256
+
1257
+ this._contextMenu.aboutToClose.connect(() => {
1258
+ // Only clear the visual highlight - DO NOT null _activeSession.
1259
+ // Lumino fires aboutToClose BEFORE the activated item's command runs,
1260
+ // so the command callback still needs to read _activeSession. The
1261
+ // field is overwritten on the next contextmenu open.
1262
+ this._setActiveRow(null);
1263
+ });
1264
+ }
1265
+
1266
+ /** Rebuild the context menu's items. Lumino submenu-type items have no
1267
+ * ``isVisible`` hook, so the menu is rebuilt per open and the branch
1268
+ * submenu inserted only when the row actually has branches. */
1269
+ private _rebuildContextMenu(withBranches: boolean): void {
1270
+ this._contextMenu.clearItems();
1217
1271
  this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
1218
1272
  this._contextMenu.addItem({
1219
1273
  command: 'claude-code-sessions:resume-dangerous'
@@ -1229,18 +1283,171 @@ export class ClaudeCodeSessionsWidget extends Widget {
1229
1283
  });
1230
1284
  this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
1231
1285
  this._contextMenu.addItem({ type: 'separator' });
1286
+ if (withBranches) {
1287
+ this._contextMenu.addItem({
1288
+ type: 'submenu',
1289
+ submenu: this._branchSubmenu
1290
+ });
1291
+ }
1232
1292
  this._contextMenu.addItem({
1233
1293
  command: 'claude-code-sessions:cleanup-parallel'
1234
1294
  });
1235
1295
  this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
1296
+ }
1236
1297
 
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);
1298
+ /** Open the row context menu, populating the branch submenu first when
1299
+ * the project has more than one conversation. On a fetch failure the
1300
+ * menu opens without the submenu. */
1301
+ private async _openContextMenu(
1302
+ session: ISession,
1303
+ x: number,
1304
+ y: number
1305
+ ): Promise<void> {
1306
+ let hasBranches = false;
1307
+ if (session.extra_sessions > 0) {
1308
+ try {
1309
+ const data = await requestAPI<IBranchesResponse>(
1310
+ `sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
1311
+ this._serverSettings,
1312
+ { cache: 'no-store' }
1313
+ );
1314
+ this._lastBranches = data.branches;
1315
+ this._branchSubmenu.clearItems();
1316
+ // The submenu shows only the 5 most recent; the full list lives
1317
+ // behind "More..." in a searchable popup.
1318
+ for (const b of data.branches.slice(0, 5)) {
1319
+ this._branchSubmenu.addItem({
1320
+ command: 'claude-code-sessions:switch-branch',
1321
+ args: {
1322
+ session_id: b.session_id,
1323
+ label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
1324
+ }
1325
+ });
1326
+ }
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
+ }
1333
+ hasBranches = data.branches.length > 0;
1334
+ } catch {
1335
+ hasBranches = false;
1336
+ }
1337
+ }
1338
+ this._rebuildContextMenu(hasBranches);
1339
+ this._contextMenu.open(x, y);
1340
+ }
1341
+
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 {
1345
+ const body = document.createElement('div');
1346
+ body.className = 'jp-ClaudeSessionsPanel-branchPopup';
1347
+
1348
+ const search = document.createElement('input');
1349
+ search.type = 'search';
1350
+ search.placeholder = 'Filter branches...';
1351
+ search.className = 'jp-ClaudeSessionsPanel-branchSearch';
1352
+ body.appendChild(search);
1353
+
1354
+ const list = document.createElement('div');
1355
+ list.className = 'jp-ClaudeSessionsPanel-branchList';
1356
+ body.appendChild(list);
1357
+
1358
+ const bodyWidget = new Widget({ node: body });
1359
+ const dialog = new Dialog({
1360
+ title: 'Switch Conversation Branch',
1361
+ body: bodyWidget,
1362
+ buttons: [Dialog.cancelButton()]
1243
1363
  });
1364
+
1365
+ const render = () => {
1366
+ const needle = search.value.trim().toLowerCase();
1367
+ list.replaceChildren();
1368
+ const matches = branches.filter(
1369
+ b =>
1370
+ !needle ||
1371
+ b.label.toLowerCase().includes(needle) ||
1372
+ b.session_id.toLowerCase().includes(needle)
1373
+ );
1374
+ if (matches.length === 0) {
1375
+ const empty = document.createElement('div');
1376
+ empty.className = 'jp-ClaudeSessionsPanel-emptySection';
1377
+ empty.textContent = 'No matching branches.';
1378
+ list.appendChild(empty);
1379
+ return;
1380
+ }
1381
+ for (const b of matches) {
1382
+ const row = document.createElement('div');
1383
+ row.className = 'jp-ClaudeSessionsPanel-branchRow';
1384
+ row.title = `Session id: ${b.session_id}`;
1385
+
1386
+ const label = document.createElement('span');
1387
+ label.className = 'jp-ClaudeSessionsPanel-branchLabel';
1388
+ label.textContent = b.label;
1389
+ row.appendChild(label);
1390
+
1391
+ const time = document.createElement('span');
1392
+ time.className = 'jp-ClaudeSessionsPanel-branchTime';
1393
+ time.textContent = this._formatRelativeTime(b.file_mtime);
1394
+ row.appendChild(time);
1395
+
1396
+ row.addEventListener('click', () => {
1397
+ dialog.dispose();
1398
+ void this._switchBranch(b.session_id);
1399
+ });
1400
+ list.appendChild(row);
1401
+ }
1402
+ };
1403
+ search.addEventListener('input', render);
1404
+ render();
1405
+
1406
+ void dialog.launch();
1407
+ search.focus();
1408
+ }
1409
+
1410
+ /** Switch the active row's project to another conversation branch.
1411
+ * The backend touches the branch JSONL's mtime; a refresh then shows
1412
+ * the selected conversation as the row's current one. */
1413
+ private async _switchBranch(sessionId: string): Promise<void> {
1414
+ const session = this._activeSession;
1415
+ if (!session) {
1416
+ return;
1417
+ }
1418
+ try {
1419
+ const result = await requestAPI<ISwitchResponse>(
1420
+ 'sessions/switch',
1421
+ this._serverSettings,
1422
+ {
1423
+ method: 'POST',
1424
+ body: JSON.stringify({
1425
+ encoded_path: session.encoded_path,
1426
+ session_id: sessionId
1427
+ })
1428
+ }
1429
+ );
1430
+ if (result.current !== result.requested) {
1431
+ // The branch's recorded cwd is inconsistent with the project dir,
1432
+ // so the recency resolution cannot make it current.
1433
+ Notification.warning(
1434
+ 'Branch cannot become current - its recorded folder does not match the project.',
1435
+ { autoClose: 4000 }
1436
+ );
1437
+ }
1438
+ } catch (err) {
1439
+ const notFound =
1440
+ err instanceof ServerConnection.ResponseError &&
1441
+ err.response.status === 404;
1442
+ Notification.error(
1443
+ notFound
1444
+ ? 'Branch no longer exists - the session list has been refreshed.'
1445
+ : `Branch switch failed: ${err}`,
1446
+ { autoClose: 4000 }
1447
+ );
1448
+ } finally {
1449
+ await this._fetch();
1450
+ }
1244
1451
  }
1245
1452
 
1246
1453
  // --------------------------------------------------------------- polling
@@ -1275,6 +1482,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
1275
1482
  private _expanded: Record<SectionKey, boolean> = loadExpanded();
1276
1483
  private _commands!: CommandRegistry;
1277
1484
  private _contextMenu!: Menu;
1485
+ private _branchSubmenu!: Menu;
1486
+ private _lastBranches: IBranch[] = [];
1278
1487
  private _newSessionMenu!: Menu;
1279
1488
  private _activeSession: ISession | null = null;
1280
1489
  private _activeRowEl: HTMLElement | null = null;
package/style/base.css CHANGED
@@ -301,3 +301,56 @@
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
+ }
349
+
350
+ /* Last-activity time on session rows - dim, right of the name. */
351
+ .jp-ClaudeSessionsPanel-rowTime {
352
+ flex: 0 0 auto;
353
+ margin-left: 6px;
354
+ color: var(--jp-ui-font-color2);
355
+ font-size: var(--jp-ui-font-size0);
356
+ }