jupyterlab_claude_code_extension 1.2.21 → 1.2.23
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/widget.d.ts +18 -10
- package/lib/widget.js +72 -44
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +70 -9
- package/src/widget.ts +75 -56
- package/style/base.css +38 -0
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
33
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; a confirmation dialog naming the project and the count appears first, and removed files honour the same trash setting
|
|
34
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 in a dialog that names the project and the count (removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
|
|
35
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
|
+
- **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
|
|
36
37
|
- **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
|
|
37
38
|
- **Search** - fuzzy filter toggled by the funnel button next to refresh
|
|
38
39
|
- **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
|
package/lib/widget.d.ts
CHANGED
|
@@ -37,7 +37,10 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
37
37
|
private _fuzzyMatch;
|
|
38
38
|
private _levenshtein;
|
|
39
39
|
private _matchesFilter;
|
|
40
|
-
|
|
40
|
+
/** Raise or clear the full-panel refresh veil. Only the explicit refresh
|
|
41
|
+
* path calls this; the background poll fetches silently so the panel never
|
|
42
|
+
* flashes a spinner on its own. */
|
|
43
|
+
private _setLoading;
|
|
41
44
|
private _showError;
|
|
42
45
|
private _fetch;
|
|
43
46
|
private _toggleFavourite;
|
|
@@ -108,6 +111,10 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
108
111
|
* the project has more than one conversation. On a fetch failure the
|
|
109
112
|
* menu opens without the submenu. */
|
|
110
113
|
private _openContextMenu;
|
|
114
|
+
/** A compact copy button for a popup row that copies the given session id
|
|
115
|
+
* to the system clipboard. ``stopPropagation`` keeps the click from
|
|
116
|
+
* switching or selecting the row it sits in. */
|
|
117
|
+
private _branchCopyButton;
|
|
111
118
|
/** Popup with the project's full branch list - browse, filter, switch
|
|
112
119
|
* and manage. Clicking an entry switches while nothing is selected;
|
|
113
120
|
* checkbox selection (one, many, or select-all) arms a two-step Delete
|
|
@@ -121,17 +128,17 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
121
128
|
/** Fork the active row's current conversation into a new named branch.
|
|
122
129
|
*
|
|
123
130
|
* 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
|
|
126
|
-
* claude
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
131
|
+
* ``claude --resume <current> --fork-session --session-id <new uuid> -n <name>`` -
|
|
132
|
+
* the uuid is generated here so the forked JSONL is known up front, and
|
|
133
|
+
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
134
|
+
* custom-title record on its first turn and re-stamps it every turn, so
|
|
135
|
+
* it sticks even though the fork inherits the parent's title. The fork is
|
|
136
|
+
* the newest JSONL, so recency resolution makes it the row's current
|
|
137
|
+
* conversation without an explicit switch. claude writes the fork's JSONL
|
|
138
|
+
* lazily (on its first turn), so the branch appears on the next poll once
|
|
139
|
+
* it materialises - the panel cannot list a file that does not exist yet.
|
|
130
140
|
*/
|
|
131
141
|
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;
|
|
135
142
|
/** Switch the active row's project to another conversation branch.
|
|
136
143
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
137
144
|
* the selected conversation as the row's current one. */
|
|
@@ -141,6 +148,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
141
148
|
private readonly _app;
|
|
142
149
|
private readonly _serverSettings;
|
|
143
150
|
private _bodyEl;
|
|
151
|
+
private _loadingEl;
|
|
144
152
|
private _refreshBtn;
|
|
145
153
|
private _filterBtn;
|
|
146
154
|
private _searchEl;
|
package/lib/widget.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
|
|
2
2
|
import { ServerConnection } from '@jupyterlab/services';
|
|
3
|
-
import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
3
|
+
import { copyIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
4
4
|
import { CommandRegistry } from '@lumino/commands';
|
|
5
5
|
import { UUID } from '@lumino/coreutils';
|
|
6
6
|
import { Menu, Widget } from '@lumino/widgets';
|
|
@@ -60,6 +60,7 @@ catch (_err) {
|
|
|
60
60
|
export class ClaudeCodeSessionsWidget extends Widget {
|
|
61
61
|
constructor(app, rootDir, terminalTracker = null, fileBrowser = null) {
|
|
62
62
|
super();
|
|
63
|
+
this._loadingEl = null;
|
|
63
64
|
this._refreshBtn = null;
|
|
64
65
|
this._filterBtn = null;
|
|
65
66
|
this._searchEl = null;
|
|
@@ -91,16 +92,19 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
91
92
|
this._setupContextMenu();
|
|
92
93
|
}
|
|
93
94
|
refresh() {
|
|
94
|
-
this.
|
|
95
|
+
this._setLoading(true);
|
|
95
96
|
this._setRefreshSpinning(true);
|
|
96
|
-
// `_fetch` is filesystem-fast, so without a floor the
|
|
97
|
-
//
|
|
98
|
-
//
|
|
97
|
+
// `_fetch` is filesystem-fast, so without a floor the spinner would show
|
|
98
|
+
// for a single frame and read as "nothing happened". Hold it for at least
|
|
99
|
+
// ~500 ms so the click visibly registers as a full re-poll.
|
|
99
100
|
const minSpin = new Promise(resolve => window.setTimeout(resolve, 500));
|
|
100
101
|
Promise.all([
|
|
101
102
|
this._fetch().catch(err => this._showError(err)),
|
|
102
103
|
minSpin
|
|
103
|
-
]).finally(() =>
|
|
104
|
+
]).finally(() => {
|
|
105
|
+
this._setRefreshSpinning(false);
|
|
106
|
+
this._setLoading(false);
|
|
107
|
+
});
|
|
104
108
|
}
|
|
105
109
|
/** Choose how rows are labelled: by session name, folder name, or path. */
|
|
106
110
|
setPresentationMode(mode) {
|
|
@@ -184,10 +188,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
184
188
|
this._searchEl = search;
|
|
185
189
|
const body = document.createElement('div');
|
|
186
190
|
body.className = 'jp-ClaudeSessionsPanel-body';
|
|
191
|
+
// Refresh veil + spinner, shown only during an explicit refresh. It lives
|
|
192
|
+
// on the root (not the body) so `_render` - which wipes the body - never
|
|
193
|
+
// removes it.
|
|
194
|
+
const loading = document.createElement('div');
|
|
195
|
+
loading.className = 'jp-ClaudeSessionsPanel-loading';
|
|
196
|
+
loading.hidden = true;
|
|
197
|
+
const loadingSpinner = document.createElement('div');
|
|
198
|
+
loadingSpinner.className =
|
|
199
|
+
'jp-claude-sessions-panel-spinner jp-ClaudeSessionsPanel-loadingSpinner';
|
|
200
|
+
loading.appendChild(loadingSpinner);
|
|
187
201
|
root.appendChild(header);
|
|
188
202
|
root.appendChild(search);
|
|
189
203
|
root.appendChild(body);
|
|
204
|
+
root.appendChild(loading);
|
|
190
205
|
this._bodyEl = body;
|
|
206
|
+
this._loadingEl = loading;
|
|
191
207
|
}
|
|
192
208
|
/** Show / hide the filter input. Hiding also clears the active filter
|
|
193
209
|
* so the user does not end up with an "invisible" filter narrowing
|
|
@@ -291,8 +307,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
291
307
|
this._fuzzyMatch(s.project_path, q) ||
|
|
292
308
|
this._fuzzyMatch(this._lookupName(s), q));
|
|
293
309
|
}
|
|
294
|
-
|
|
295
|
-
|
|
310
|
+
/** Raise or clear the full-panel refresh veil. Only the explicit refresh
|
|
311
|
+
* path calls this; the background poll fetches silently so the panel never
|
|
312
|
+
* flashes a spinner on its own. */
|
|
313
|
+
_setLoading(on) {
|
|
314
|
+
if (this._loadingEl) {
|
|
315
|
+
this._loadingEl.hidden = !on;
|
|
316
|
+
}
|
|
296
317
|
}
|
|
297
318
|
_showError(err) {
|
|
298
319
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1071,6 +1092,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1071
1092
|
Clipboard.copyToSystem(path);
|
|
1072
1093
|
}
|
|
1073
1094
|
});
|
|
1095
|
+
this._commands.addCommand('claude-code-sessions:copy-session-id', {
|
|
1096
|
+
label: 'Copy Session ID',
|
|
1097
|
+
execute: () => {
|
|
1098
|
+
var _a;
|
|
1099
|
+
const id = (_a = this._activeSession) === null || _a === void 0 ? void 0 : _a.session_id;
|
|
1100
|
+
if (id) {
|
|
1101
|
+
Clipboard.copyToSystem(id);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1074
1105
|
this._commands.addCommand('claude-code-sessions:cleanup-parallel', {
|
|
1075
1106
|
label: () => { var _a, _b; return `Clean Up Parallel Sessions (${(_b = (_a = this._activeSession) === null || _a === void 0 ? void 0 : _a.extra_sessions) !== null && _b !== void 0 ? _b : 0})`; },
|
|
1076
1107
|
isVisible: () => { var _a, _b; return ((_b = (_a = this._activeSession) === null || _a === void 0 ? void 0 : _a.extra_sessions) !== null && _b !== void 0 ? _b : 0) > 0; },
|
|
@@ -1181,6 +1212,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1181
1212
|
command: 'claude-code-sessions:toggle-favourite'
|
|
1182
1213
|
});
|
|
1183
1214
|
this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
|
|
1215
|
+
this._contextMenu.addItem({
|
|
1216
|
+
command: 'claude-code-sessions:copy-session-id'
|
|
1217
|
+
});
|
|
1184
1218
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1185
1219
|
if (withBranches) {
|
|
1186
1220
|
this._contextMenu.addItem({
|
|
@@ -1234,6 +1268,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1234
1268
|
this._rebuildContextMenu(hasBranches);
|
|
1235
1269
|
this._contextMenu.open(x, y);
|
|
1236
1270
|
}
|
|
1271
|
+
/** A compact copy button for a popup row that copies the given session id
|
|
1272
|
+
* to the system clipboard. ``stopPropagation`` keeps the click from
|
|
1273
|
+
* switching or selecting the row it sits in. */
|
|
1274
|
+
_branchCopyButton(sessionId) {
|
|
1275
|
+
const btn = document.createElement('button');
|
|
1276
|
+
btn.type = 'button';
|
|
1277
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchCopy';
|
|
1278
|
+
btn.title = 'Copy session id';
|
|
1279
|
+
copyIcon.element({ container: btn });
|
|
1280
|
+
btn.addEventListener('click', e => {
|
|
1281
|
+
e.stopPropagation();
|
|
1282
|
+
Clipboard.copyToSystem(sessionId);
|
|
1283
|
+
});
|
|
1284
|
+
return btn;
|
|
1285
|
+
}
|
|
1237
1286
|
/** Popup with the project's full branch list - browse, filter, switch
|
|
1238
1287
|
* and manage. Clicking an entry switches while nothing is selected;
|
|
1239
1288
|
* checkbox selection (one, many, or select-all) arms a two-step Delete
|
|
@@ -1306,6 +1355,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1306
1355
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1307
1356
|
badge.textContent = 'current';
|
|
1308
1357
|
currentRow.appendChild(badge);
|
|
1358
|
+
currentRow.appendChild(this._branchCopyButton(current));
|
|
1309
1359
|
list.appendChild(currentRow);
|
|
1310
1360
|
const matches = visibleMatches();
|
|
1311
1361
|
if (matches.length === 0) {
|
|
@@ -1344,6 +1394,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1344
1394
|
time.className = 'jp-ClaudeSessionsPanel-branchTime';
|
|
1345
1395
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1346
1396
|
row.appendChild(time);
|
|
1397
|
+
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1347
1398
|
row.addEventListener('click', () => {
|
|
1348
1399
|
// Selection mode: while anything is ticked, row clicks toggle
|
|
1349
1400
|
// selection - no accidental switch mid-selection.
|
|
@@ -1442,12 +1493,15 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1442
1493
|
/** Fork the active row's current conversation into a new named branch.
|
|
1443
1494
|
*
|
|
1444
1495
|
* Asks for a name, then launches a terminal running
|
|
1445
|
-
* ``claude --resume <current> --fork-session --session-id <new uuid>`` -
|
|
1446
|
-
* the uuid is generated here so the forked JSONL is known up front
|
|
1447
|
-
* claude
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1496
|
+
* ``claude --resume <current> --fork-session --session-id <new uuid> -n <name>`` -
|
|
1497
|
+
* the uuid is generated here so the forked JSONL is known up front, and
|
|
1498
|
+
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
1499
|
+
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1500
|
+
* it sticks even though the fork inherits the parent's title. The fork is
|
|
1501
|
+
* the newest JSONL, so recency resolution makes it the row's current
|
|
1502
|
+
* conversation without an explicit switch. claude writes the fork's JSONL
|
|
1503
|
+
* lazily (on its first turn), so the branch appears on the next poll once
|
|
1504
|
+
* it materialises - the panel cannot list a file that does not exist yet.
|
|
1451
1505
|
*/
|
|
1452
1506
|
async _branchSession(forceDangerous) {
|
|
1453
1507
|
const session = this._activeSession;
|
|
@@ -1472,6 +1526,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1472
1526
|
project_path: session.project_path,
|
|
1473
1527
|
session_id: session.session_id,
|
|
1474
1528
|
fork_session_id: forkId,
|
|
1529
|
+
name: title,
|
|
1475
1530
|
dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
|
|
1476
1531
|
})
|
|
1477
1532
|
});
|
|
@@ -1491,36 +1546,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1491
1546
|
finally {
|
|
1492
1547
|
spinner.dispose();
|
|
1493
1548
|
}
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1497
|
-
}
|
|
1498
|
-
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
1499
|
-
* does not), then refresh so the row shows the named fork as current. */
|
|
1500
|
-
async _stampForkTitle(encodedPath, sessionId, title) {
|
|
1501
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1502
|
-
try {
|
|
1503
|
-
await requestAPI('sessions/set-title', this._serverSettings, {
|
|
1504
|
-
method: 'POST',
|
|
1505
|
-
body: JSON.stringify({
|
|
1506
|
-
encoded_path: encodedPath,
|
|
1507
|
-
session_id: sessionId,
|
|
1508
|
-
title
|
|
1509
|
-
})
|
|
1510
|
-
});
|
|
1511
|
-
await this._fetch();
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
catch (err) {
|
|
1515
|
-
const notYet = err instanceof ServerConnection.ResponseError &&
|
|
1516
|
-
err.response.status === 404;
|
|
1517
|
-
if (!notYet) {
|
|
1518
|
-
break;
|
|
1519
|
-
}
|
|
1520
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
Notification.warning(`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`, { autoClose: 6000 });
|
|
1549
|
+
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1550
|
+
// on the fork's first turn. The branch surfaces on the next poll once
|
|
1551
|
+
// claude materialises its JSONL.
|
|
1524
1552
|
}
|
|
1525
1553
|
/** Switch the active row's project to another conversation branch.
|
|
1526
1554
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
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.23",
|
|
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",
|
|
@@ -378,17 +378,16 @@ describe('launch spinner dismiss contract', () => {
|
|
|
378
378
|
expect(branch).toMatch(/UUID\.uuid4\(\)/);
|
|
379
379
|
expect(branch).toMatch(/fork_session_id: forkId/);
|
|
380
380
|
expect(branch).toMatch(/session_id: session\.session_id/);
|
|
381
|
-
|
|
381
|
+
// The name is forced at launch (claude -n <name>) so it sticks.
|
|
382
|
+
expect(branch).toMatch(/name: title/);
|
|
382
383
|
});
|
|
383
384
|
|
|
384
|
-
it('
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
)
|
|
388
|
-
expect(
|
|
389
|
-
expect(
|
|
390
|
-
expect(stamp).toMatch(/await this\._fetch\(\)/);
|
|
391
|
-
expect(stamp).toMatch(/Notification\.warning/);
|
|
385
|
+
it('branch session does not post-hoc stamp a title (claude -n owns it)', () => {
|
|
386
|
+
// The obsolete sessions/set-title poll and its false "could not be
|
|
387
|
+
// applied" warning were removed once -n took over naming (DEF-1).
|
|
388
|
+
expect(widgetSrc).not.toMatch(/_stampForkTitle/);
|
|
389
|
+
expect(widgetSrc).not.toMatch(/sessions\/set-title/);
|
|
390
|
+
expect(widgetSrc).not.toMatch(/could not be applied/);
|
|
392
391
|
});
|
|
393
392
|
|
|
394
393
|
it('live dot is softened with reduced opacity', () => {
|
|
@@ -402,6 +401,68 @@ describe('launch spinner dismiss contract', () => {
|
|
|
402
401
|
expect(dot).toMatch(/--jp-success-color1/);
|
|
403
402
|
expect(dot).toMatch(/opacity: 0\.75/);
|
|
404
403
|
});
|
|
404
|
+
|
|
405
|
+
it('Copy Session ID command copies the active session id', () => {
|
|
406
|
+
const cmd = (widgetSrc.match(
|
|
407
|
+
/addCommand\('claude-code-sessions:copy-session-id'[\s\S]*?\}\);/
|
|
408
|
+
) ?? [''])[0];
|
|
409
|
+
expect(cmd).toMatch(/label: 'Copy Session ID'/);
|
|
410
|
+
expect(cmd).toMatch(/this\._activeSession\?\.session_id/);
|
|
411
|
+
expect(cmd).toMatch(/Clipboard\.copyToSystem\(id\)/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('Copy Session ID sits in the context menu next to Copy Path', () => {
|
|
415
|
+
expect(widgetSrc).toMatch(
|
|
416
|
+
/copy-path'[\s\S]*?claude-code-sessions:copy-session-id/
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('popup rows carry a copy button that copies without switching', () => {
|
|
421
|
+
const helper = (widgetSrc.match(
|
|
422
|
+
/private _branchCopyButton[\s\S]*?\n \}/
|
|
423
|
+
) ?? [''])[0];
|
|
424
|
+
expect(helper).toMatch(/branchCopy/);
|
|
425
|
+
expect(helper).toMatch(/copyIcon\.element/);
|
|
426
|
+
// type='button' so the button never acts as a form submit.
|
|
427
|
+
expect(helper).toMatch(/btn\.type = 'button'/);
|
|
428
|
+
expect(helper).toMatch(/stopPropagation/);
|
|
429
|
+
expect(helper).toMatch(/Clipboard\.copyToSystem\(sessionId\)/);
|
|
430
|
+
// Wired into the current row and every branch row.
|
|
431
|
+
expect(widgetSrc).toMatch(/this\._branchCopyButton\(current\)/);
|
|
432
|
+
expect(widgetSrc).toMatch(/this\._branchCopyButton\(b\.session_id\)/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('refresh raises the panel veil; the background poll stays silent', () => {
|
|
436
|
+
const refresh = (widgetSrc.match(
|
|
437
|
+
/refresh\(\): void \{[\s\S]*?\n \}/
|
|
438
|
+
) ?? [''])[0];
|
|
439
|
+
expect(refresh).toMatch(/this\._setLoading\(true\)/);
|
|
440
|
+
expect(refresh).toMatch(/this\._setLoading\(false\)/);
|
|
441
|
+
const poll = (widgetSrc.match(/private _startPolling[\s\S]*?\n \}/) ?? [
|
|
442
|
+
''
|
|
443
|
+
])[0];
|
|
444
|
+
expect(poll).not.toMatch(/_setLoading/);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('refresh veil is built on the root so _render never wipes it', () => {
|
|
448
|
+
expect(widgetSrc).toMatch(/jp-ClaudeSessionsPanel-loading/);
|
|
449
|
+
expect(widgetSrc).toMatch(/root\.appendChild\(loading\)/);
|
|
450
|
+
// Starts hidden and is toggled via the `hidden` attribute.
|
|
451
|
+
expect(widgetSrc).toMatch(/loading\.hidden = true/);
|
|
452
|
+
const css: string = fs.readFileSync(
|
|
453
|
+
path.join(__dirname, '..', '..', 'style', 'base.css'),
|
|
454
|
+
'utf-8'
|
|
455
|
+
);
|
|
456
|
+
// The veil rule MUST be gated on :not([hidden]); a bare
|
|
457
|
+
// `.loading { display: flex }` author rule beats the UA
|
|
458
|
+
// `[hidden] { display: none }` and pins the veil permanently on.
|
|
459
|
+
const veil = (css.match(
|
|
460
|
+
/\.jp-ClaudeSessionsPanel-loading:not\(\[hidden\]\) \{[\s\S]*?\}/
|
|
461
|
+
) ?? [''])[0];
|
|
462
|
+
expect(veil).toMatch(/position: absolute/);
|
|
463
|
+
expect(veil).toMatch(/inset: 0/);
|
|
464
|
+
expect(veil).toMatch(/display: flex/);
|
|
465
|
+
});
|
|
405
466
|
});
|
|
406
467
|
|
|
407
468
|
it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
|
package/src/widget.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { ServerConnection } from '@jupyterlab/services';
|
|
10
10
|
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
11
11
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
12
|
-
import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
12
|
+
import { copyIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
13
13
|
import { CommandRegistry } from '@lumino/commands';
|
|
14
14
|
import { UUID } from '@lumino/coreutils';
|
|
15
15
|
import { Menu, Widget } from '@lumino/widgets';
|
|
@@ -131,18 +131,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
refresh(): void {
|
|
134
|
-
this.
|
|
134
|
+
this._setLoading(true);
|
|
135
135
|
this._setRefreshSpinning(true);
|
|
136
|
-
// `_fetch` is filesystem-fast, so without a floor the
|
|
137
|
-
//
|
|
138
|
-
//
|
|
136
|
+
// `_fetch` is filesystem-fast, so without a floor the spinner would show
|
|
137
|
+
// for a single frame and read as "nothing happened". Hold it for at least
|
|
138
|
+
// ~500 ms so the click visibly registers as a full re-poll.
|
|
139
139
|
const minSpin = new Promise<void>(resolve =>
|
|
140
140
|
window.setTimeout(resolve, 500)
|
|
141
141
|
);
|
|
142
142
|
Promise.all([
|
|
143
143
|
this._fetch().catch(err => this._showError(err)),
|
|
144
144
|
minSpin
|
|
145
|
-
]).finally(() =>
|
|
145
|
+
]).finally(() => {
|
|
146
|
+
this._setRefreshSpinning(false);
|
|
147
|
+
this._setLoading(false);
|
|
148
|
+
});
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
/** Choose how rows are labelled: by session name, folder name, or path. */
|
|
@@ -242,11 +245,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
242
245
|
const body = document.createElement('div');
|
|
243
246
|
body.className = 'jp-ClaudeSessionsPanel-body';
|
|
244
247
|
|
|
248
|
+
// Refresh veil + spinner, shown only during an explicit refresh. It lives
|
|
249
|
+
// on the root (not the body) so `_render` - which wipes the body - never
|
|
250
|
+
// removes it.
|
|
251
|
+
const loading = document.createElement('div');
|
|
252
|
+
loading.className = 'jp-ClaudeSessionsPanel-loading';
|
|
253
|
+
loading.hidden = true;
|
|
254
|
+
const loadingSpinner = document.createElement('div');
|
|
255
|
+
loadingSpinner.className =
|
|
256
|
+
'jp-claude-sessions-panel-spinner jp-ClaudeSessionsPanel-loadingSpinner';
|
|
257
|
+
loading.appendChild(loadingSpinner);
|
|
258
|
+
|
|
245
259
|
root.appendChild(header);
|
|
246
260
|
root.appendChild(search);
|
|
247
261
|
root.appendChild(body);
|
|
262
|
+
root.appendChild(loading);
|
|
248
263
|
|
|
249
264
|
this._bodyEl = body;
|
|
265
|
+
this._loadingEl = loading;
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
/** Show / hide the filter input. Hiding also clears the active filter
|
|
@@ -357,8 +373,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
357
373
|
);
|
|
358
374
|
}
|
|
359
375
|
|
|
360
|
-
|
|
361
|
-
|
|
376
|
+
/** Raise or clear the full-panel refresh veil. Only the explicit refresh
|
|
377
|
+
* path calls this; the background poll fetches silently so the panel never
|
|
378
|
+
* flashes a spinner on its own. */
|
|
379
|
+
private _setLoading(on: boolean): void {
|
|
380
|
+
if (this._loadingEl) {
|
|
381
|
+
this._loadingEl.hidden = !on;
|
|
382
|
+
}
|
|
362
383
|
}
|
|
363
384
|
|
|
364
385
|
private _showError(err: unknown): void {
|
|
@@ -1249,6 +1270,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1249
1270
|
}
|
|
1250
1271
|
});
|
|
1251
1272
|
|
|
1273
|
+
this._commands.addCommand('claude-code-sessions:copy-session-id', {
|
|
1274
|
+
label: 'Copy Session ID',
|
|
1275
|
+
execute: () => {
|
|
1276
|
+
const id = this._activeSession?.session_id;
|
|
1277
|
+
if (id) {
|
|
1278
|
+
Clipboard.copyToSystem(id);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1252
1283
|
this._commands.addCommand('claude-code-sessions:cleanup-parallel', {
|
|
1253
1284
|
label: () =>
|
|
1254
1285
|
`Clean Up Parallel Sessions (${this._activeSession?.extra_sessions ?? 0})`,
|
|
@@ -1375,6 +1406,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1375
1406
|
command: 'claude-code-sessions:toggle-favourite'
|
|
1376
1407
|
});
|
|
1377
1408
|
this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
|
|
1409
|
+
this._contextMenu.addItem({
|
|
1410
|
+
command: 'claude-code-sessions:copy-session-id'
|
|
1411
|
+
});
|
|
1378
1412
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1379
1413
|
if (withBranches) {
|
|
1380
1414
|
this._contextMenu.addItem({
|
|
@@ -1437,6 +1471,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1437
1471
|
this._contextMenu.open(x, y);
|
|
1438
1472
|
}
|
|
1439
1473
|
|
|
1474
|
+
/** A compact copy button for a popup row that copies the given session id
|
|
1475
|
+
* to the system clipboard. ``stopPropagation`` keeps the click from
|
|
1476
|
+
* switching or selecting the row it sits in. */
|
|
1477
|
+
private _branchCopyButton(sessionId: string): HTMLButtonElement {
|
|
1478
|
+
const btn = document.createElement('button');
|
|
1479
|
+
btn.type = 'button';
|
|
1480
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchCopy';
|
|
1481
|
+
btn.title = 'Copy session id';
|
|
1482
|
+
copyIcon.element({ container: btn });
|
|
1483
|
+
btn.addEventListener('click', e => {
|
|
1484
|
+
e.stopPropagation();
|
|
1485
|
+
Clipboard.copyToSystem(sessionId);
|
|
1486
|
+
});
|
|
1487
|
+
return btn;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1440
1490
|
/** Popup with the project's full branch list - browse, filter, switch
|
|
1441
1491
|
* and manage. Clicking an entry switches while nothing is selected;
|
|
1442
1492
|
* checkbox selection (one, many, or select-all) arms a two-step Delete
|
|
@@ -1524,6 +1574,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1524
1574
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1525
1575
|
badge.textContent = 'current';
|
|
1526
1576
|
currentRow.appendChild(badge);
|
|
1577
|
+
currentRow.appendChild(this._branchCopyButton(current));
|
|
1527
1578
|
list.appendChild(currentRow);
|
|
1528
1579
|
|
|
1529
1580
|
const matches = visibleMatches();
|
|
@@ -1566,6 +1617,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1566
1617
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1567
1618
|
row.appendChild(time);
|
|
1568
1619
|
|
|
1620
|
+
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1621
|
+
|
|
1569
1622
|
row.addEventListener('click', () => {
|
|
1570
1623
|
// Selection mode: while anything is ticked, row clicks toggle
|
|
1571
1624
|
// selection - no accidental switch mid-selection.
|
|
@@ -1671,12 +1724,15 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1671
1724
|
/** Fork the active row's current conversation into a new named branch.
|
|
1672
1725
|
*
|
|
1673
1726
|
* Asks for a name, then launches a terminal running
|
|
1674
|
-
* ``claude --resume <current> --fork-session --session-id <new uuid>`` -
|
|
1675
|
-
* the uuid is generated here so the forked JSONL is known up front
|
|
1676
|
-
* claude
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1727
|
+
* ``claude --resume <current> --fork-session --session-id <new uuid> -n <name>`` -
|
|
1728
|
+
* the uuid is generated here so the forked JSONL is known up front, and
|
|
1729
|
+
* ``-n`` makes claude own the name: it writes the chosen name as a
|
|
1730
|
+
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1731
|
+
* it sticks even though the fork inherits the parent's title. The fork is
|
|
1732
|
+
* the newest JSONL, so recency resolution makes it the row's current
|
|
1733
|
+
* conversation without an explicit switch. claude writes the fork's JSONL
|
|
1734
|
+
* lazily (on its first turn), so the branch appears on the next poll once
|
|
1735
|
+
* it materialises - the panel cannot list a file that does not exist yet.
|
|
1680
1736
|
*/
|
|
1681
1737
|
private async _branchSession(forceDangerous: boolean): Promise<void> {
|
|
1682
1738
|
const session = this._activeSession;
|
|
@@ -1704,6 +1760,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1704
1760
|
project_path: session.project_path,
|
|
1705
1761
|
session_id: session.session_id,
|
|
1706
1762
|
fork_session_id: forkId,
|
|
1763
|
+
name: title,
|
|
1707
1764
|
dangerously_skip_permissions:
|
|
1708
1765
|
forceDangerous || this._dangerouslySkip
|
|
1709
1766
|
})
|
|
@@ -1723,48 +1780,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1723
1780
|
} finally {
|
|
1724
1781
|
spinner.dispose();
|
|
1725
1782
|
}
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
1732
|
-
* does not), then refresh so the row shows the named fork as current. */
|
|
1733
|
-
private async _stampForkTitle(
|
|
1734
|
-
encodedPath: string,
|
|
1735
|
-
sessionId: string,
|
|
1736
|
-
title: string
|
|
1737
|
-
): Promise<void> {
|
|
1738
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1739
|
-
try {
|
|
1740
|
-
await requestAPI<{ ok: boolean }>(
|
|
1741
|
-
'sessions/set-title',
|
|
1742
|
-
this._serverSettings,
|
|
1743
|
-
{
|
|
1744
|
-
method: 'POST',
|
|
1745
|
-
body: JSON.stringify({
|
|
1746
|
-
encoded_path: encodedPath,
|
|
1747
|
-
session_id: sessionId,
|
|
1748
|
-
title
|
|
1749
|
-
})
|
|
1750
|
-
}
|
|
1751
|
-
);
|
|
1752
|
-
await this._fetch();
|
|
1753
|
-
return;
|
|
1754
|
-
} catch (err) {
|
|
1755
|
-
const notYet =
|
|
1756
|
-
err instanceof ServerConnection.ResponseError &&
|
|
1757
|
-
err.response.status === 404;
|
|
1758
|
-
if (!notYet) {
|
|
1759
|
-
break;
|
|
1760
|
-
}
|
|
1761
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
Notification.warning(
|
|
1765
|
-
`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`,
|
|
1766
|
-
{ autoClose: 6000 }
|
|
1767
|
-
);
|
|
1783
|
+
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1784
|
+
// on the fork's first turn. The branch surfaces on the next poll once
|
|
1785
|
+
// claude materialises its JSONL.
|
|
1768
1786
|
}
|
|
1769
1787
|
|
|
1770
1788
|
/** Switch the active row's project to another conversation branch.
|
|
@@ -1835,6 +1853,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1835
1853
|
private readonly _app: JupyterFrontEnd;
|
|
1836
1854
|
private readonly _serverSettings: ServerConnection.ISettings;
|
|
1837
1855
|
private _bodyEl!: HTMLDivElement;
|
|
1856
|
+
private _loadingEl: HTMLElement | null = null;
|
|
1838
1857
|
private _refreshBtn: HTMLButtonElement | null = null;
|
|
1839
1858
|
private _filterBtn: HTMLButtonElement | null = null;
|
|
1840
1859
|
private _searchEl: HTMLInputElement | null = null;
|
package/style/base.css
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
.jp-ClaudeSessionsPanel {
|
|
7
7
|
display: flex;
|
|
8
8
|
flex-direction: column;
|
|
9
|
+
position: relative;
|
|
9
10
|
background-color: var(--jp-layout-color1);
|
|
10
11
|
color: var(--jp-ui-font-color1);
|
|
11
12
|
font-family: var(--jp-ui-font-family);
|
|
@@ -274,6 +275,26 @@
|
|
|
274
275
|
display: inline-block;
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/* Full-panel veil shown only during an explicit refresh (refresh button or
|
|
279
|
+
panel show) - the background 30 s poll never raises it. The rule is gated
|
|
280
|
+
on :not([hidden]) because this author `display` would otherwise beat the
|
|
281
|
+
UA `[hidden] { display: none }` rule and pin the veil permanently on. */
|
|
282
|
+
.jp-ClaudeSessionsPanel-loading:not([hidden]) {
|
|
283
|
+
position: absolute;
|
|
284
|
+
inset: 0;
|
|
285
|
+
z-index: 10;
|
|
286
|
+
display: flex;
|
|
287
|
+
align-items: center;
|
|
288
|
+
justify-content: center;
|
|
289
|
+
background: color-mix(in srgb, var(--jp-layout-color1) 65%, transparent);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.jp-ClaudeSessionsPanel-loadingSpinner {
|
|
293
|
+
width: 24px;
|
|
294
|
+
height: 24px;
|
|
295
|
+
border-width: 3px;
|
|
296
|
+
}
|
|
297
|
+
|
|
277
298
|
.jp-ClaudeSessionsPanel-launchOverlay {
|
|
278
299
|
display: flex;
|
|
279
300
|
flex-direction: column;
|
|
@@ -379,11 +400,28 @@
|
|
|
379
400
|
}
|
|
380
401
|
|
|
381
402
|
.jp-ClaudeSessionsPanel-branchLabel {
|
|
403
|
+
flex: 1 1 auto;
|
|
404
|
+
min-width: 0;
|
|
382
405
|
overflow: hidden;
|
|
383
406
|
text-overflow: ellipsis;
|
|
384
407
|
white-space: nowrap;
|
|
385
408
|
}
|
|
386
409
|
|
|
410
|
+
.jp-ClaudeSessionsPanel-branchCopy {
|
|
411
|
+
flex: none;
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: center;
|
|
414
|
+
padding: 0;
|
|
415
|
+
border: none;
|
|
416
|
+
background: transparent;
|
|
417
|
+
color: var(--jp-ui-font-color2);
|
|
418
|
+
cursor: pointer;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.jp-ClaudeSessionsPanel-branchCopy:hover {
|
|
422
|
+
color: var(--jp-ui-font-color1);
|
|
423
|
+
}
|
|
424
|
+
|
|
387
425
|
.jp-ClaudeSessionsPanel-branchTime {
|
|
388
426
|
flex: none;
|
|
389
427
|
width: 4em;
|