jupyterlab_claude_code_extension 1.2.5 → 1.2.9
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 +15 -2
- package/lib/icons.d.ts +1 -0
- package/lib/icons.js +9 -0
- package/lib/types.d.ts +7 -0
- package/lib/widget.d.ts +24 -2
- package/lib/widget.js +307 -34
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +166 -5
- package/src/icons.ts +11 -0
- package/src/types.ts +9 -0
- package/src/widget.ts +345 -33
- package/style/base.css +127 -5
package/README.md
CHANGED
|
@@ -8,10 +8,21 @@
|
|
|
8
8
|
[](https://kolomolo.com)
|
|
9
9
|
[](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
A full Claude Code launcher and manager for JupyterLab. Start, resume, fork, switch, and clean up Claude Code CLI sessions from a side panel - one click lands you in the right terminal with Claude already running, no duplicate tabs, no UUID hunting, with a live indicator showing which sessions are active right now.
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
15
|
+
## Why this extension
|
|
16
|
+
|
|
17
|
+
One principle: **Anthropic knows best how to build the agent harness; we know best how to make it work in JupyterLab.**
|
|
18
|
+
|
|
19
|
+
Chat-panel extensions re-implement the agent loop and trail the real tool. This one runs the genuine, unmodified Claude Code CLI in JupyterLab terminals - skills, subagents, MCP, hooks, plan mode, every release the day it lands. The extension owns the JupyterLab side:
|
|
20
|
+
|
|
21
|
+
- **Launching** - new, resumed, or forked sessions, with or without permission prompts, no wrapper shell, correctly sized before Claude draws its first frame
|
|
22
|
+
- **Finding** - every Claude project in one panel: favourites, search, live activity
|
|
23
|
+
- **Reusing** - clicking a session focuses its existing terminal, never a duplicate
|
|
24
|
+
- **Managing** - parallel conversations: switch, fork with a name, delete - no `--resume` pickers, no raw UUIDs
|
|
25
|
+
|
|
15
26
|
## Features
|
|
16
27
|
|
|
17
28
|
- **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
|
|
@@ -20,7 +31,9 @@ Browse, resume, and manage your Claude Code sessions from a JupyterLab side pane
|
|
|
20
31
|
- **Favorites** - star projects you keep coming back to via the right-click menu
|
|
21
32
|
- **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
33
|
- **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
|
-
- **
|
|
34
|
+
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup over the full list where conversations can also be deleted - select one, many, or all via checkboxes, then confirm with a two-step Delete button (removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
|
|
35
|
+
- **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
|
|
36
|
+
- **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
|
|
24
37
|
- **Search** - fuzzy filter toggled by the funnel button next to refresh
|
|
25
38
|
- **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
|
|
26
39
|
- **Hover tooltip** with project path, last activity, message count, branch, and session id
|
package/lib/icons.d.ts
CHANGED
package/lib/icons.js
CHANGED
|
@@ -58,6 +58,15 @@ export const addIcon = new LabIcon({
|
|
|
58
58
|
name: 'jupyterlab_claude_code_extension:add',
|
|
59
59
|
svgstr: addSvgStr
|
|
60
60
|
});
|
|
61
|
+
// Git-branch glyph (Octicons git-branch-16, MIT) - marks rows that carry
|
|
62
|
+
// parallel conversations and the Branch Session menu entries.
|
|
63
|
+
const branchSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
64
|
+
<path class="jp-icon3" fill="#616161" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
|
|
65
|
+
</svg>`;
|
|
66
|
+
export const branchIcon = new LabIcon({
|
|
67
|
+
name: 'jupyterlab_claude_code_extension:branch',
|
|
68
|
+
svgstr: branchSvgStr
|
|
69
|
+
});
|
|
61
70
|
// Funnel copied verbatim from @jupyterlab/ui-components'
|
|
62
71
|
// `search/filter.svg` - the same image the file browser's filter
|
|
63
72
|
// toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
|
package/lib/types.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface ISwitchResponse {
|
|
|
60
60
|
requested: string;
|
|
61
61
|
current: string | null;
|
|
62
62
|
}
|
|
63
|
+
export interface IDeleteBranchesRequest {
|
|
64
|
+
encoded_path: string;
|
|
65
|
+
session_ids: string[];
|
|
66
|
+
}
|
|
67
|
+
export interface IDeleteBranchesResponse {
|
|
68
|
+
removed_count: number;
|
|
69
|
+
}
|
|
63
70
|
export interface ILaunchTerminalRequest {
|
|
64
71
|
project_path: string;
|
|
65
72
|
/** Omit to start a brand-new claude session instead of resuming one. */
|
package/lib/widget.d.ts
CHANGED
|
@@ -108,9 +108,30 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
108
108
|
* the project has more than one conversation. On a fetch failure the
|
|
109
109
|
* menu opens without the submenu. */
|
|
110
110
|
private _openContextMenu;
|
|
111
|
-
/** Popup with the project's full branch list - browse
|
|
112
|
-
*
|
|
111
|
+
/** Popup with the project's full branch list - browse, filter, switch
|
|
112
|
+
* and manage. Clicking an entry switches while nothing is selected;
|
|
113
|
+
* checkbox selection (one, many, or select-all) arms a two-step Delete
|
|
114
|
+
* button that removes the chosen sessions. The current conversation is
|
|
115
|
+
* shown first, badged and untouchable. */
|
|
113
116
|
private _showBranchPopup;
|
|
117
|
+
/** Delete the given branch sessions of the active row's project.
|
|
118
|
+
* Returns the removed count, or null on failure (after notifying).
|
|
119
|
+
* Always resyncs the panel so the row's conversation count drops. */
|
|
120
|
+
private _deleteBranches;
|
|
121
|
+
/** Fork the active row's current conversation into a new named branch.
|
|
122
|
+
*
|
|
123
|
+
* Asks for a name, then launches a terminal running
|
|
124
|
+
* ``claude --resume <current> --fork-session --session-id <new uuid>`` -
|
|
125
|
+
* the uuid is generated here so the forked JSONL is known up front. Once
|
|
126
|
+
* claude materialises the file (polled via sessions/set-title) the chosen
|
|
127
|
+
* name is stamped as a custom-title record. The fork is the newest JSONL,
|
|
128
|
+
* so the recency resolution makes it the row's current conversation
|
|
129
|
+
* without an explicit switch.
|
|
130
|
+
*/
|
|
131
|
+
private _branchSession;
|
|
132
|
+
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
133
|
+
* does not), then refresh so the row shows the named fork as current. */
|
|
134
|
+
private _stampForkTitle;
|
|
114
135
|
/** Switch the active row's project to another conversation branch.
|
|
115
136
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
116
137
|
* the selected conversation as the row's current one. */
|
|
@@ -129,6 +150,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
129
150
|
private _contextMenu;
|
|
130
151
|
private _branchSubmenu;
|
|
131
152
|
private _lastBranches;
|
|
153
|
+
private _lastBranchesCurrent;
|
|
132
154
|
private _newSessionMenu;
|
|
133
155
|
private _activeSession;
|
|
134
156
|
private _activeRowEl;
|
package/lib/widget.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Clipboard, Dialog, Notification, showDialog } from '@jupyterlab/apputils';
|
|
1
|
+
import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
|
|
2
2
|
import { ServerConnection } from '@jupyterlab/services';
|
|
3
3
|
import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
4
4
|
import { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import { UUID } from '@lumino/coreutils';
|
|
5
6
|
import { Menu, Widget } from '@lumino/widgets';
|
|
6
7
|
import { requestAPI } from './request';
|
|
7
|
-
import { addIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
|
|
8
|
+
import { addIcon, branchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
|
|
8
9
|
const POLL_INTERVAL_MS = 30000;
|
|
9
10
|
const DEFAULT_RECENT_LIMIT = 10;
|
|
10
11
|
const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
|
|
@@ -65,6 +66,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
65
66
|
this._sessions = null;
|
|
66
67
|
this._expanded = loadExpanded();
|
|
67
68
|
this._lastBranches = [];
|
|
69
|
+
this._lastBranchesCurrent = '';
|
|
68
70
|
this._activeSession = null;
|
|
69
71
|
this._activeRowEl = null;
|
|
70
72
|
this._pollHandle = null;
|
|
@@ -783,6 +785,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
783
785
|
const row = document.createElement('div');
|
|
784
786
|
row.className = 'jp-ClaudeSessionsPanel-row';
|
|
785
787
|
row.title = this._buildRowTooltip(session);
|
|
788
|
+
// Age emphasis: active within the last minute reads bright, idle for
|
|
789
|
+
// over a week dims; the state decays/promotes on the next refresh.
|
|
790
|
+
if (session.file_mtime) {
|
|
791
|
+
const age = Date.now() - session.file_mtime;
|
|
792
|
+
if (age < 60000) {
|
|
793
|
+
row.classList.add('jp-mod-recentlyActive');
|
|
794
|
+
}
|
|
795
|
+
else if (age > 7 * 86400000) {
|
|
796
|
+
row.classList.add('jp-mod-stale');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
786
799
|
const removing = this._removingPaths.has(session.encoded_path);
|
|
787
800
|
if (removing) {
|
|
788
801
|
row.classList.add('jp-mod-busy');
|
|
@@ -806,20 +819,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
806
819
|
}
|
|
807
820
|
const name = document.createElement('span');
|
|
808
821
|
name.className = 'jp-ClaudeSessionsPanel-name';
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
822
|
+
name.textContent = this._lookupName(session);
|
|
823
|
+
// Branch icon + total conversation count - only when the project has
|
|
824
|
+
// branches. Lives inside the name span so it hugs the label text
|
|
825
|
+
// instead of being flexed to the row's right edge.
|
|
826
|
+
if (session.extra_sessions > 0) {
|
|
827
|
+
const badge = document.createElement('span');
|
|
828
|
+
badge.className = 'jp-ClaudeSessionsPanel-branchBadge';
|
|
829
|
+
const icon = document.createElement('span');
|
|
830
|
+
icon.className = 'jp-ClaudeSessionsPanel-branchBadgeIcon';
|
|
831
|
+
branchIcon.element({ container: icon });
|
|
832
|
+
badge.appendChild(icon);
|
|
833
|
+
badge.appendChild(document.createTextNode(String(session.extra_sessions + 1)));
|
|
834
|
+
name.appendChild(badge);
|
|
820
835
|
}
|
|
836
|
+
row.appendChild(name);
|
|
821
837
|
// No star in the Favorites section - every row there is a favorite
|
|
822
838
|
// by definition; stars are an indicator only useful in Recent/All.
|
|
839
|
+
// Star sits before the time so the fixed-width time column stays the
|
|
840
|
+
// rightmost alignment anchor across all rows.
|
|
823
841
|
if (session.favourite && sectionKey !== 'favourites') {
|
|
824
842
|
const star = document.createElement('span');
|
|
825
843
|
star.className = 'jp-ClaudeSessionsPanel-favStar';
|
|
@@ -827,6 +845,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
827
845
|
starFilledIcon.element({ container: star });
|
|
828
846
|
row.appendChild(star);
|
|
829
847
|
}
|
|
848
|
+
if (session.file_mtime) {
|
|
849
|
+
const time = document.createElement('span');
|
|
850
|
+
time.className = 'jp-ClaudeSessionsPanel-rowTime';
|
|
851
|
+
time.textContent = this._formatRelativeTime(session.file_mtime);
|
|
852
|
+
row.appendChild(time);
|
|
853
|
+
}
|
|
830
854
|
row.addEventListener('click', () => {
|
|
831
855
|
if (removing) {
|
|
832
856
|
return;
|
|
@@ -1043,11 +1067,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1043
1067
|
}
|
|
1044
1068
|
});
|
|
1045
1069
|
this._commands.addCommand('claude-code-sessions:switch-branch-more', {
|
|
1046
|
-
label: () => `
|
|
1070
|
+
label: () => `Manage Sessions... (${this._lastBranches.length})`,
|
|
1047
1071
|
execute: () => {
|
|
1048
|
-
void this._showBranchPopup(this._lastBranches);
|
|
1072
|
+
void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
|
|
1049
1073
|
}
|
|
1050
1074
|
});
|
|
1075
|
+
this._commands.addCommand('claude-code-sessions:branch-session', {
|
|
1076
|
+
label: 'Branch Session...',
|
|
1077
|
+
icon: branchIcon,
|
|
1078
|
+
execute: () => void this._branchSession(false)
|
|
1079
|
+
});
|
|
1080
|
+
this._commands.addCommand('claude-code-sessions:branch-session-dangerous', {
|
|
1081
|
+
label: 'Branch Session (Skip Permissions)...',
|
|
1082
|
+
icon: shieldIcon,
|
|
1083
|
+
execute: () => void this._branchSession(true)
|
|
1084
|
+
});
|
|
1051
1085
|
this._commands.addCommand('claude-code-sessions:remove', {
|
|
1052
1086
|
label: 'Remove from Claude',
|
|
1053
1087
|
icon: removeIcon,
|
|
@@ -1081,7 +1115,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1081
1115
|
// sessions/branches fetch.
|
|
1082
1116
|
this._branchSubmenu = new Menu({ commands: this._commands });
|
|
1083
1117
|
this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1084
|
-
this._branchSubmenu.title.label = 'Switch
|
|
1118
|
+
this._branchSubmenu.title.label = 'Switch and Manage Sessions';
|
|
1085
1119
|
this._contextMenu = new Menu({ commands: this._commands });
|
|
1086
1120
|
this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1087
1121
|
this._rebuildContextMenu(false);
|
|
@@ -1119,6 +1153,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1119
1153
|
submenu: this._branchSubmenu
|
|
1120
1154
|
});
|
|
1121
1155
|
}
|
|
1156
|
+
this._contextMenu.addItem({
|
|
1157
|
+
command: 'claude-code-sessions:branch-session'
|
|
1158
|
+
});
|
|
1159
|
+
this._contextMenu.addItem({
|
|
1160
|
+
command: 'claude-code-sessions:branch-session-dangerous'
|
|
1161
|
+
});
|
|
1122
1162
|
this._contextMenu.addItem({
|
|
1123
1163
|
command: 'claude-code-sessions:cleanup-parallel'
|
|
1124
1164
|
});
|
|
@@ -1133,9 +1173,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1133
1173
|
try {
|
|
1134
1174
|
const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
|
|
1135
1175
|
this._lastBranches = data.branches;
|
|
1176
|
+
this._lastBranchesCurrent = data.current;
|
|
1136
1177
|
this._branchSubmenu.clearItems();
|
|
1137
|
-
|
|
1138
|
-
//
|
|
1178
|
+
this._branchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
|
|
1179
|
+
// The submenu shows only the 5 most recent inline (fewest clicks
|
|
1180
|
+
// for often-used sessions); the full list plus management lives
|
|
1181
|
+
// behind the always-present "Manage Sessions..." popup.
|
|
1139
1182
|
for (const b of data.branches.slice(0, 5)) {
|
|
1140
1183
|
this._branchSubmenu.addItem({
|
|
1141
1184
|
command: 'claude-code-sessions:switch-branch',
|
|
@@ -1145,12 +1188,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1145
1188
|
}
|
|
1146
1189
|
});
|
|
1147
1190
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1191
|
+
this._branchSubmenu.addItem({ type: 'separator' });
|
|
1192
|
+
this._branchSubmenu.addItem({
|
|
1193
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1194
|
+
});
|
|
1154
1195
|
hasBranches = data.branches.length > 0;
|
|
1155
1196
|
}
|
|
1156
1197
|
catch (_a) {
|
|
@@ -1160,35 +1201,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1160
1201
|
this._rebuildContextMenu(hasBranches);
|
|
1161
1202
|
this._contextMenu.open(x, y);
|
|
1162
1203
|
}
|
|
1163
|
-
/** Popup with the project's full branch list - browse
|
|
1164
|
-
*
|
|
1165
|
-
|
|
1204
|
+
/** Popup with the project's full branch list - browse, filter, switch
|
|
1205
|
+
* and manage. Clicking an entry switches while nothing is selected;
|
|
1206
|
+
* checkbox selection (one, many, or select-all) arms a two-step Delete
|
|
1207
|
+
* button that removes the chosen sessions. The current conversation is
|
|
1208
|
+
* shown first, badged and untouchable. */
|
|
1209
|
+
_showBranchPopup(branches, current) {
|
|
1210
|
+
// Local working copy so deletions can refresh the list in place.
|
|
1211
|
+
let items = [...branches];
|
|
1212
|
+
const selected = new Set();
|
|
1213
|
+
let confirmArmed = false;
|
|
1166
1214
|
const body = document.createElement('div');
|
|
1167
1215
|
body.className = 'jp-ClaudeSessionsPanel-branchPopup';
|
|
1168
1216
|
const search = document.createElement('input');
|
|
1169
1217
|
search.type = 'search';
|
|
1170
|
-
search.placeholder = 'Filter
|
|
1218
|
+
search.placeholder = 'Filter sessions...';
|
|
1171
1219
|
search.className = 'jp-ClaudeSessionsPanel-branchSearch';
|
|
1172
1220
|
body.appendChild(search);
|
|
1221
|
+
const selectAllBar = document.createElement('label');
|
|
1222
|
+
selectAllBar.className = 'jp-ClaudeSessionsPanel-branchSelectAll';
|
|
1223
|
+
const selectAll = document.createElement('input');
|
|
1224
|
+
selectAll.type = 'checkbox';
|
|
1225
|
+
selectAllBar.appendChild(selectAll);
|
|
1226
|
+
selectAllBar.appendChild(document.createTextNode('Select all'));
|
|
1227
|
+
body.appendChild(selectAllBar);
|
|
1173
1228
|
const list = document.createElement('div');
|
|
1174
1229
|
list.className = 'jp-ClaudeSessionsPanel-branchList';
|
|
1175
1230
|
body.appendChild(list);
|
|
1231
|
+
const footer = document.createElement('div');
|
|
1232
|
+
footer.className = 'jp-ClaudeSessionsPanel-branchFooter';
|
|
1233
|
+
const deleteBtn = document.createElement('button');
|
|
1234
|
+
deleteBtn.className = 'jp-ClaudeSessionsPanel-branchDelete';
|
|
1235
|
+
footer.appendChild(deleteBtn);
|
|
1236
|
+
body.appendChild(footer);
|
|
1176
1237
|
const bodyWidget = new Widget({ node: body });
|
|
1177
1238
|
const dialog = new Dialog({
|
|
1178
|
-
title: 'Switch
|
|
1239
|
+
title: 'Switch and Manage Sessions',
|
|
1179
1240
|
body: bodyWidget,
|
|
1180
1241
|
buttons: [Dialog.cancelButton()]
|
|
1181
1242
|
});
|
|
1182
|
-
const
|
|
1243
|
+
const visibleMatches = () => {
|
|
1183
1244
|
const needle = search.value.trim().toLowerCase();
|
|
1184
|
-
|
|
1185
|
-
const matches = branches.filter(b => !needle ||
|
|
1245
|
+
return items.filter(b => !needle ||
|
|
1186
1246
|
b.label.toLowerCase().includes(needle) ||
|
|
1187
1247
|
b.session_id.toLowerCase().includes(needle));
|
|
1248
|
+
};
|
|
1249
|
+
// Any selection change disarms a pending confirm.
|
|
1250
|
+
const updateControls = () => {
|
|
1251
|
+
confirmArmed = false;
|
|
1252
|
+
deleteBtn.disabled = selected.size === 0;
|
|
1253
|
+
deleteBtn.textContent = `Delete (${selected.size})`;
|
|
1254
|
+
deleteBtn.classList.remove('jp-mod-confirm');
|
|
1255
|
+
const visible = visibleMatches();
|
|
1256
|
+
const visibleSelected = visible.filter(b => selected.has(b.session_id)).length;
|
|
1257
|
+
selectAll.checked =
|
|
1258
|
+
visible.length > 0 && visibleSelected === visible.length;
|
|
1259
|
+
selectAll.indeterminate =
|
|
1260
|
+
visibleSelected > 0 && visibleSelected < visible.length;
|
|
1261
|
+
};
|
|
1262
|
+
const render = () => {
|
|
1263
|
+
list.replaceChildren();
|
|
1264
|
+
// The current conversation leads the list - badged, unselectable,
|
|
1265
|
+
// undeletable; only the extras below it are manageable.
|
|
1266
|
+
const currentRow = document.createElement('div');
|
|
1267
|
+
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1268
|
+
currentRow.title = `Session id: ${current}`;
|
|
1269
|
+
const currentLabel = document.createElement('span');
|
|
1270
|
+
currentLabel.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1271
|
+
const currentName = this._activeSession
|
|
1272
|
+
? this._lookupName(this._activeSession)
|
|
1273
|
+
: current.slice(0, 8);
|
|
1274
|
+
currentLabel.textContent = `${currentName} (${current.slice(0, 8)})`;
|
|
1275
|
+
currentRow.appendChild(currentLabel);
|
|
1276
|
+
const badge = document.createElement('span');
|
|
1277
|
+
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1278
|
+
badge.textContent = 'current';
|
|
1279
|
+
currentRow.appendChild(badge);
|
|
1280
|
+
list.appendChild(currentRow);
|
|
1281
|
+
const matches = visibleMatches();
|
|
1188
1282
|
if (matches.length === 0) {
|
|
1189
1283
|
const empty = document.createElement('div');
|
|
1190
1284
|
empty.className = 'jp-ClaudeSessionsPanel-emptySection';
|
|
1191
|
-
empty.textContent =
|
|
1285
|
+
empty.textContent = items.length
|
|
1286
|
+
? 'No matching sessions.'
|
|
1287
|
+
: 'No other conversations.';
|
|
1192
1288
|
list.appendChild(empty);
|
|
1193
1289
|
return;
|
|
1194
1290
|
}
|
|
@@ -1196,6 +1292,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1196
1292
|
const row = document.createElement('div');
|
|
1197
1293
|
row.className = 'jp-ClaudeSessionsPanel-branchRow';
|
|
1198
1294
|
row.title = `Session id: ${b.session_id}`;
|
|
1295
|
+
const check = document.createElement('input');
|
|
1296
|
+
check.type = 'checkbox';
|
|
1297
|
+
check.checked = selected.has(b.session_id);
|
|
1298
|
+
// The checkbox is its own click zone - ticking must not switch.
|
|
1299
|
+
check.addEventListener('click', e => {
|
|
1300
|
+
e.stopPropagation();
|
|
1301
|
+
if (check.checked) {
|
|
1302
|
+
selected.add(b.session_id);
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
selected.delete(b.session_id);
|
|
1306
|
+
}
|
|
1307
|
+
updateControls();
|
|
1308
|
+
});
|
|
1309
|
+
row.appendChild(check);
|
|
1199
1310
|
const label = document.createElement('span');
|
|
1200
1311
|
label.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1201
1312
|
label.textContent = this._branchDisplayName(b);
|
|
@@ -1205,17 +1316,179 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1205
1316
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1206
1317
|
row.appendChild(time);
|
|
1207
1318
|
row.addEventListener('click', () => {
|
|
1319
|
+
// Selection mode: while anything is ticked, row clicks toggle
|
|
1320
|
+
// selection - no accidental switch mid-selection.
|
|
1321
|
+
if (selected.size > 0) {
|
|
1322
|
+
if (selected.has(b.session_id)) {
|
|
1323
|
+
selected.delete(b.session_id);
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
selected.add(b.session_id);
|
|
1327
|
+
}
|
|
1328
|
+
check.checked = selected.has(b.session_id);
|
|
1329
|
+
updateControls();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1208
1332
|
dialog.dispose();
|
|
1209
1333
|
void this._switchBranch(b.session_id);
|
|
1210
1334
|
});
|
|
1211
1335
|
list.appendChild(row);
|
|
1212
1336
|
}
|
|
1213
1337
|
};
|
|
1214
|
-
|
|
1338
|
+
selectAll.addEventListener('change', () => {
|
|
1339
|
+
// Select-all acts on the visible (filtered) rows only.
|
|
1340
|
+
const visible = visibleMatches();
|
|
1341
|
+
if (selectAll.checked) {
|
|
1342
|
+
visible.forEach(b => selected.add(b.session_id));
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
visible.forEach(b => selected.delete(b.session_id));
|
|
1346
|
+
}
|
|
1347
|
+
render();
|
|
1348
|
+
updateControls();
|
|
1349
|
+
});
|
|
1350
|
+
deleteBtn.addEventListener('click', () => {
|
|
1351
|
+
if (selected.size === 0) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (!confirmArmed) {
|
|
1355
|
+
// Two-step delete: first click arms, second click executes.
|
|
1356
|
+
confirmArmed = true;
|
|
1357
|
+
deleteBtn.textContent = `Confirm delete (${selected.size})`;
|
|
1358
|
+
deleteBtn.classList.add('jp-mod-confirm');
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
void this._deleteBranches([...selected]).then(deleted => {
|
|
1362
|
+
if (deleted === null) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
items = items.filter(b => !selected.has(b.session_id));
|
|
1366
|
+
selected.clear();
|
|
1367
|
+
this._lastBranches = items;
|
|
1368
|
+
render();
|
|
1369
|
+
updateControls();
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
search.addEventListener('input', () => {
|
|
1373
|
+
render();
|
|
1374
|
+
updateControls();
|
|
1375
|
+
});
|
|
1215
1376
|
render();
|
|
1377
|
+
updateControls();
|
|
1216
1378
|
void dialog.launch();
|
|
1217
1379
|
search.focus();
|
|
1218
1380
|
}
|
|
1381
|
+
/** Delete the given branch sessions of the active row's project.
|
|
1382
|
+
* Returns the removed count, or null on failure (after notifying).
|
|
1383
|
+
* Always resyncs the panel so the row's conversation count drops. */
|
|
1384
|
+
async _deleteBranches(sessionIds) {
|
|
1385
|
+
const session = this._activeSession;
|
|
1386
|
+
if (!session) {
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const result = await requestAPI('sessions/delete-branches', this._serverSettings, {
|
|
1391
|
+
method: 'POST',
|
|
1392
|
+
body: JSON.stringify({
|
|
1393
|
+
encoded_path: session.encoded_path,
|
|
1394
|
+
session_ids: sessionIds
|
|
1395
|
+
})
|
|
1396
|
+
});
|
|
1397
|
+
return result.removed_count;
|
|
1398
|
+
}
|
|
1399
|
+
catch (err) {
|
|
1400
|
+
Notification.error(`Delete failed: ${String(err)}`, {
|
|
1401
|
+
autoClose: 4000
|
|
1402
|
+
});
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
finally {
|
|
1406
|
+
await this._fetch();
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/** Fork the active row's current conversation into a new named branch.
|
|
1410
|
+
*
|
|
1411
|
+
* Asks for a name, then launches a terminal running
|
|
1412
|
+
* ``claude --resume <current> --fork-session --session-id <new uuid>`` -
|
|
1413
|
+
* the uuid is generated here so the forked JSONL is known up front. Once
|
|
1414
|
+
* claude materialises the file (polled via sessions/set-title) the chosen
|
|
1415
|
+
* name is stamped as a custom-title record. The fork is the newest JSONL,
|
|
1416
|
+
* so the recency resolution makes it the row's current conversation
|
|
1417
|
+
* without an explicit switch.
|
|
1418
|
+
*/
|
|
1419
|
+
async _branchSession(forceDangerous) {
|
|
1420
|
+
const session = this._activeSession;
|
|
1421
|
+
if (!session) {
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const named = await InputDialog.getText({
|
|
1425
|
+
title: 'Branch Session',
|
|
1426
|
+
label: 'Name for the new session',
|
|
1427
|
+
placeholder: this._lookupName(session)
|
|
1428
|
+
});
|
|
1429
|
+
if (!named.button.accept || !named.value || !named.value.trim()) {
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
const title = named.value.trim();
|
|
1433
|
+
const forkId = UUID.uuid4();
|
|
1434
|
+
const spinner = this._showLaunchSpinner();
|
|
1435
|
+
try {
|
|
1436
|
+
const launched = await requestAPI('launch-terminal', this._serverSettings, {
|
|
1437
|
+
method: 'POST',
|
|
1438
|
+
body: JSON.stringify({
|
|
1439
|
+
project_path: session.project_path,
|
|
1440
|
+
session_id: session.session_id,
|
|
1441
|
+
fork_session_id: forkId,
|
|
1442
|
+
dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
|
|
1443
|
+
})
|
|
1444
|
+
});
|
|
1445
|
+
const widget = await this._app.commands.execute('terminal:open', {
|
|
1446
|
+
name: launched.terminal_name
|
|
1447
|
+
});
|
|
1448
|
+
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
1449
|
+
this._terminalsByPath.set(session.project_path, widget);
|
|
1450
|
+
this._wireTerminalDisposal(session.project_path, widget);
|
|
1451
|
+
this._focusTerminal(widget);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
this._showError(err);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
finally {
|
|
1459
|
+
spinner.dispose();
|
|
1460
|
+
}
|
|
1461
|
+
// Stamp the name in the background once the forked JSONL appears -
|
|
1462
|
+
// claude writes it on its first record, typically within seconds.
|
|
1463
|
+
void this._stampForkTitle(session.encoded_path, forkId, title);
|
|
1464
|
+
}
|
|
1465
|
+
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
1466
|
+
* does not), then refresh so the row shows the named fork as current. */
|
|
1467
|
+
async _stampForkTitle(encodedPath, sessionId, title) {
|
|
1468
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1469
|
+
try {
|
|
1470
|
+
await requestAPI('sessions/set-title', this._serverSettings, {
|
|
1471
|
+
method: 'POST',
|
|
1472
|
+
body: JSON.stringify({
|
|
1473
|
+
encoded_path: encodedPath,
|
|
1474
|
+
session_id: sessionId,
|
|
1475
|
+
title
|
|
1476
|
+
})
|
|
1477
|
+
});
|
|
1478
|
+
await this._fetch();
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
const notYet = err instanceof ServerConnection.ResponseError &&
|
|
1483
|
+
err.response.status === 404;
|
|
1484
|
+
if (!notYet) {
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
Notification.warning(`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`, { autoClose: 6000 });
|
|
1491
|
+
}
|
|
1219
1492
|
/** Switch the active row's project to another conversation branch.
|
|
1220
1493
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
1221
1494
|
* the selected conversation as the row's current one. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_claude_code_extension",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.9",
|
|
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",
|