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