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 +1 -0
- package/lib/types.d.ts +18 -0
- package/lib/widget.d.ts +17 -0
- package/lib/widget.js +177 -8
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +67 -0
- package/src/types.ts +22 -0
- package/src/widget.ts +214 -9
- package/style/base.css +45 -0
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
|
-
|
|
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.
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
+
}
|