jupyterlab_claude_code_extension 1.2.22 → 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 +12 -6
- package/lib/widget.js +65 -40
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +68 -9
- package/src/widget.ts +68 -52
- 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
|
|
@@ -127,13 +134,11 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
127
134
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
128
135
|
* it sticks even though the fork inherits the parent's title. The fork is
|
|
129
136
|
* the newest JSONL, so recency resolution makes it the row's current
|
|
130
|
-
* conversation without an explicit switch.
|
|
131
|
-
*
|
|
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.
|
|
132
140
|
*/
|
|
133
141
|
private _branchSession;
|
|
134
|
-
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
135
|
-
* does not), then refresh so the row shows the named fork as current. */
|
|
136
|
-
private _stampForkTitle;
|
|
137
142
|
/** Switch the active row's project to another conversation branch.
|
|
138
143
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
139
144
|
* the selected conversation as the row's current one. */
|
|
@@ -143,6 +148,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
143
148
|
private readonly _app;
|
|
144
149
|
private readonly _serverSettings;
|
|
145
150
|
private _bodyEl;
|
|
151
|
+
private _loadingEl;
|
|
146
152
|
private _refreshBtn;
|
|
147
153
|
private _filterBtn;
|
|
148
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.
|
|
@@ -1448,8 +1499,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1448
1499
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1449
1500
|
* it sticks even though the fork inherits the parent's title. The fork is
|
|
1450
1501
|
* the newest JSONL, so recency resolution makes it the row's current
|
|
1451
|
-
* conversation without an explicit switch.
|
|
1452
|
-
*
|
|
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.
|
|
1453
1505
|
*/
|
|
1454
1506
|
async _branchSession(forceDangerous) {
|
|
1455
1507
|
const session = this._activeSession;
|
|
@@ -1494,36 +1546,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1494
1546
|
finally {
|
|
1495
1547
|
spinner.dispose();
|
|
1496
1548
|
}
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1500
|
-
}
|
|
1501
|
-
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
1502
|
-
* does not), then refresh so the row shows the named fork as current. */
|
|
1503
|
-
async _stampForkTitle(encodedPath, sessionId, title) {
|
|
1504
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1505
|
-
try {
|
|
1506
|
-
await requestAPI('sessions/set-title', this._serverSettings, {
|
|
1507
|
-
method: 'POST',
|
|
1508
|
-
body: JSON.stringify({
|
|
1509
|
-
encoded_path: encodedPath,
|
|
1510
|
-
session_id: sessionId,
|
|
1511
|
-
title
|
|
1512
|
-
})
|
|
1513
|
-
});
|
|
1514
|
-
await this._fetch();
|
|
1515
|
-
return;
|
|
1516
|
-
}
|
|
1517
|
-
catch (err) {
|
|
1518
|
-
const notYet = err instanceof ServerConnection.ResponseError &&
|
|
1519
|
-
err.response.status === 404;
|
|
1520
|
-
if (!notYet) {
|
|
1521
|
-
break;
|
|
1522
|
-
}
|
|
1523
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
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.
|
|
1527
1552
|
}
|
|
1528
1553
|
/** Switch the active row's project to another conversation branch.
|
|
1529
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",
|
|
@@ -380,17 +380,14 @@ describe('launch spinner dismiss contract', () => {
|
|
|
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
382
|
expect(branch).toMatch(/name: title/);
|
|
383
|
-
expect(branch).toMatch(/_stampForkTitle/);
|
|
384
383
|
});
|
|
385
384
|
|
|
386
|
-
it('
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
)
|
|
390
|
-
expect(
|
|
391
|
-
expect(
|
|
392
|
-
expect(stamp).toMatch(/await this\._fetch\(\)/);
|
|
393
|
-
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/);
|
|
394
391
|
});
|
|
395
392
|
|
|
396
393
|
it('live dot is softened with reduced opacity', () => {
|
|
@@ -404,6 +401,68 @@ describe('launch spinner dismiss contract', () => {
|
|
|
404
401
|
expect(dot).toMatch(/--jp-success-color1/);
|
|
405
402
|
expect(dot).toMatch(/opacity: 0\.75/);
|
|
406
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
|
+
});
|
|
407
466
|
});
|
|
408
467
|
|
|
409
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.
|
|
@@ -1677,8 +1730,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1677
1730
|
* custom-title record on its first turn and re-stamps it every turn, so
|
|
1678
1731
|
* it sticks even though the fork inherits the parent's title. The fork is
|
|
1679
1732
|
* the newest JSONL, so recency resolution makes it the row's current
|
|
1680
|
-
* conversation without an explicit switch.
|
|
1681
|
-
*
|
|
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.
|
|
1682
1736
|
*/
|
|
1683
1737
|
private async _branchSession(forceDangerous: boolean): Promise<void> {
|
|
1684
1738
|
const session = this._activeSession;
|
|
@@ -1726,48 +1780,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1726
1780
|
} finally {
|
|
1727
1781
|
spinner.dispose();
|
|
1728
1782
|
}
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
/** Retry sessions/set-title until the forked JSONL exists (404 while it
|
|
1735
|
-
* does not), then refresh so the row shows the named fork as current. */
|
|
1736
|
-
private async _stampForkTitle(
|
|
1737
|
-
encodedPath: string,
|
|
1738
|
-
sessionId: string,
|
|
1739
|
-
title: string
|
|
1740
|
-
): Promise<void> {
|
|
1741
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1742
|
-
try {
|
|
1743
|
-
await requestAPI<{ ok: boolean }>(
|
|
1744
|
-
'sessions/set-title',
|
|
1745
|
-
this._serverSettings,
|
|
1746
|
-
{
|
|
1747
|
-
method: 'POST',
|
|
1748
|
-
body: JSON.stringify({
|
|
1749
|
-
encoded_path: encodedPath,
|
|
1750
|
-
session_id: sessionId,
|
|
1751
|
-
title
|
|
1752
|
-
})
|
|
1753
|
-
}
|
|
1754
|
-
);
|
|
1755
|
-
await this._fetch();
|
|
1756
|
-
return;
|
|
1757
|
-
} catch (err) {
|
|
1758
|
-
const notYet =
|
|
1759
|
-
err instanceof ServerConnection.ResponseError &&
|
|
1760
|
-
err.response.status === 404;
|
|
1761
|
-
if (!notYet) {
|
|
1762
|
-
break;
|
|
1763
|
-
}
|
|
1764
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
Notification.warning(
|
|
1768
|
-
`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`,
|
|
1769
|
-
{ autoClose: 6000 }
|
|
1770
|
-
);
|
|
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.
|
|
1771
1786
|
}
|
|
1772
1787
|
|
|
1773
1788
|
/** Switch the active row's project to another conversation branch.
|
|
@@ -1838,6 +1853,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1838
1853
|
private readonly _app: JupyterFrontEnd;
|
|
1839
1854
|
private readonly _serverSettings: ServerConnection.ISettings;
|
|
1840
1855
|
private _bodyEl!: HTMLDivElement;
|
|
1856
|
+
private _loadingEl: HTMLElement | null = null;
|
|
1841
1857
|
private _refreshBtn: HTMLButtonElement | null = null;
|
|
1842
1858
|
private _filterBtn: HTMLButtonElement | null = null;
|
|
1843
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;
|