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 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
- private _showLoading;
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. 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.
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._showLoading();
95
+ this._setLoading(true);
95
96
  this._setRefreshSpinning(true);
96
- // `_fetch` is filesystem-fast, so without a floor the refresh icon would
97
- // spin for a single frame and read as "nothing happened". Hold the
98
- // spinner for at least ~500 ms so the click visibly registers.
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(() => this._setRefreshSpinning(false));
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
- _showLoading() {
295
- // No visual indicator - the spinning refresh button conveys loading state.
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. Once
1447
- * claude materialises the file (polled via sessions/set-title) the chosen
1448
- * name is stamped as a custom-title record. The fork is the newest JSONL,
1449
- * so the recency resolution makes it the row's current conversation
1450
- * without an explicit switch.
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
- // Stamp the name in the background once the forked JSONL appears -
1495
- // claude writes it on its first record, typically within seconds.
1496
- void this._stampForkTitle(session.encoded_path, forkId, title);
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.21",
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
- expect(branch).toMatch(/_stampForkTitle/);
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('fork title stamping retries on 404 until the JSONL appears', () => {
385
- const stamp = (widgetSrc.match(
386
- /private async _stampForkTitle[\s\S]*?\n \}/
387
- ) ?? [''])[0];
388
- expect(stamp).toMatch(/sessions\/set-title/);
389
- expect(stamp).toMatch(/status === 404/);
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._showLoading();
134
+ this._setLoading(true);
135
135
  this._setRefreshSpinning(true);
136
- // `_fetch` is filesystem-fast, so without a floor the refresh icon would
137
- // spin for a single frame and read as "nothing happened". Hold the
138
- // spinner for at least ~500 ms so the click visibly registers.
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(() => this._setRefreshSpinning(false));
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
- private _showLoading(): void {
361
- // No visual indicator - the spinning refresh button conveys loading state.
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. Once
1676
- * claude materialises the file (polled via sessions/set-title) the chosen
1677
- * name is stamped as a custom-title record. The fork is the newest JSONL,
1678
- * so the recency resolution makes it the row's current conversation
1679
- * without an explicit switch.
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
- // Stamp the name in the background once the forked JSONL appears -
1727
- // claude writes it on its first record, typically within seconds.
1728
- void this._stampForkTitle(session.encoded_path, forkId, title);
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;