jupyterlab_claude_code_extension 1.2.22 → 1.2.24

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
@@ -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. ``_stampForkTitle`` still runs
131
- * to seed the title and refresh the row promptly while claude starts.
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._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.
@@ -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. ``_stampForkTitle`` still runs
1452
- * to seed the title and refresh the row promptly while claude starts.
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
- // Stamp the name in the background once the forked JSONL appears -
1498
- // claude writes it on its first record, typically within seconds.
1499
- void this._stampForkTitle(session.encoded_path, forkId, title);
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.22",
3
+ "version": "1.2.24",
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('fork title stamping retries on 404 until the JSONL appears', () => {
387
- const stamp = (widgetSrc.match(
388
- /private async _stampForkTitle[\s\S]*?\n \}/
389
- ) ?? [''])[0];
390
- expect(stamp).toMatch(/sessions\/set-title/);
391
- expect(stamp).toMatch(/status === 404/);
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._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.
@@ -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. ``_stampForkTitle`` still runs
1681
- * to seed the title and refresh the row promptly while claude starts.
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
- // Stamp the name in the background once the forked JSONL appears -
1730
- // claude writes it on its first record, typically within seconds.
1731
- void this._stampForkTitle(session.encoded_path, forkId, title);
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;