jupyterlab_claude_code_extension 1.0.16

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/lib/widget.js ADDED
@@ -0,0 +1,643 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { Menu, Widget } from '@lumino/widgets';
3
+ import { requestAPI } from './request';
4
+ import { claudeIcon, refreshIcon, removeIcon, starFilledIcon } from './icons';
5
+ const POLL_INTERVAL_MS = 10000;
6
+ const RECENT_LIMIT = 10;
7
+ const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
8
+ const SECTION_LABELS = {
9
+ favourites: 'Favourites',
10
+ recent: 'Recent',
11
+ all: 'All'
12
+ };
13
+ const DEFAULT_EXPANDED = {
14
+ favourites: true,
15
+ recent: true,
16
+ all: true
17
+ };
18
+ function loadExpanded() {
19
+ try {
20
+ const raw = window.localStorage.getItem(EXPANDED_STORAGE_KEY);
21
+ if (!raw) {
22
+ return { ...DEFAULT_EXPANDED };
23
+ }
24
+ const parsed = JSON.parse(raw);
25
+ return {
26
+ favourites: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.favourites) === 'boolean'
27
+ ? parsed.favourites
28
+ : DEFAULT_EXPANDED.favourites,
29
+ recent: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.recent) === 'boolean'
30
+ ? parsed.recent
31
+ : DEFAULT_EXPANDED.recent,
32
+ all: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.all) === 'boolean' ? parsed.all : DEFAULT_EXPANDED.all
33
+ };
34
+ }
35
+ catch (_err) {
36
+ return { ...DEFAULT_EXPANDED };
37
+ }
38
+ }
39
+ function saveExpanded(state) {
40
+ try {
41
+ window.localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(state));
42
+ }
43
+ catch (_err) {
44
+ // localStorage unavailable (private mode, quota) - ignore
45
+ }
46
+ }
47
+ // Drop any pre-v0.6.18 localStorage entries from previous schemes - they
48
+ // were unreliable and we now interrogate JL's terminal manager directly.
49
+ try {
50
+ window.localStorage.removeItem('jupyterlab_claude_code_extension:terminals');
51
+ }
52
+ catch (_err) {
53
+ // ignore
54
+ }
55
+ export class ClaudeCodeSessionsWidget extends Widget {
56
+ constructor(app, rootDir, terminalTracker = null) {
57
+ super();
58
+ this._refreshBtn = null;
59
+ this._sessions = null;
60
+ this._expanded = loadExpanded();
61
+ this._activeSession = null;
62
+ this._activeRowEl = null;
63
+ this._pollHandle = null;
64
+ this._removingPaths = new Set();
65
+ this._terminalsByPath = new Map();
66
+ this._pendingByPath = new Map();
67
+ this._resolveNames = true;
68
+ this._displayNames = new Map();
69
+ this._app = app;
70
+ this._serverSettings = app.serviceManager.serverSettings;
71
+ this._rootDir = rootDir.replace(/\/+$/, '');
72
+ this._terminalTracker = terminalTracker;
73
+ this.id = 'jupyterlab-claude-code-extension';
74
+ this.title.icon = claudeIcon;
75
+ this.title.caption = 'Claude Code Sessions';
76
+ this.addClass('jp-ClaudeSessionsPanel');
77
+ this._buildShell();
78
+ this._setupContextMenu();
79
+ }
80
+ refresh() {
81
+ this._showLoading();
82
+ this._setRefreshSpinning(true);
83
+ this._fetch()
84
+ .catch(err => this._showError(err))
85
+ .finally(() => this._setRefreshSpinning(false));
86
+ }
87
+ /** Toggle whether explicit ``/rename`` names are honoured. */
88
+ setResolveSessionNames(on) {
89
+ if (this._resolveNames === on) {
90
+ return;
91
+ }
92
+ this._resolveNames = on;
93
+ this._render();
94
+ }
95
+ onAfterShow(_msg) {
96
+ this.refresh();
97
+ this._startPolling();
98
+ }
99
+ onBeforeHide(_msg) {
100
+ this._stopPolling();
101
+ }
102
+ onCloseRequest(msg) {
103
+ this._stopPolling();
104
+ super.onCloseRequest(msg);
105
+ }
106
+ // ------------------------------------------------------------------ shell
107
+ _buildShell() {
108
+ const root = this.node;
109
+ root.innerHTML = '';
110
+ const header = document.createElement('div');
111
+ header.className = 'jp-ClaudeSessionsPanel-header';
112
+ const title = document.createElement('span');
113
+ title.className = 'jp-ClaudeSessionsPanel-title';
114
+ title.textContent = 'Claude Code Sessions';
115
+ header.appendChild(title);
116
+ const refreshBtn = document.createElement('button');
117
+ refreshBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
118
+ refreshBtn.title = 'Refresh';
119
+ refreshIcon.element({ container: refreshBtn });
120
+ refreshBtn.addEventListener('click', () => this.refresh());
121
+ header.appendChild(refreshBtn);
122
+ this._refreshBtn = refreshBtn;
123
+ const body = document.createElement('div');
124
+ body.className = 'jp-ClaudeSessionsPanel-body';
125
+ const status = document.createElement('div');
126
+ status.className = 'jp-ClaudeSessionsPanel-status';
127
+ root.appendChild(header);
128
+ root.appendChild(body);
129
+ root.appendChild(status);
130
+ this._bodyEl = body;
131
+ this._statusEl = status;
132
+ }
133
+ _showLoading() {
134
+ this._statusEl.textContent = this._sessions === null ? 'Loading...' : '';
135
+ }
136
+ _showError(err) {
137
+ const message = err instanceof Error ? err.message : String(err);
138
+ this._statusEl.textContent = `Error: ${message}`;
139
+ }
140
+ // ------------------------------------------------------------------ data
141
+ async _fetch() {
142
+ var _a;
143
+ const data = await requestAPI('sessions', this._serverSettings);
144
+ this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
145
+ this._statusEl.textContent = '';
146
+ this._render();
147
+ }
148
+ async _toggleFavourite(session) {
149
+ const next = !session.favourite;
150
+ // Optimistic update
151
+ session.favourite = next;
152
+ this._render();
153
+ try {
154
+ await requestAPI('sessions/favourite', this._serverSettings, {
155
+ method: 'POST',
156
+ body: JSON.stringify({
157
+ project_path: session.project_path,
158
+ favourite: next
159
+ })
160
+ });
161
+ }
162
+ catch (err) {
163
+ // Roll back on failure
164
+ session.favourite = !next;
165
+ this._render();
166
+ this._showError(err);
167
+ }
168
+ }
169
+ async _remove(session) {
170
+ var _a;
171
+ this._removingPaths.add(session.encoded_path);
172
+ this._render();
173
+ try {
174
+ await requestAPI('sessions/remove', this._serverSettings, {
175
+ method: 'POST',
176
+ body: JSON.stringify({ encoded_path: session.encoded_path })
177
+ });
178
+ // Drop locally and re-render; a full refresh will follow on next poll
179
+ this._sessions = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).filter(s => s.encoded_path !== session.encoded_path);
180
+ }
181
+ catch (err) {
182
+ this._showError(err);
183
+ }
184
+ finally {
185
+ this._removingPaths.delete(session.encoded_path);
186
+ this._render();
187
+ }
188
+ }
189
+ // -------------------------------------------------------------- terminal
190
+ async _resumeInTerminal(session) {
191
+ // Coalesce concurrent clicks on the same row - subsequent clicks attach
192
+ // to the in-flight promise instead of creating their own terminal.
193
+ const inFlight = this._pendingByPath.get(session.project_path);
194
+ if (inFlight) {
195
+ return inFlight;
196
+ }
197
+ const promise = this._doResumeInTerminal(session).finally(() => {
198
+ this._pendingByPath.delete(session.project_path);
199
+ });
200
+ this._pendingByPath.set(session.project_path, promise);
201
+ return promise;
202
+ }
203
+ async _doResumeInTerminal(session) {
204
+ var _a, _b, _c;
205
+ try {
206
+ // 1. In-memory microcache - covers rapid-click and post-creation reuse
207
+ // before the new widget propagates fully through the tracker. Cleared
208
+ // on widget disposal. NOT persisted to localStorage.
209
+ const cached = this._terminalsByPath.get(session.project_path);
210
+ if (cached && !cached.isDisposed) {
211
+ this._app.shell.activateById(cached.id);
212
+ return;
213
+ }
214
+ // 2. Walk every live terminal widget JL knows about and ask the
215
+ // server for the cwd of EVERY process in its pty's tree. Match if
216
+ // any one of them equals project_path.
217
+ const found = await this._findTerminalForCwd(session.project_path);
218
+ if (found) {
219
+ this._terminalsByPath.set(session.project_path, found);
220
+ this._wireTerminalDisposal(session.project_path, found);
221
+ this._app.shell.activateById(found.id);
222
+ return;
223
+ }
224
+ // 3. No matching open terminal - create a new one rooted at this folder.
225
+ const widget = await this._app.commands.execute('terminal:create-new', { cwd: session.project_path });
226
+ if (widget === null || widget === void 0 ? void 0 : widget.id) {
227
+ this._terminalsByPath.set(session.project_path, widget);
228
+ this._wireTerminalDisposal(session.project_path, widget);
229
+ }
230
+ const term = (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.content) === null || _a === void 0 ? void 0 : _a.session) !== null && _b !== void 0 ? _b : widget === null || widget === void 0 ? void 0 : widget.session;
231
+ const command = `cd ${this._shellQuote(session.project_path)} && claude --resume ${session.session_id}\r`;
232
+ if (!term || typeof term.send !== 'function') {
233
+ this._statusEl.textContent = `Run in a terminal: cd ${session.project_path} && claude --resume ${session.session_id}`;
234
+ return;
235
+ }
236
+ // Wait for the first message from the terminal (shell prompt) before
237
+ // injecting input - sending too early arrives before the shell is ready.
238
+ let sent = false;
239
+ const send = () => {
240
+ if (sent) {
241
+ return;
242
+ }
243
+ sent = true;
244
+ term.send({ type: 'stdin', content: [command] });
245
+ };
246
+ if ((_c = term.messageReceived) === null || _c === void 0 ? void 0 : _c.connect) {
247
+ const handler = () => {
248
+ term.messageReceived.disconnect(handler);
249
+ // Tiny additional delay so the prompt is rendered and ready for input
250
+ setTimeout(send, 150);
251
+ };
252
+ term.messageReceived.connect(handler);
253
+ // Hard fallback: if no message arrives within 2s, send anyway
254
+ setTimeout(send, 2000);
255
+ }
256
+ else {
257
+ setTimeout(send, 600);
258
+ }
259
+ }
260
+ catch (err) {
261
+ this._showError(err);
262
+ }
263
+ }
264
+ _shellQuote(s) {
265
+ return `'${s.replace(/'/g, "'\\''")}'`;
266
+ }
267
+ async _findTerminalForCwd(projectPath) {
268
+ var _a, _b;
269
+ if (!this._terminalTracker) {
270
+ return null;
271
+ }
272
+ const candidates = [];
273
+ this._terminalTracker.forEach((widget) => {
274
+ if (widget && !widget.isDisposed) {
275
+ candidates.push(widget);
276
+ }
277
+ });
278
+ const target = projectPath.replace(/\/+$/, '');
279
+ for (const widget of candidates) {
280
+ const sessName = (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.content) === null || _a === void 0 ? void 0 : _a.session) === null || _b === void 0 ? void 0 : _b.name;
281
+ if (typeof sessName !== 'string' || !sessName) {
282
+ continue;
283
+ }
284
+ try {
285
+ const data = await requestAPI(`terminal-cwd/${encodeURIComponent(sessName)}`, this._serverSettings);
286
+ const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
287
+ for (const cwd of cwds) {
288
+ if ((cwd || '').replace(/\/+$/, '') === target) {
289
+ return widget;
290
+ }
291
+ }
292
+ }
293
+ catch (_err) {
294
+ // Backend may report 404 for terminals that disappeared between
295
+ // tracker enumeration and fetch - skip and continue.
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+ _wireTerminalDisposal(projectPath, widget) {
301
+ var _a;
302
+ if (!((_a = widget === null || widget === void 0 ? void 0 : widget.disposed) === null || _a === void 0 ? void 0 : _a.connect)) {
303
+ return;
304
+ }
305
+ widget.disposed.connect(() => {
306
+ if (this._terminalsByPath.get(projectPath) === widget) {
307
+ this._terminalsByPath.delete(projectPath);
308
+ }
309
+ });
310
+ }
311
+ // -------------------------------------------------------------- rendering
312
+ /** Apply the resolve-names setting + path-segment disambiguation. */
313
+ _displayName(s) {
314
+ if (!this._resolveNames) {
315
+ return this._basename(s.project_path) || s.encoded_path;
316
+ }
317
+ return s.name || this._basename(s.project_path) || s.encoded_path;
318
+ }
319
+ _basename(p) {
320
+ if (!p) {
321
+ return '';
322
+ }
323
+ const parts = p.split('/').filter(Boolean);
324
+ return parts[parts.length - 1] || '';
325
+ }
326
+ /** Walk path tails until every name in a colliding group is unique. */
327
+ _disambiguate(rows) {
328
+ var _a;
329
+ const out = new Map();
330
+ const groups = new Map();
331
+ for (const r of rows) {
332
+ const n = this._displayName(r);
333
+ groups.set(n, ((_a = groups.get(n)) !== null && _a !== void 0 ? _a : []).concat(r));
334
+ }
335
+ for (const [name, group] of groups.entries()) {
336
+ if (group.length === 1) {
337
+ out.set(group[0].project_path, name);
338
+ continue;
339
+ }
340
+ const segs = group.map(r => r.project_path.split('/').filter(Boolean));
341
+ const max = Math.max(...segs.map(s => s.length));
342
+ let depth = 1;
343
+ while (depth <= max) {
344
+ const tails = segs.map(s => s.slice(-depth).join('/'));
345
+ if (new Set(tails).size === tails.length) {
346
+ group.forEach((r, i) => out.set(r.project_path, tails[i]));
347
+ break;
348
+ }
349
+ depth += 1;
350
+ }
351
+ if (!out.has(group[0].project_path)) {
352
+ // Fallback to absolute path
353
+ group.forEach(r => out.set(r.project_path, r.project_path));
354
+ }
355
+ }
356
+ return out;
357
+ }
358
+ _render() {
359
+ var _a;
360
+ const sessions = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
361
+ // Capture scrollTop per section so polling refreshes don't reset the
362
+ // user's place inside the All list.
363
+ const scrolls = new Map();
364
+ this._bodyEl
365
+ .querySelectorAll('.jp-ClaudeSessionsPanel-section')
366
+ .forEach(sect => {
367
+ const key = sect.dataset.section;
368
+ const list = sect.querySelector('.jp-ClaudeSessionsPanel-list');
369
+ if (key && list) {
370
+ scrolls.set(key, list.scrollTop);
371
+ }
372
+ });
373
+ this._bodyEl.innerHTML = '';
374
+ if (sessions.length === 0) {
375
+ const empty = document.createElement('div');
376
+ empty.className = 'jp-ClaudeSessionsPanel-empty';
377
+ empty.textContent = 'No Claude Code sessions found.';
378
+ this._bodyEl.appendChild(empty);
379
+ return;
380
+ }
381
+ // Compute disambiguated display names once per render.
382
+ this._displayNames = this._disambiguate(sessions);
383
+ const favourites = sessions.filter(s => s.favourite);
384
+ const recent = [...sessions]
385
+ .sort((a, b) => b.file_mtime - a.file_mtime)
386
+ .slice(0, RECENT_LIMIT);
387
+ const all = [...sessions].sort((a, b) => this._lookupName(a).localeCompare(this._lookupName(b)));
388
+ if (favourites.length > 0) {
389
+ this._renderSection('favourites', favourites);
390
+ }
391
+ this._renderSection('recent', recent);
392
+ this._renderSection('all', all);
393
+ // Restore scroll positions
394
+ this._bodyEl
395
+ .querySelectorAll('.jp-ClaudeSessionsPanel-section')
396
+ .forEach(sect => {
397
+ const key = sect.dataset.section;
398
+ const list = sect.querySelector('.jp-ClaudeSessionsPanel-list');
399
+ const saved = key ? scrolls.get(key) : undefined;
400
+ if (list && saved !== undefined) {
401
+ list.scrollTop = saved;
402
+ }
403
+ });
404
+ }
405
+ _renderSection(key, items) {
406
+ const section = document.createElement('div');
407
+ section.className = 'jp-ClaudeSessionsPanel-section';
408
+ section.dataset.section = key;
409
+ const expanded = this._expanded[key];
410
+ const header = document.createElement('button');
411
+ header.className = 'jp-ClaudeSessionsPanel-sectionHeader';
412
+ header.setAttribute('aria-expanded', String(expanded));
413
+ const caret = document.createElement('span');
414
+ caret.className = 'jp-ClaudeSessionsPanel-caret';
415
+ caret.textContent = expanded ? '▾' : '▸';
416
+ header.appendChild(caret);
417
+ const label = document.createElement('span');
418
+ label.className = 'jp-ClaudeSessionsPanel-sectionLabel';
419
+ label.textContent = `${SECTION_LABELS[key]} (${items.length})`;
420
+ header.appendChild(label);
421
+ header.addEventListener('click', () => {
422
+ this._expanded[key] = !this._expanded[key];
423
+ saveExpanded(this._expanded);
424
+ this._render();
425
+ });
426
+ section.appendChild(header);
427
+ if (expanded) {
428
+ const list = document.createElement('div');
429
+ list.className = 'jp-ClaudeSessionsPanel-list';
430
+ if (items.length === 0) {
431
+ const empty = document.createElement('div');
432
+ empty.className = 'jp-ClaudeSessionsPanel-emptySection';
433
+ empty.textContent =
434
+ key === 'favourites' ? 'No favourites yet.' : 'Empty.';
435
+ list.appendChild(empty);
436
+ }
437
+ else {
438
+ for (const item of items) {
439
+ list.appendChild(this._renderRow(item));
440
+ }
441
+ }
442
+ section.appendChild(list);
443
+ }
444
+ this._bodyEl.appendChild(section);
445
+ }
446
+ _renderRow(session) {
447
+ const row = document.createElement('div');
448
+ row.className = 'jp-ClaudeSessionsPanel-row';
449
+ row.title = this._buildRowTooltip(session);
450
+ const removing = this._removingPaths.has(session.encoded_path);
451
+ if (removing) {
452
+ row.classList.add('jp-mod-busy');
453
+ }
454
+ if (removing) {
455
+ const spinner = document.createElement('span');
456
+ spinner.className = 'jp-ClaudeSessionsPanel-spinner';
457
+ spinner.title = 'Removing...';
458
+ row.appendChild(spinner);
459
+ }
460
+ else if (session.remote_control) {
461
+ const dot = document.createElement('span');
462
+ dot.className = 'jp-ClaudeSessionsPanel-dot';
463
+ dot.title = 'Remote control session is active';
464
+ row.appendChild(dot);
465
+ }
466
+ else {
467
+ const dotPlaceholder = document.createElement('span');
468
+ dotPlaceholder.className = 'jp-ClaudeSessionsPanel-dotPlaceholder';
469
+ row.appendChild(dotPlaceholder);
470
+ }
471
+ const name = document.createElement('span');
472
+ name.className = 'jp-ClaudeSessionsPanel-name';
473
+ name.textContent = this._lookupName(session);
474
+ row.appendChild(name);
475
+ if (session.favourite) {
476
+ const star = document.createElement('span');
477
+ star.className = 'jp-ClaudeSessionsPanel-favStar';
478
+ star.title = 'Favourite';
479
+ starFilledIcon.element({ container: star });
480
+ row.appendChild(star);
481
+ }
482
+ row.addEventListener('click', () => {
483
+ if (removing) {
484
+ return;
485
+ }
486
+ void this._resumeInTerminal(session);
487
+ });
488
+ row.addEventListener('contextmenu', e => {
489
+ e.preventDefault();
490
+ if (removing) {
491
+ return;
492
+ }
493
+ this._activeSession = session;
494
+ this._setActiveRow(row);
495
+ this._contextMenu.open(e.clientX, e.clientY);
496
+ });
497
+ return row;
498
+ }
499
+ _lookupName(s) {
500
+ var _a;
501
+ return (_a = this._displayNames.get(s.project_path)) !== null && _a !== void 0 ? _a : this._displayName(s);
502
+ }
503
+ _buildRowTooltip(s) {
504
+ const lines = [this._lookupName(s)];
505
+ lines.push(`Path: ${this._displayPath(s.project_path)}`);
506
+ if (s.file_mtime) {
507
+ lines.push(`Last activity: ${this._formatRelativeTime(s.file_mtime)}`);
508
+ }
509
+ if (s.message_count) {
510
+ lines.push(`Messages: ${s.message_count}`);
511
+ }
512
+ if (s.git_branch) {
513
+ lines.push(`Branch: ${s.git_branch}`);
514
+ }
515
+ if (s.remote_control) {
516
+ lines.push('Remote control: active');
517
+ }
518
+ if (s.first_prompt) {
519
+ const trimmed = s.first_prompt.length > 100
520
+ ? `${s.first_prompt.slice(0, 100)}...`
521
+ : s.first_prompt;
522
+ lines.push(`First prompt: ${trimmed}`);
523
+ }
524
+ if (s.session_id) {
525
+ lines.push(`Session id: ${s.session_id}`);
526
+ }
527
+ return lines.join('\n');
528
+ }
529
+ _displayPath(absolute) {
530
+ if (!this._rootDir) {
531
+ return absolute;
532
+ }
533
+ if (absolute === this._rootDir) {
534
+ return '.';
535
+ }
536
+ if (absolute.startsWith(this._rootDir + '/')) {
537
+ return absolute.slice(this._rootDir.length + 1);
538
+ }
539
+ return absolute;
540
+ }
541
+ _formatRelativeTime(epochMs) {
542
+ if (!epochMs) {
543
+ return 'unknown';
544
+ }
545
+ const diff = Date.now() - epochMs;
546
+ if (diff < 60000) {
547
+ return 'just now';
548
+ }
549
+ if (diff < 3600000) {
550
+ return `${Math.floor(diff / 60000)}m ago`;
551
+ }
552
+ if (diff < 86400000) {
553
+ return `${Math.floor(diff / 3600000)}h ago`;
554
+ }
555
+ if (diff < 30 * 86400000) {
556
+ return `${Math.floor(diff / 86400000)}d ago`;
557
+ }
558
+ return new Date(epochMs).toLocaleDateString();
559
+ }
560
+ _setRefreshSpinning(on) {
561
+ if (!this._refreshBtn) {
562
+ return;
563
+ }
564
+ this._refreshBtn.classList.toggle('jp-mod-spinning', on);
565
+ }
566
+ _setActiveRow(row) {
567
+ if (this._activeRowEl && this._activeRowEl !== row) {
568
+ this._activeRowEl.classList.remove('jp-mod-active');
569
+ }
570
+ this._activeRowEl = row;
571
+ if (row) {
572
+ row.classList.add('jp-mod-active');
573
+ }
574
+ }
575
+ // -------------------------------------------------------------- ctx menu
576
+ _setupContextMenu() {
577
+ this._commands = new CommandRegistry();
578
+ this._commands.addCommand('claude-code-sessions:toggle-favourite', {
579
+ label: () => {
580
+ var _a;
581
+ return ((_a = this._activeSession) === null || _a === void 0 ? void 0 : _a.favourite)
582
+ ? 'Remove from Favourites'
583
+ : 'Add to Favourites';
584
+ },
585
+ execute: () => {
586
+ if (this._activeSession) {
587
+ void this._toggleFavourite(this._activeSession);
588
+ }
589
+ }
590
+ });
591
+ this._commands.addCommand('claude-code-sessions:resume', {
592
+ label: 'Resume in Terminal',
593
+ execute: () => {
594
+ if (this._activeSession) {
595
+ void this._resumeInTerminal(this._activeSession);
596
+ }
597
+ }
598
+ });
599
+ this._commands.addCommand('claude-code-sessions:remove', {
600
+ label: 'Remove from Claude',
601
+ icon: removeIcon,
602
+ execute: () => {
603
+ if (this._activeSession) {
604
+ void this._remove(this._activeSession);
605
+ }
606
+ }
607
+ });
608
+ this._contextMenu = new Menu({ commands: this._commands });
609
+ this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
610
+ this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
611
+ this._contextMenu.addItem({
612
+ command: 'claude-code-sessions:toggle-favourite'
613
+ });
614
+ this._contextMenu.addItem({ type: 'separator' });
615
+ this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
616
+ this._contextMenu.aboutToClose.connect(() => {
617
+ // Only clear the visual highlight - DO NOT null _activeSession.
618
+ // Lumino fires aboutToClose BEFORE the activated item's command runs,
619
+ // so the command callback still needs to read _activeSession. The
620
+ // field is overwritten on the next contextmenu open.
621
+ this._setActiveRow(null);
622
+ });
623
+ }
624
+ // --------------------------------------------------------------- polling
625
+ _startPolling() {
626
+ if (this._pollHandle !== null) {
627
+ return;
628
+ }
629
+ this._pollHandle = window.setInterval(() => {
630
+ // Don't reshuffle rows while the user is interacting with the context menu
631
+ if (this._contextMenu.isAttached) {
632
+ return;
633
+ }
634
+ this._fetch().catch(err => this._showError(err));
635
+ }, POLL_INTERVAL_MS);
636
+ }
637
+ _stopPolling() {
638
+ if (this._pollHandle !== null) {
639
+ window.clearInterval(this._pollHandle);
640
+ this._pollHandle = null;
641
+ }
642
+ }
643
+ }