jupyterlab_ai_code_assistants_extension 0.6.15

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.
Files changed (57) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +44 -0
  3. package/lib/core/colour.d.ts +57 -0
  4. package/lib/core/colour.js +190 -0
  5. package/lib/core/icons.d.ts +12 -0
  6. package/lib/core/icons.js +99 -0
  7. package/lib/core/modes.d.ts +13 -0
  8. package/lib/core/modes.js +36 -0
  9. package/lib/core/panel.d.ts +233 -0
  10. package/lib/core/panel.js +1673 -0
  11. package/lib/core/popup.d.ts +40 -0
  12. package/lib/core/popup.js +404 -0
  13. package/lib/core/registry.d.ts +24 -0
  14. package/lib/core/registry.js +48 -0
  15. package/lib/core/request.d.ts +25 -0
  16. package/lib/core/request.js +64 -0
  17. package/lib/core/terminals.d.ts +129 -0
  18. package/lib/core/terminals.js +344 -0
  19. package/lib/core/types.d.ts +269 -0
  20. package/lib/core/types.js +6 -0
  21. package/lib/index.d.ts +3 -0
  22. package/lib/index.js +277 -0
  23. package/lib/providers/claude.d.ts +3 -0
  24. package/lib/providers/claude.js +88 -0
  25. package/lib/providers/codex.d.ts +3 -0
  26. package/lib/providers/codex.js +79 -0
  27. package/lib/providers/gemini.d.ts +2 -0
  28. package/lib/providers/gemini.js +73 -0
  29. package/lib/providers/index.d.ts +3 -0
  30. package/lib/providers/index.js +16 -0
  31. package/lib/providers/kimi.d.ts +23 -0
  32. package/lib/providers/kimi.js +139 -0
  33. package/package.json +169 -0
  34. package/schema/plugin.json +93 -0
  35. package/src/__tests__/colour.spec.ts +153 -0
  36. package/src/__tests__/core-neutrality.spec.ts +86 -0
  37. package/src/__tests__/labels.spec.ts +89 -0
  38. package/src/__tests__/launch-mode.spec.ts +53 -0
  39. package/src/__tests__/registry.spec.ts +178 -0
  40. package/src/core/colour.ts +224 -0
  41. package/src/core/icons.ts +121 -0
  42. package/src/core/modes.ts +45 -0
  43. package/src/core/panel.ts +2020 -0
  44. package/src/core/popup.ts +475 -0
  45. package/src/core/registry.ts +60 -0
  46. package/src/core/request.ts +96 -0
  47. package/src/core/terminals.ts +446 -0
  48. package/src/core/types.ts +305 -0
  49. package/src/index.ts +356 -0
  50. package/src/providers/claude.ts +102 -0
  51. package/src/providers/codex.ts +89 -0
  52. package/src/providers/gemini.ts +80 -0
  53. package/src/providers/index.ts +21 -0
  54. package/src/providers/kimi.ts +155 -0
  55. package/style/base.css +888 -0
  56. package/style/index.css +1 -0
  57. package/style/index.js +1 -0
@@ -0,0 +1,2020 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import {
3
+ Clipboard,
4
+ Dialog,
5
+ InputDialog,
6
+ Notification,
7
+ showDialog
8
+ } from '@jupyterlab/apputils';
9
+ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
10
+ import { ServerConnection } from '@jupyterlab/services';
11
+ import { ITerminalTracker } from '@jupyterlab/terminal';
12
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
13
+ import { closeIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
14
+ import { CommandRegistry } from '@lumino/commands';
15
+ import { UUID } from '@lumino/coreutils';
16
+ import { Message } from '@lumino/messaging';
17
+ import { Menu, Widget } from '@lumino/widgets';
18
+
19
+ import { ColourStore } from './colour';
20
+ import {
21
+ addIcon,
22
+ branchIcon,
23
+ filterIcon,
24
+ providerIcon,
25
+ refreshIcon,
26
+ removeIcon,
27
+ starFilledIcon,
28
+ switchIcon,
29
+ warningIcon
30
+ } from './icons';
31
+ import { resolveLaunchMode } from './modes';
32
+ import { showManageSessionsPopup } from './popup';
33
+ import { LOG_PREFIX } from './registry';
34
+ import { isResponseStatus, requestProvider, withQuery } from './request';
35
+ import { TerminalManager } from './terminals';
36
+ import {
37
+ IBranch,
38
+ IBranchesResponse,
39
+ ICleanupResponse,
40
+ IDeleteSessionsResponse,
41
+ IFavouriteResponse,
42
+ IForkResponse,
43
+ ILaunchMode,
44
+ ILaunchRequest,
45
+ IProviderDescriptor,
46
+ IProviderHooks,
47
+ IRemoveResponse,
48
+ ISession,
49
+ ISessionsListResponse,
50
+ ISwitchResponse
51
+ } from './types';
52
+
53
+ // Rows refresh on this cadence. It is also what refreshes whatever roster the
54
+ // server caches for the context menu, so raising it past the server's cache
55
+ // horizon leaves that snapshot stale at every menu open.
56
+ const POLL_INTERVAL_MS = 30_000;
57
+ // After a fork is requested, watch for its lazily-written store entry at this
58
+ // faster cadence (bounded) so a new branch surfaces in seconds.
59
+ const BRANCH_WATCH_INTERVAL_MS = 2_000;
60
+ const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
61
+ const DEFAULT_RECENT_LIMIT = 10;
62
+ /** Conversations listed inline in a branch submenu before the popup takes over. */
63
+ const INLINE_BRANCH_LIMIT = 5;
64
+
65
+ type SectionKey = 'favourites' | 'recent' | 'all';
66
+
67
+ export type PresentationMode = 'name' | 'path';
68
+
69
+ const DEFAULT_PRESENTATION_MODE: PresentationMode = 'name';
70
+
71
+ const SECTION_LABELS: Record<SectionKey, string> = {
72
+ favourites: 'Favorites',
73
+ recent: 'Recent',
74
+ all: 'All'
75
+ };
76
+
77
+ const DEFAULT_EXPANDED: Record<SectionKey, boolean> = {
78
+ favourites: true,
79
+ recent: true,
80
+ all: true
81
+ };
82
+
83
+ /** Widget id for one provider's panel - what the layout restorer keys on. */
84
+ export function panelWidgetId(providerId: string): string {
85
+ return `jupyterlab-ai-code-assistants-${providerId}`;
86
+ }
87
+
88
+ /** Command id for one provider's action. Namespaced per provider, so two
89
+ * panels never collide in a shared registry. */
90
+ export function commandId(providerId: string, action: string): string {
91
+ return `ai-code-assistants:${providerId}:${action}`;
92
+ }
93
+
94
+ function loadExpanded(storageKey: string): Record<SectionKey, boolean> {
95
+ try {
96
+ const raw = window.localStorage.getItem(storageKey);
97
+ if (!raw) {
98
+ return { ...DEFAULT_EXPANDED };
99
+ }
100
+ const parsed = JSON.parse(raw);
101
+ const read = (key: SectionKey): boolean =>
102
+ typeof parsed?.[key] === 'boolean' ? parsed[key] : DEFAULT_EXPANDED[key];
103
+ return {
104
+ favourites: read('favourites'),
105
+ recent: read('recent'),
106
+ all: read('all')
107
+ };
108
+ } catch (_err) {
109
+ return { ...DEFAULT_EXPANDED };
110
+ }
111
+ }
112
+
113
+ function saveExpanded(
114
+ storageKey: string,
115
+ state: Record<SectionKey, boolean>
116
+ ): void {
117
+ try {
118
+ window.localStorage.setItem(storageKey, JSON.stringify(state));
119
+ } catch (_err) {
120
+ // localStorage unavailable (private mode, quota) - ignore.
121
+ }
122
+ }
123
+
124
+ /** Whether the user asked the platform for reduced motion. */
125
+ function prefersReducedMotion(): boolean {
126
+ return (
127
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
128
+ );
129
+ }
130
+
131
+ /**
132
+ * One assistant's sessions panel.
133
+ *
134
+ * Layout, filtering, polling, menus, the popup and the launch flow are shared
135
+ * by every provider. Everything that differs enters through the descriptor's
136
+ * capability flags or through the provider's hooks - this class never asks
137
+ * which assistant it is rendering.
138
+ */
139
+ export class AssistantSessionsPanel extends Widget {
140
+ constructor(options: AssistantSessionsPanel.IOptions) {
141
+ super();
142
+ this._app = options.app;
143
+ this._descriptor = options.descriptor;
144
+ this._hooks = options.hooks ?? {};
145
+ this._serverSettings = options.app.serviceManager.serverSettings;
146
+ this._rootDir = (options.rootDir || '').replace(/\/+$/, '');
147
+ this._deleteToTrash = options.deleteToTrash !== false;
148
+ this._fileBrowser = options.fileBrowser ?? null;
149
+ this._expandedKey = `jupyterlab_ai_code_assistants_extension:${this._descriptor.id}:expanded`;
150
+ this._expanded = loadExpanded(this._expandedKey);
151
+
152
+ this._colours = new ColourStore(this._descriptor.id, this._serverSettings);
153
+ this._terminals = new TerminalManager({
154
+ app: options.app,
155
+ descriptor: this._descriptor,
156
+ colourStore: this._colours,
157
+ terminalTracker: options.terminalTracker ?? null,
158
+ colourfulTabs: options.colourfulTabs ?? null,
159
+ serverSettings: this._serverSettings
160
+ });
161
+
162
+ this.id = panelWidgetId(this._descriptor.id);
163
+ this.title.icon = providerIcon(
164
+ this._descriptor.iconName,
165
+ this._descriptor.iconSvg
166
+ );
167
+ this.title.caption = this._descriptor.panelTitle;
168
+ this.addClass('jp-AiAssistantsPanel');
169
+ this.node.dataset.provider = this._descriptor.id;
170
+
171
+ this._buildShell();
172
+ this._setupCommands();
173
+ // Tinting runs from startup, not from the first time the panel is shown -
174
+ // the widget is constructed at activation whether or not the user ever
175
+ // opens the panel.
176
+ this._terminals.startColourLoop();
177
+ void this._colours.load();
178
+ }
179
+
180
+ get descriptor(): IProviderDescriptor {
181
+ return this._descriptor;
182
+ }
183
+
184
+ dispose(): void {
185
+ this._stopPolling();
186
+ // Disabling a provider must leave its running terminals alive and only
187
+ // drop the tint this panel applied.
188
+ void this._terminals.clearColours();
189
+ this._terminals.dispose();
190
+ super.dispose();
191
+ }
192
+
193
+ refresh(): void {
194
+ this._setLoading(true);
195
+ this._setRefreshSpinning(true);
196
+ // `_fetch` is filesystem-fast, so without a floor the spinner shows for a
197
+ // single frame and reads as "nothing happened". Hold it ~500 ms so the
198
+ // click visibly registers as a full re-poll - except under reduced motion,
199
+ // where an artificially prolonged spin is exactly what the preference asks
200
+ // us not to draw.
201
+ const minSpin = prefersReducedMotion()
202
+ ? Promise.resolve()
203
+ : new Promise<void>(resolve => window.setTimeout(resolve, 500));
204
+ Promise.all([
205
+ this._fetch().catch(err => this._showError(err)),
206
+ minSpin
207
+ ]).finally(() => {
208
+ this._setRefreshSpinning(false);
209
+ this._setLoading(false);
210
+ });
211
+ }
212
+
213
+ /** Label rows by name (the session name the assistant records) or by path. */
214
+ setPresentationMode(mode: PresentationMode): void {
215
+ if (this._presentationMode === mode) {
216
+ return;
217
+ }
218
+ this._presentationMode = mode;
219
+ this._render();
220
+ }
221
+
222
+ /** How many rows the Recent section displays. */
223
+ setRecentLimit(n: number): void {
224
+ const clamped = Math.max(1, Math.min(100, Math.floor(n)));
225
+ if (this._recentLimit === clamped) {
226
+ return;
227
+ }
228
+ this._recentLimit = clamped;
229
+ this._render();
230
+ }
231
+
232
+ /** Current values of this provider's launch modes, read from settings. Each
233
+ * key is an `ILaunchMode.id` in the assistant's own terminology. */
234
+ setModes(modes: Record<string, boolean | string>): void {
235
+ this._modes = { ...modes };
236
+ }
237
+
238
+ setColouredTabs(on: boolean): void {
239
+ this._terminals.setColouredTabs(on);
240
+ }
241
+
242
+ protected onAfterShow(_msg: Message): void {
243
+ this.refresh();
244
+ this._startPolling();
245
+ }
246
+
247
+ protected onBeforeHide(_msg: Message): void {
248
+ this._stopPolling();
249
+ }
250
+
251
+ protected onCloseRequest(msg: Message): void {
252
+ this._stopPolling();
253
+ super.onCloseRequest(msg);
254
+ }
255
+
256
+ // ------------------------------------------------------------------ shell
257
+
258
+ private _buildShell(): void {
259
+ const root = this.node;
260
+ root.innerHTML = '';
261
+
262
+ const header = document.createElement('div');
263
+ header.className = 'jp-AiAssistantsPanel-header';
264
+
265
+ const title = document.createElement('span');
266
+ title.className = 'jp-AiAssistantsPanel-title';
267
+ title.textContent = this._descriptor.panelTitle;
268
+ header.appendChild(title);
269
+
270
+ const newBtn = document.createElement('button');
271
+ newBtn.className = 'jp-AiAssistantsPanel-iconButton';
272
+ newBtn.title = `New ${this._descriptor.label} session in the current folder`;
273
+ addIcon.element({ container: newBtn });
274
+ newBtn.addEventListener('click', () => {
275
+ // Drop the menu just below the button, left-aligned with it.
276
+ const rect = newBtn.getBoundingClientRect();
277
+ this._newSessionMenu.open(rect.left, rect.bottom);
278
+ });
279
+ header.appendChild(newBtn);
280
+
281
+ const filterBtn = document.createElement('button');
282
+ filterBtn.className = 'jp-AiAssistantsPanel-iconButton';
283
+ filterBtn.title = 'Filter sessions';
284
+ filterIcon.element({ container: filterBtn });
285
+ filterBtn.addEventListener('click', () => this._toggleFilterBar());
286
+ header.appendChild(filterBtn);
287
+ this._filterBtn = filterBtn;
288
+
289
+ const refreshBtn = document.createElement('button');
290
+ refreshBtn.className = 'jp-AiAssistantsPanel-iconButton';
291
+ refreshBtn.title = 'Refresh';
292
+ refreshIcon.element({ container: refreshBtn });
293
+ refreshBtn.addEventListener('click', () => this.refresh());
294
+ header.appendChild(refreshBtn);
295
+ this._refreshBtn = refreshBtn;
296
+
297
+ // Input and its clear button share a wrapper so the button can sit inside
298
+ // the field's right edge. The wrapper carries the hidden toggle, so the
299
+ // button never outlives the input it belongs to.
300
+ const searchWrap = document.createElement('div');
301
+ searchWrap.className = 'jp-AiAssistantsPanel-searchWrap';
302
+ // Hidden by default; the filter button reveals it. The `hidden` attribute
303
+ // toggles `display: none` via the user-agent stylesheet, so the wrapper's
304
+ // `display: flex` must not override it (see base.css).
305
+ searchWrap.hidden = true;
306
+
307
+ const search = document.createElement('input');
308
+ search.type = 'search';
309
+ search.className = 'jp-AiAssistantsPanel-search';
310
+ search.placeholder = 'Filter sessions...';
311
+ search.setAttribute('aria-label', 'Filter sessions');
312
+ search.spellcheck = false;
313
+ search.addEventListener('input', () => {
314
+ this._filter = search.value;
315
+ this._syncSearchClear();
316
+ this._render();
317
+ });
318
+ this._searchEl = search;
319
+
320
+ const searchClear = document.createElement('button');
321
+ searchClear.className = 'jp-AiAssistantsPanel-searchClear';
322
+ searchClear.title = 'Clear filter';
323
+ searchClear.hidden = true;
324
+ closeIcon.element({ container: searchClear });
325
+ searchClear.addEventListener('click', () => {
326
+ this._filter = '';
327
+ search.value = '';
328
+ this._syncSearchClear();
329
+ this._render();
330
+ // Clearing is a step in filtering, not the end of it - keep the caret in
331
+ // the field so the next query can be typed straight away.
332
+ search.focus();
333
+ });
334
+ this._searchClearEl = searchClear;
335
+
336
+ searchWrap.appendChild(search);
337
+ searchWrap.appendChild(searchClear);
338
+ this._searchWrapEl = searchWrap;
339
+
340
+ const body = document.createElement('div');
341
+ body.className = 'jp-AiAssistantsPanel-body';
342
+
343
+ // Refresh veil, shown only during an explicit refresh. It lives on the
344
+ // root (not the body) so `_render` - which wipes the body - never removes
345
+ // it.
346
+ const loading = document.createElement('div');
347
+ loading.className = 'jp-AiAssistantsPanel-loading';
348
+ loading.hidden = true;
349
+ const loadingSpinner = document.createElement('div');
350
+ loadingSpinner.className =
351
+ 'jp-aiAssistants-spinner jp-AiAssistantsPanel-loadingSpinner';
352
+ loading.appendChild(loadingSpinner);
353
+
354
+ // Inline error strip - a failed poll reports here and keeps retrying,
355
+ // rather than tearing the panel down or raising a modal that would also
356
+ // interrupt the other providers' panels.
357
+ const error = document.createElement('div');
358
+ error.className = 'jp-AiAssistantsPanel-error';
359
+ // Announced politely, so a failure a sighted user reads in red is not
360
+ // silent for a screen-reader user.
361
+ error.setAttribute('role', 'status');
362
+ error.hidden = true;
363
+
364
+ root.appendChild(header);
365
+ root.appendChild(searchWrap);
366
+ root.appendChild(error);
367
+ root.appendChild(body);
368
+ root.appendChild(loading);
369
+
370
+ this._bodyEl = body;
371
+ this._loadingEl = loading;
372
+ this._errorEl = error;
373
+ }
374
+
375
+ /** Show / hide the filter input. Hiding also clears the active filter, so
376
+ * the user never returns to an "invisible" filter narrowing the rows. */
377
+ private _toggleFilterBar(): void {
378
+ if (!this._searchEl || !this._searchWrapEl) {
379
+ return;
380
+ }
381
+ const show = this._searchWrapEl.hidden;
382
+ this._searchWrapEl.hidden = !show;
383
+ if (this._filterBtn) {
384
+ this._filterBtn.classList.toggle('jp-mod-active', show);
385
+ }
386
+ if (show) {
387
+ this._searchEl.focus();
388
+ } else if (this._filter) {
389
+ this._filter = '';
390
+ this._searchEl.value = '';
391
+ this._syncSearchClear();
392
+ this._render();
393
+ }
394
+ }
395
+
396
+ /** Show the clear button only while the field has text - an "x" over an
397
+ * empty box is noise, and there is nothing to clear. */
398
+ private _syncSearchClear(): void {
399
+ if (this._searchClearEl) {
400
+ this._searchClearEl.hidden = !this._searchEl?.value;
401
+ }
402
+ }
403
+
404
+ /** Normalise for filter comparison: NFD-decompose, strip combining marks,
405
+ * lowercase, and collapse separators entirely - so "foo-bar", "foo_bar",
406
+ * "foo bar" and "Foo Bar" all compare equal as "foobar". */
407
+ private _normalize(s: string): string {
408
+ return s
409
+ .normalize('NFD')
410
+ .replace(/[̀-ͯ]/g, '')
411
+ .toLowerCase()
412
+ .replace(/[\s\-_./]+/g, '');
413
+ }
414
+
415
+ /** Fuzzy match at a 95% threshold: substring on normalised strings, with up
416
+ * to 5% Levenshtein tolerance. For short queries the budget rounds to zero,
417
+ * so behaviour is substring-only there; the relaxation only reaches a full
418
+ * edit at 10+ characters. */
419
+ private _fuzzyMatch(haystack: string, needle: string): boolean {
420
+ if (!needle) {
421
+ return true;
422
+ }
423
+ const h = this._normalize(haystack);
424
+ const n = this._normalize(needle);
425
+ if (!n) {
426
+ return true;
427
+ }
428
+ if (h.includes(n)) {
429
+ return true;
430
+ }
431
+ const tol = Math.round(n.length * 0.05);
432
+ if (tol === 0) {
433
+ return false;
434
+ }
435
+ for (let len = n.length - tol; len <= n.length + tol; len += 1) {
436
+ if (len <= 0) {
437
+ continue;
438
+ }
439
+ for (let i = 0; i + len <= h.length; i += 1) {
440
+ if (this._levenshtein(h.slice(i, i + len), n) <= tol) {
441
+ return true;
442
+ }
443
+ }
444
+ }
445
+ return false;
446
+ }
447
+
448
+ private _levenshtein(a: string, b: string): number {
449
+ const m = a.length;
450
+ const n = b.length;
451
+ if (m === 0) {
452
+ return n;
453
+ }
454
+ if (n === 0) {
455
+ return m;
456
+ }
457
+ const dp: number[] = new Array(n + 1);
458
+ for (let j = 0; j <= n; j += 1) {
459
+ dp[j] = j;
460
+ }
461
+ for (let i = 1; i <= m; i += 1) {
462
+ let prev = dp[0];
463
+ dp[0] = i;
464
+ for (let j = 1; j <= n; j += 1) {
465
+ const tmp = dp[j];
466
+ dp[j] =
467
+ a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
468
+ prev = tmp;
469
+ }
470
+ }
471
+ return dp[n];
472
+ }
473
+
474
+ private _matchesFilter(s: ISession): boolean {
475
+ const q = this._filter.trim();
476
+ if (!q) {
477
+ return true;
478
+ }
479
+ return (
480
+ this._fuzzyMatch(s.name, q) ||
481
+ this._fuzzyMatch(s.project_path, q) ||
482
+ this._fuzzyMatch(this._lookupName(s), q)
483
+ );
484
+ }
485
+
486
+ /** Raise or clear the full-panel refresh veil. Only the explicit refresh
487
+ * path calls this; the background poll fetches silently so the panel never
488
+ * flashes a spinner on its own. */
489
+ private _setLoading(on: boolean): void {
490
+ if (this._loadingEl) {
491
+ this._loadingEl.hidden = !on;
492
+ }
493
+ }
494
+
495
+ /** Inline, per-panel error. One provider's unreachable server must leave the
496
+ * other panels alone, so nothing here is modal. */
497
+ private _setInlineError(message: string | null): void {
498
+ if (!this._errorEl) {
499
+ return;
500
+ }
501
+ this._errorEl.textContent = message ?? '';
502
+ this._errorEl.hidden = !message;
503
+ }
504
+
505
+ private _showError(err: unknown): void {
506
+ const message = err instanceof Error ? err.message : String(err);
507
+ this._logError(message);
508
+ this._setInlineError(`Could not reach the server - retrying. ${message}`);
509
+ }
510
+
511
+ /** A one-shot action failed. Nothing retries it, so the copy names what did
512
+ * not happen instead of promising a retry the poll loop alone performs. */
513
+ private _showActionError(copy: string, err: unknown): void {
514
+ const message = err instanceof Error ? err.message : String(err);
515
+ this._logError(message);
516
+ this._setInlineError(`${copy} ${message}`);
517
+ }
518
+
519
+ private _logError(message: string): void {
520
+ console.error(`${LOG_PREFIX}[${this._descriptor.id}]`, message);
521
+ }
522
+
523
+ // ------------------------------------------------------------------- data
524
+
525
+ private async _fetch(): Promise<void> {
526
+ // `cache: 'no-store'` so a manual refresh always re-reads the server's
527
+ // view rather than a possibly stale browser-cached response.
528
+ const data = await requestProvider<ISessionsListResponse>(
529
+ this._descriptor.id,
530
+ 'sessions',
531
+ this._serverSettings,
532
+ { cache: 'no-store' }
533
+ );
534
+ this._sessions = data.sessions ?? [];
535
+ this._setInlineError(null);
536
+ this._terminals.setSessions(this._sessions);
537
+ this._render();
538
+ // Best-effort tinting; never let a colour failure break the fetch.
539
+ void this._colours.load().then(() => this._terminals.reconcileColours());
540
+ }
541
+
542
+ private async _toggleFavourite(session: ISession): Promise<void> {
543
+ const next = !session.favourite;
544
+ session.favourite = next; // optimistic
545
+ this._render();
546
+ try {
547
+ await requestProvider<IFavouriteResponse>(
548
+ this._descriptor.id,
549
+ 'favourite',
550
+ this._serverSettings,
551
+ {
552
+ method: 'POST',
553
+ body: JSON.stringify({
554
+ project_path: session.project_path,
555
+ favourite: next
556
+ })
557
+ }
558
+ );
559
+ } catch (err) {
560
+ session.favourite = !next; // roll back
561
+ this._render();
562
+ this._showActionError('Could not toggle favourite - try again.', err);
563
+ }
564
+ }
565
+
566
+ private get _disposalVerb(): string {
567
+ return this._deleteToTrash ? 'moved to trash' : 'deleted permanently';
568
+ }
569
+
570
+ private async _removeProject(session: ISession): Promise<void> {
571
+ const name = this._lookupName(session);
572
+ const confirm = await showDialog({
573
+ title: `Remove from ${this._descriptor.label}`,
574
+ body:
575
+ `Remove "${name}" from ${this._descriptor.label}? This drops the ` +
576
+ `entire project history and every conversation it holds - ` +
577
+ `${this._disposalVerb}. This cannot be undone.`,
578
+ buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Remove' })]
579
+ });
580
+ if (!confirm.button.accept) {
581
+ return;
582
+ }
583
+
584
+ this._removingPaths.add(session.encoded_path);
585
+ this._render();
586
+ try {
587
+ await requestProvider<IRemoveResponse>(
588
+ this._descriptor.id,
589
+ 'sessions',
590
+ this._serverSettings,
591
+ {
592
+ method: 'DELETE',
593
+ body: JSON.stringify({ encoded_path: session.encoded_path })
594
+ }
595
+ );
596
+ // Drop locally; the next poll reconciles against disk.
597
+ this._sessions = (this._sessions ?? []).filter(
598
+ s => s.encoded_path !== session.encoded_path
599
+ );
600
+ } catch (err) {
601
+ this._showActionError(`Could not remove "${name}" - try again.`, err);
602
+ } finally {
603
+ this._removingPaths.delete(session.encoded_path);
604
+ this._render();
605
+ }
606
+ }
607
+
608
+ private async _cleanupParallel(session: ISession): Promise<void> {
609
+ const extra = session.extra_sessions;
610
+ const name = this._lookupName(session);
611
+ const confirm = await showDialog({
612
+ title: 'Clean Up Parallel Sessions',
613
+ body:
614
+ `Remove ${extra} parallel session${extra === 1 ? '' : 's'} from ` +
615
+ `"${name}"? The current conversation is kept; the rest are ` +
616
+ `${this._disposalVerb}. This cannot be undone.`,
617
+ buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Remove' })]
618
+ });
619
+ if (!confirm.button.accept) {
620
+ return;
621
+ }
622
+
623
+ const body = new Widget();
624
+ body.node.className = 'jp-AiAssistantsPanel-cleanupBody';
625
+
626
+ const message = document.createElement('div');
627
+ message.className = 'jp-AiAssistantsPanel-cleanupMessage';
628
+ message.textContent = `Removing ${extra} parallel session${
629
+ extra === 1 ? '' : 's'
630
+ }...`;
631
+ body.node.appendChild(message);
632
+
633
+ // No `value` attribute -> indeterminate (animated) while the request is in
634
+ // flight; set to max on completion so the bar reads as finished.
635
+ const bar = document.createElement('progress');
636
+ bar.className = 'jp-AiAssistantsPanel-cleanupProgress';
637
+ bar.max = 1;
638
+ body.node.appendChild(bar);
639
+
640
+ const dialog = new Dialog<unknown>({
641
+ title: 'Clean Up Parallel Sessions',
642
+ body,
643
+ buttons: [Dialog.okButton({ label: 'Close' })]
644
+ });
645
+ // Hide the Close button while work is in progress; restore it once the
646
+ // outcome is shown so the user can dismiss the popup.
647
+ const footer = dialog.node.querySelector(
648
+ '.jp-Dialog-footer'
649
+ ) as HTMLElement | null;
650
+ if (footer) {
651
+ footer.style.display = 'none';
652
+ }
653
+ void dialog.launch();
654
+
655
+ try {
656
+ // The extras ARE the branch list - the current conversation is not in
657
+ // it - so cleaning up is deleting exactly those ids.
658
+ const listed = await this._fetchBranches(session);
659
+ const ids = listed.branches.map(b => b.session_id);
660
+ const data = await requestProvider<ICleanupResponse>(
661
+ this._descriptor.id,
662
+ 'sessions',
663
+ this._serverSettings,
664
+ {
665
+ method: 'DELETE',
666
+ body: JSON.stringify({
667
+ encoded_path: session.encoded_path,
668
+ session_ids: ids
669
+ })
670
+ }
671
+ );
672
+ // Only the conversations that ACTUALLY went lose their colour - one the
673
+ // server could not delete keeps its tint.
674
+ await this._colours.forget(data.removed_ids ?? ids);
675
+ bar.value = 1;
676
+ message.textContent = `Removed ${data.removed_count} parallel session${
677
+ data.removed_count === 1 ? '' : 's'
678
+ }.`;
679
+ // Refresh so the row's conversation count and menu label update.
680
+ await this._fetch();
681
+ } catch (err) {
682
+ bar.remove();
683
+ message.classList.add('jp-AiAssistantsPanel-cleanupError');
684
+ message.textContent = `Cleanup failed: ${
685
+ err instanceof Error ? err.message : String(err)
686
+ }`;
687
+ this._showError(err);
688
+ } finally {
689
+ if (footer) {
690
+ footer.style.display = '';
691
+ }
692
+ }
693
+ }
694
+
695
+ // ---------------------------------------------------------------- launch
696
+
697
+ /** This provider's launch-mode token for one action - see
698
+ * `resolveLaunchMode` for the precedence rule. */
699
+ private _launchMode(force?: string): string | undefined {
700
+ return resolveLaunchMode(this._descriptor.launchModes, this._modes, force);
701
+ }
702
+
703
+ private async _openSession(
704
+ session: ISession,
705
+ force?: string,
706
+ sessionId?: string
707
+ ): Promise<void> {
708
+ try {
709
+ await this._terminals.openSession(
710
+ session,
711
+ this._launchMode(force),
712
+ sessionId
713
+ );
714
+ } catch (err) {
715
+ if (isResponseStatus(err, 404)) {
716
+ Notification.error(
717
+ 'That conversation no longer exists - the list has been refreshed.',
718
+ { autoClose: 4000 }
719
+ );
720
+ } else {
721
+ this._showError(err);
722
+ }
723
+ } finally {
724
+ // Reuse or fresh launch, either way the picture changed. Pull fresh
725
+ // state; a poll tick retries if this one fails.
726
+ void this._fetch().catch(() => undefined);
727
+ }
728
+ }
729
+
730
+ /** Absolute path of the file browser's current folder; the server root when
731
+ * no file browser is available. */
732
+ private _currentFolder(): string {
733
+ const rel = (this._fileBrowser?.model?.path ?? '').replace(/^\/+/, '');
734
+ return rel ? `${this._rootDir}/${rel}` : this._rootDir;
735
+ }
736
+
737
+ /** Start a brand-new conversation in the file browser's current folder. */
738
+ private async _newSession(force?: string): Promise<void> {
739
+ const projectPath = this._currentFolder();
740
+ if (!projectPath) {
741
+ return;
742
+ }
743
+ // Mint the id here when the assistant accepts one, so the terminal is
744
+ // identifiable from its argv and a later click refocuses this exact
745
+ // conversation instead of guessing from an unknown terminal.
746
+ const newId = this._descriptor.mintsNewSessionId ? UUID.uuid4() : undefined;
747
+ // A project that already has a row may carry a branch pin; sending its
748
+ // store key lets the server clear that pin so the new conversation is not
749
+ // shadowed by a stale one (DEF-9).
750
+ const existing = (this._sessions ?? []).find(
751
+ s => s.project_path === projectPath
752
+ );
753
+ try {
754
+ await this._terminals.launch(
755
+ {
756
+ project_path: projectPath,
757
+ encoded_path: existing?.encoded_path,
758
+ new_session_id: newId,
759
+ mode: this._launchMode(force)
760
+ },
761
+ newId
762
+ );
763
+ } catch (err) {
764
+ this._showError(err);
765
+ } finally {
766
+ void this._fetch().catch(() => undefined);
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Branch the active row's current conversation, by the descriptor's
772
+ * `forkStrategy`.
773
+ *
774
+ * The three strategies differ only in who mints the new id and when it
775
+ * becomes knowable - so the shared part (ask for a name, launch a terminal
776
+ * on the fork, inherit the parent's tint, surface the branch) lives here
777
+ * once, and the strategy decides the payload and the discovery.
778
+ */
779
+ private async _branchSession(force?: string): Promise<void> {
780
+ const session = this._activeSession;
781
+ if (!session) {
782
+ return;
783
+ }
784
+ const parentColour = this._terminals.colourFor(session);
785
+
786
+ let name: string | undefined;
787
+ if (this._descriptor.namingStrategy !== 'none') {
788
+ const named = await InputDialog.getText({
789
+ title: 'Branch Session',
790
+ label: 'Name for the new session',
791
+ placeholder: this._lookupName(session)
792
+ });
793
+ if (!named.button.accept || !named.value || !named.value.trim()) {
794
+ return;
795
+ }
796
+ name = named.value.trim();
797
+ }
798
+
799
+ if (this._descriptor.forkStrategy === 'server-copy') {
800
+ await this._branchByServerCopy(session, name, force, parentColour);
801
+ return;
802
+ }
803
+ if (this._descriptor.forkStrategy === 'native-command') {
804
+ await this._branchByNativeCommand(session, force, parentColour);
805
+ return;
806
+ }
807
+ await this._branchByNativeFlag(session, name, force, parentColour);
808
+ }
809
+
810
+ /** The CLI forks from a flag pair and takes the new id from us, so the fork
811
+ * is known before it exists. Its store entry is written lazily, on the first
812
+ * turn, so the branch is watched for rather than expected at once. */
813
+ private async _branchByNativeFlag(
814
+ session: ISession,
815
+ name: string | undefined,
816
+ force: string | undefined,
817
+ parentColour: string | null
818
+ ): Promise<void> {
819
+ const forkId = UUID.uuid4();
820
+ const request: ILaunchRequest = {
821
+ project_path: session.project_path,
822
+ // Arms the server's fork pin - without it the fork never becomes the
823
+ // row's current conversation and recency drags the row back (DEF-9).
824
+ encoded_path: session.encoded_path,
825
+ session_id: session.session_id,
826
+ fork_session_id: forkId,
827
+ name,
828
+ mode: this._launchMode(force)
829
+ };
830
+ try {
831
+ await this._terminals.launch(request, forkId);
832
+ } catch (err) {
833
+ this._showActionError('Could not branch this session - try again.', err);
834
+ return;
835
+ }
836
+ await this._inheritColour(parentColour, forkId);
837
+ this._watchForBranch(session.encoded_path, forkId);
838
+ }
839
+
840
+ /** The CLI owns both the fork verb and the id, so the id has to be
841
+ * discovered: snapshot the branch ids first, then diff after launch. */
842
+ private async _branchByNativeCommand(
843
+ session: ISession,
844
+ force: string | undefined,
845
+ parentColour: string | null
846
+ ): Promise<void> {
847
+ const known = new Set(
848
+ (await this._fetchBranches(session).catch(() => null))?.branches.map(
849
+ b => b.session_id
850
+ ) ?? []
851
+ );
852
+ known.add(session.session_id);
853
+ try {
854
+ await this._terminals.launch({
855
+ project_path: session.project_path,
856
+ fork_from: session.session_id,
857
+ mode: this._launchMode(force)
858
+ });
859
+ } catch (err) {
860
+ this._showError(err);
861
+ return;
862
+ }
863
+ this._watchForNewBranch(session, known, parentColour);
864
+ void this._fetch().catch(() => undefined);
865
+ }
866
+
867
+ /** The assistant has no fork at all: the server copies the store entry and
868
+ * answers with the new id, so the branch exists before the terminal opens
869
+ * and one refresh surfaces it - no watcher needed. */
870
+ private async _branchByServerCopy(
871
+ session: ISession,
872
+ name: string | undefined,
873
+ force: string | undefined,
874
+ parentColour: string | null
875
+ ): Promise<void> {
876
+ try {
877
+ const fork = await requestProvider<IForkResponse>(
878
+ this._descriptor.id,
879
+ 'branch',
880
+ this._serverSettings,
881
+ {
882
+ method: 'POST',
883
+ body: JSON.stringify({
884
+ encoded_path: session.encoded_path,
885
+ session_id: session.session_id,
886
+ name
887
+ })
888
+ }
889
+ );
890
+ await this._inheritColour(parentColour, fork.session_id);
891
+ await this._terminals.launch(
892
+ {
893
+ project_path: session.project_path,
894
+ session_id: fork.session_id,
895
+ mode: this._launchMode(force)
896
+ },
897
+ fork.session_id
898
+ );
899
+ } catch (err) {
900
+ if (isResponseStatus(err, 400)) {
901
+ Notification.error(
902
+ `${this._descriptor.label} cannot branch this conversation.`,
903
+ { autoClose: 4000 }
904
+ );
905
+ } else {
906
+ this._showError(err);
907
+ }
908
+ return;
909
+ }
910
+ await this._fetch().catch(() => undefined);
911
+ }
912
+
913
+ /** Copy the parent's tint onto a fresh branch, unless the assistant owns
914
+ * conversation colours itself: that store refuses every write (the server
915
+ * answers 400), so the call would be one guaranteed-failing request per fork
916
+ * plus a tint that shows until the next reconcile drops it again. */
917
+ private async _inheritColour(
918
+ parentColour: string | null,
919
+ childId: string
920
+ ): Promise<void> {
921
+ if (this._descriptor.colourSource === 'native') {
922
+ return;
923
+ }
924
+ await this._colours.inherit(parentColour, childId);
925
+ }
926
+
927
+ /** Poll fast for a fork whose id we already know, then refresh once. The
928
+ * panel is never updated before the branch genuinely exists - it cannot list
929
+ * a store entry that has not been written. */
930
+ private _watchForBranch(encodedPath: string, forkId: string): void {
931
+ let attempts = 0;
932
+ const tick = async (): Promise<void> => {
933
+ // dispose() can land between an await and this callback; the widget must
934
+ // not keep polling off a dead object.
935
+ if (this.isDisposed) {
936
+ return;
937
+ }
938
+ attempts += 1;
939
+ try {
940
+ const data = await requestProvider<IBranchesResponse>(
941
+ this._descriptor.id,
942
+ withQuery('branches', { encoded_path: encodedPath }),
943
+ this._serverSettings,
944
+ { cache: 'no-store' }
945
+ );
946
+ if (
947
+ data.current === forkId ||
948
+ data.branches.some(b => b.session_id === forkId)
949
+ ) {
950
+ await this._fetch();
951
+ return;
952
+ }
953
+ } catch {
954
+ // Transient - keep watching until the attempt budget runs out.
955
+ }
956
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
957
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
958
+ }
959
+ };
960
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
961
+ }
962
+
963
+ /** Poll fast for a fork whose id the CLI minted: whichever conversation the
964
+ * snapshot did not hold is the fork. It is then pinned as current (recency
965
+ * alone would not - the actively written parent overtakes it) and inherits
966
+ * the parent's tint. */
967
+ private _watchForNewBranch(
968
+ session: ISession,
969
+ known: Set<string>,
970
+ parentColour: string | null
971
+ ): void {
972
+ let attempts = 0;
973
+ const tick = async (): Promise<void> => {
974
+ if (this.isDisposed) {
975
+ return;
976
+ }
977
+ attempts += 1;
978
+ try {
979
+ const data = await this._fetchBranches(session);
980
+ const fresh = data.branches.find(b => !known.has(b.session_id));
981
+ if (fresh) {
982
+ await this._inheritColour(parentColour, fresh.session_id);
983
+ await this._switchBranch(session, fresh.session_id);
984
+ return;
985
+ }
986
+ } catch {
987
+ // Transient - keep watching.
988
+ }
989
+ if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
990
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
991
+ }
992
+ };
993
+ window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
994
+ }
995
+
996
+ /** Make another conversation the row's current one. */
997
+ private async _switchBranch(
998
+ session: ISession,
999
+ sessionId: string
1000
+ ): Promise<void> {
1001
+ try {
1002
+ const result = await requestProvider<ISwitchResponse>(
1003
+ this._descriptor.id,
1004
+ 'switch',
1005
+ this._serverSettings,
1006
+ {
1007
+ method: 'POST',
1008
+ body: JSON.stringify({
1009
+ encoded_path: session.encoded_path,
1010
+ session_id: sessionId
1011
+ })
1012
+ }
1013
+ );
1014
+ if (result.current !== result.requested) {
1015
+ Notification.warning(
1016
+ 'That conversation cannot become current - its recorded folder does not match the project.',
1017
+ { autoClose: 4000 }
1018
+ );
1019
+ }
1020
+ } catch (err) {
1021
+ Notification.error(
1022
+ isResponseStatus(err, 404)
1023
+ ? 'That conversation no longer exists - the list has been refreshed.'
1024
+ : `Switch failed: ${err}`,
1025
+ { autoClose: 4000 }
1026
+ );
1027
+ } finally {
1028
+ // Best-effort refresh - never let a transient failure reject this
1029
+ // fire-and-forget switch (callers do not await it).
1030
+ await this._fetch().catch(() => undefined);
1031
+ }
1032
+ }
1033
+
1034
+ /** Delete conversations of the active row's project. Resolves with the
1035
+ * removed count, or null on a failure already reported to the user. */
1036
+ private async _deleteBranches(
1037
+ session: ISession,
1038
+ sessionIds: string[]
1039
+ ): Promise<number | null> {
1040
+ try {
1041
+ const result = await requestProvider<IDeleteSessionsResponse>(
1042
+ this._descriptor.id,
1043
+ 'sessions',
1044
+ this._serverSettings,
1045
+ {
1046
+ method: 'DELETE',
1047
+ body: JSON.stringify({
1048
+ encoded_path: session.encoded_path,
1049
+ session_ids: sessionIds
1050
+ })
1051
+ }
1052
+ );
1053
+ // A deleted conversation leaves no colour entry behind - but only the
1054
+ // ids the server says it removed, so a refused delete keeps its tint.
1055
+ await this._colours.forget(result.removed_ids ?? sessionIds);
1056
+ return result.removed_count;
1057
+ } catch (err) {
1058
+ Notification.error(`Delete failed: ${String(err)}`, { autoClose: 4000 });
1059
+ return null;
1060
+ } finally {
1061
+ await this._fetch().catch(() => undefined);
1062
+ }
1063
+ }
1064
+
1065
+ private async _fetchBranches(session: ISession): Promise<IBranchesResponse> {
1066
+ const params: Record<string, string> = {
1067
+ encoded_path: session.encoded_path
1068
+ };
1069
+ // Providers whose conversations can be held by a live worker need that
1070
+ // resolved in the same round trip - the menu opens on this response. The
1071
+ // name is the server's own (`include_extras`); a core flag that spelled it
1072
+ // otherwise was dead on the wire.
1073
+ if (this._descriptor.hasBgAgents) {
1074
+ params.include_extras = '1';
1075
+ }
1076
+ return requestProvider<IBranchesResponse>(
1077
+ this._descriptor.id,
1078
+ withQuery('branches', params),
1079
+ this._serverSettings,
1080
+ { cache: 'no-store' }
1081
+ );
1082
+ }
1083
+
1084
+ // -------------------------------------------------------------- rendering
1085
+
1086
+ /** Apply the presentation-mode setting. Basename collisions in name mode are
1087
+ * resolved separately in `_disambiguate`. */
1088
+ private _displayName(s: ISession): string {
1089
+ const folder = this._basename(s.project_path) || s.encoded_path;
1090
+ if (this._presentationMode === 'path') {
1091
+ return this._displayPath(s.project_path) || folder;
1092
+ }
1093
+ // Honour the session name the assistant records (e.g. after a rename);
1094
+ // fall back to the folder basename when there is none.
1095
+ if (s.name_source === 'session' && s.name) {
1096
+ return s.name;
1097
+ }
1098
+ return folder;
1099
+ }
1100
+
1101
+ private _basename(p: string): string {
1102
+ if (!p) {
1103
+ return '';
1104
+ }
1105
+ const parts = p.split('/').filter(Boolean);
1106
+ return parts[parts.length - 1] || '';
1107
+ }
1108
+
1109
+ /** Walk path tails until every name in a colliding group is unique. Name-mode
1110
+ * labels are folder basenames, so two different projects can carry the same
1111
+ * label; each colliding label is extended with as much of its parent path as
1112
+ * it takes to tell them apart. */
1113
+ private _disambiguate(rows: ISession[]): Map<string, string> {
1114
+ const out = new Map<string, string>();
1115
+ const groups = new Map<string, ISession[]>();
1116
+ for (const r of rows) {
1117
+ const n = this._displayName(r);
1118
+ groups.set(n, (groups.get(n) ?? []).concat(r));
1119
+ }
1120
+ for (const [name, group] of groups.entries()) {
1121
+ if (group.length === 1) {
1122
+ out.set(group[0].project_path, name);
1123
+ continue;
1124
+ }
1125
+ const segs = group.map(r => r.project_path.split('/').filter(Boolean));
1126
+ const max = Math.max(...segs.map(s => s.length));
1127
+ let depth = 1;
1128
+ let resolved = false;
1129
+ while (depth <= max) {
1130
+ const tails = segs.map(s => s.slice(-depth).join('/'));
1131
+ if (new Set(tails).size === tails.length) {
1132
+ group.forEach((r, i) => out.set(r.project_path, tails[i]));
1133
+ resolved = true;
1134
+ break;
1135
+ }
1136
+ depth += 1;
1137
+ }
1138
+ if (!resolved) {
1139
+ // Identical project paths across rows should not happen, but if they
1140
+ // ever do, the absolute path keeps rows distinguishable.
1141
+ group.forEach(r => out.set(r.project_path, r.project_path));
1142
+ }
1143
+ }
1144
+ return out;
1145
+ }
1146
+
1147
+ private _render(): void {
1148
+ const sessions = this._sessions ?? [];
1149
+
1150
+ // Capture scrollTop per section so a polling refresh does not reset the
1151
+ // user's place. The body scrolls too in short windows - clearing innerHTML
1152
+ // clamps its scrollTop to 0, so it needs the same capture and restore.
1153
+ const bodyScroll = this._bodyEl.scrollTop;
1154
+ const scrolls = new Map<string, number>();
1155
+ this._bodyEl
1156
+ .querySelectorAll<HTMLElement>('.jp-AiAssistantsPanel-section')
1157
+ .forEach(sect => {
1158
+ const key = sect.dataset.section;
1159
+ const list = sect.querySelector<HTMLElement>(
1160
+ '.jp-AiAssistantsPanel-list'
1161
+ );
1162
+ if (key && list) {
1163
+ scrolls.set(key, list.scrollTop);
1164
+ }
1165
+ });
1166
+
1167
+ this._bodyEl.innerHTML = '';
1168
+
1169
+ if (sessions.length === 0) {
1170
+ const empty = document.createElement('div');
1171
+ empty.className = 'jp-AiAssistantsPanel-empty';
1172
+ empty.textContent = `No ${this._descriptor.label} sessions found.`;
1173
+ this._bodyEl.appendChild(empty);
1174
+ // The first-run state has to name the next step - the header's + button
1175
+ // is the only way out of it.
1176
+ const hint = document.createElement('div');
1177
+ hint.className = 'jp-AiAssistantsPanel-empty';
1178
+ hint.textContent = 'Use + to start a session in the current folder.';
1179
+ this._bodyEl.appendChild(hint);
1180
+ return;
1181
+ }
1182
+
1183
+ // Disambiguated names are computed once per render against the FULL set,
1184
+ // so suffixes stay stable when a filter narrows the view.
1185
+ this._displayNames = this._disambiguate(sessions);
1186
+
1187
+ const filtered = sessions.filter(s => this._matchesFilter(s));
1188
+ const favourites = filtered.filter(s => s.favourite);
1189
+ const recent = [...filtered]
1190
+ .sort((a, b) => b.file_mtime - a.file_mtime)
1191
+ .slice(0, this._recentLimit);
1192
+ const all = [...filtered].sort((a, b) =>
1193
+ this._lookupName(a).localeCompare(this._lookupName(b))
1194
+ );
1195
+
1196
+ if (favourites.length > 0) {
1197
+ this._renderSection('favourites', favourites);
1198
+ }
1199
+ this._renderSection('recent', recent);
1200
+ this._renderSection('all', all);
1201
+
1202
+ this._bodyEl
1203
+ .querySelectorAll<HTMLElement>('.jp-AiAssistantsPanel-section')
1204
+ .forEach(sect => {
1205
+ const key = sect.dataset.section;
1206
+ const list = sect.querySelector<HTMLElement>(
1207
+ '.jp-AiAssistantsPanel-list'
1208
+ );
1209
+ const saved = key ? scrolls.get(key) : undefined;
1210
+ if (list && saved !== undefined) {
1211
+ list.scrollTop = saved;
1212
+ }
1213
+ });
1214
+ this._bodyEl.scrollTop = bodyScroll;
1215
+ }
1216
+
1217
+ private _renderSection(key: SectionKey, items: ISession[]): void {
1218
+ const section = document.createElement('div');
1219
+ section.className = 'jp-AiAssistantsPanel-section';
1220
+ section.dataset.section = key;
1221
+ const expanded = this._expanded[key];
1222
+
1223
+ const header = document.createElement('button');
1224
+ header.className = 'jp-AiAssistantsPanel-sectionHeader';
1225
+ header.setAttribute('aria-expanded', String(expanded));
1226
+
1227
+ const caret = document.createElement('span');
1228
+ caret.className = 'jp-AiAssistantsPanel-caret';
1229
+ caret.textContent = expanded ? '▾' : '▸';
1230
+ // Decorative - the state it draws is already on the header's aria-expanded.
1231
+ caret.setAttribute('aria-hidden', 'true');
1232
+ header.appendChild(caret);
1233
+
1234
+ const label = document.createElement('span');
1235
+ label.className = 'jp-AiAssistantsPanel-sectionLabel';
1236
+ label.textContent = `${SECTION_LABELS[key]} (${items.length})`;
1237
+ header.appendChild(label);
1238
+
1239
+ header.addEventListener('click', () => {
1240
+ this._expanded[key] = !this._expanded[key];
1241
+ saveExpanded(this._expandedKey, this._expanded);
1242
+ this._render();
1243
+ });
1244
+ section.appendChild(header);
1245
+
1246
+ if (expanded) {
1247
+ const list = document.createElement('div');
1248
+ list.className = 'jp-AiAssistantsPanel-list';
1249
+ if (items.length === 0) {
1250
+ const empty = document.createElement('div');
1251
+ empty.className = 'jp-AiAssistantsPanel-emptySection';
1252
+ // A filter that hides everything and a genuinely empty section are
1253
+ // different states, and only the first one has a way out.
1254
+ empty.textContent = this._filter.trim()
1255
+ ? 'No matches.'
1256
+ : key === 'favourites'
1257
+ ? 'No favorites yet.'
1258
+ : 'Empty.';
1259
+ list.appendChild(empty);
1260
+ } else {
1261
+ for (const item of items) {
1262
+ list.appendChild(this._renderRow(item, key));
1263
+ }
1264
+ }
1265
+ section.appendChild(list);
1266
+ }
1267
+
1268
+ this._bodyEl.appendChild(section);
1269
+ }
1270
+
1271
+ private _renderRow(
1272
+ session: ISession,
1273
+ sectionKey: SectionKey
1274
+ ): HTMLDivElement {
1275
+ const row = document.createElement('div');
1276
+ row.className = 'jp-AiAssistantsPanel-row';
1277
+ row.title = this._buildRowTooltip(session);
1278
+ // The row IS the primary control - without these it is reachable by mouse
1279
+ // only, and so is the context menu that carries every other action.
1280
+ row.setAttribute('role', 'button');
1281
+ row.tabIndex = 0;
1282
+
1283
+ // Age emphasis: active within the last minute reads bright, idle over a
1284
+ // week dims; both decay or promote on the next refresh.
1285
+ if (session.file_mtime) {
1286
+ const age = Date.now() - session.file_mtime;
1287
+ if (age < 60_000) {
1288
+ row.classList.add('jp-mod-recentlyActive');
1289
+ } else if (age > 7 * 86_400_000) {
1290
+ row.classList.add('jp-mod-stale');
1291
+ }
1292
+ }
1293
+
1294
+ const removing = this._removingPaths.has(session.encoded_path);
1295
+ if (removing) {
1296
+ row.classList.add('jp-mod-busy');
1297
+ const spinner = document.createElement('span');
1298
+ spinner.className = 'jp-AiAssistantsPanel-spinner';
1299
+ spinner.title = 'Removing...';
1300
+ row.appendChild(spinner);
1301
+ } else {
1302
+ row.appendChild(this._renderIndicator(session));
1303
+ }
1304
+
1305
+ const name = document.createElement('span');
1306
+ name.className = 'jp-AiAssistantsPanel-name';
1307
+ name.textContent = this._lookupName(session);
1308
+ // Branch icon plus total conversation count, only when the project holds
1309
+ // more than one. Inside the name span so it hugs the label text instead of
1310
+ // being flexed to the row's right edge.
1311
+ if (session.extra_sessions > 0) {
1312
+ const badge = document.createElement('span');
1313
+ badge.className = 'jp-AiAssistantsPanel-branchBadge';
1314
+ const icon = document.createElement('span');
1315
+ icon.className = 'jp-AiAssistantsPanel-branchBadgeIcon';
1316
+ branchIcon.element({ container: icon });
1317
+ badge.appendChild(icon);
1318
+ badge.appendChild(
1319
+ document.createTextNode(String(session.extra_sessions + 1))
1320
+ );
1321
+ name.appendChild(badge);
1322
+ }
1323
+ // A live worker owns this conversation, so a click joins it rather than
1324
+ // resuming a dormant one. Marked so the difference is visible before the
1325
+ // click, not a surprise after it. No own title: a child title shadows the
1326
+ // row's, which would hide the path behind a two-character chip.
1327
+ if (this._descriptor.hasBgAgents && session.bg_id) {
1328
+ const bg = document.createElement('span');
1329
+ bg.className = 'jp-AiAssistantsPanel-bgBadge';
1330
+ bg.textContent = 'bg';
1331
+ name.appendChild(bg);
1332
+ }
1333
+ row.appendChild(name);
1334
+
1335
+ // No star in the Favorites section - every row there is one by definition.
1336
+ // The star sits before the time so the fixed-width time column stays the
1337
+ // rightmost alignment anchor across all rows.
1338
+ if (session.favourite && sectionKey !== 'favourites') {
1339
+ const star = document.createElement('span');
1340
+ star.className = 'jp-AiAssistantsPanel-favStar';
1341
+ star.title = 'Favorite';
1342
+ starFilledIcon.element({ container: star });
1343
+ row.appendChild(star);
1344
+ }
1345
+
1346
+ // Always present (empty without an mtime) so the star column keeps the
1347
+ // same anchor across every row in the panel.
1348
+ const time = document.createElement('span');
1349
+ time.className = 'jp-AiAssistantsPanel-rowTime';
1350
+ time.textContent = session.file_mtime
1351
+ ? this._formatRelativeTime(session.file_mtime)
1352
+ : '';
1353
+ row.appendChild(time);
1354
+
1355
+ row.addEventListener('click', () => {
1356
+ if (removing) {
1357
+ return;
1358
+ }
1359
+ void this._openSession(session);
1360
+ });
1361
+ row.addEventListener('contextmenu', e => {
1362
+ e.preventDefault();
1363
+ if (removing) {
1364
+ return;
1365
+ }
1366
+ this._activeSession = session;
1367
+ this._setActiveRow(row);
1368
+ void this._openContextMenu(session, e.clientX, e.clientY);
1369
+ });
1370
+ row.addEventListener('keydown', e => {
1371
+ if (removing) {
1372
+ return;
1373
+ }
1374
+ if (e.key === 'Enter' || e.key === ' ') {
1375
+ // Space would scroll the list otherwise.
1376
+ e.preventDefault();
1377
+ void this._openSession(session);
1378
+ return;
1379
+ }
1380
+ // The two keyboard routes to a context menu. Anchored on the row's own
1381
+ // rect, since there is no pointer to anchor on.
1382
+ if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
1383
+ e.preventDefault();
1384
+ this._activeSession = session;
1385
+ this._setActiveRow(row);
1386
+ const rect = row.getBoundingClientRect();
1387
+ void this._openContextMenu(session, rect.left, rect.bottom);
1388
+ }
1389
+ });
1390
+
1391
+ return row;
1392
+ }
1393
+
1394
+ /** Leading status dot, or a placeholder that reserves its column. Which
1395
+ * states exist is a descriptor question, never an assistant question. */
1396
+ private _renderIndicator(session: ISession): HTMLElement {
1397
+ const dot = document.createElement('span');
1398
+ // A `title` alone is not announced by most screen readers, so the same
1399
+ // words go on an aria-label.
1400
+ if (this._descriptor.hasRemoteControl && session.remote_control) {
1401
+ dot.className = 'jp-AiAssistantsPanel-dot';
1402
+ dot.title = 'Remote control session is active';
1403
+ dot.setAttribute('aria-label', dot.title);
1404
+ return dot;
1405
+ }
1406
+ if (this._descriptor.hasLiveProcess && session.live) {
1407
+ dot.className = 'jp-AiAssistantsPanel-dot';
1408
+ dot.title = `A ${this._descriptor.label} session is active in this project`;
1409
+ dot.setAttribute('aria-label', dot.title);
1410
+ return dot;
1411
+ }
1412
+ dot.className = 'jp-AiAssistantsPanel-dotPlaceholder';
1413
+ return dot;
1414
+ }
1415
+
1416
+ private _lookupName(s: ISession): string {
1417
+ return this._displayNames.get(s.project_path) ?? this._displayName(s);
1418
+ }
1419
+
1420
+ private _buildRowTooltip(s: ISession): string {
1421
+ const lines: string[] = [this._lookupName(s)];
1422
+ lines.push(`Path: ${this._displayPath(s.project_path)}`);
1423
+ if (s.file_mtime) {
1424
+ // Absolute first: the tooltip is the one place an exact reference fits,
1425
+ // and the relative value is already on the row.
1426
+ lines.push(
1427
+ `Last activity: ${this._formatAbsoluteTime(
1428
+ s.file_mtime
1429
+ )} (${this._formatRelativeTime(s.file_mtime)})`
1430
+ );
1431
+ }
1432
+ if (s.message_count) {
1433
+ lines.push(`Messages: ${s.message_count}`);
1434
+ }
1435
+ if (s.extra_sessions > 0) {
1436
+ lines.push(`Conversations: ${s.extra_sessions + 1}`);
1437
+ }
1438
+ if (s.git_branch) {
1439
+ lines.push(`Branch: ${s.git_branch}`);
1440
+ }
1441
+ if (this._descriptor.hasRemoteControl && s.remote_control) {
1442
+ lines.push('Remote control: active');
1443
+ }
1444
+ if (this._descriptor.hasLiveProcess && s.live) {
1445
+ lines.push('Status: active');
1446
+ }
1447
+ if (this._descriptor.hasBgAgents && s.bg_id) {
1448
+ lines.push(`Background agent: ${s.bg_id} (click attaches to it)`);
1449
+ }
1450
+ lines.push(
1451
+ ...(this._hooks.tooltipLines?.({
1452
+ session: s,
1453
+ descriptor: this._descriptor
1454
+ }) ?? [])
1455
+ );
1456
+ if (s.session_id) {
1457
+ lines.push(`Session id: ${s.session_id}`);
1458
+ }
1459
+ return lines.join('\n');
1460
+ }
1461
+
1462
+ private _displayPath(absolute: string): string {
1463
+ if (!this._rootDir) {
1464
+ return absolute;
1465
+ }
1466
+ if (absolute === this._rootDir) {
1467
+ return '.';
1468
+ }
1469
+ if (absolute.startsWith(this._rootDir + '/')) {
1470
+ return absolute.slice(this._rootDir.length + 1);
1471
+ }
1472
+ return absolute;
1473
+ }
1474
+
1475
+ /** Path relative to the JupyterLab server root (`''` for the root itself),
1476
+ * or null when the folder lies outside it - in which case the file browser
1477
+ * has no way to address it. */
1478
+ private _pathUnderRoot(absolute: string): string | null {
1479
+ if (!this._rootDir) {
1480
+ return null;
1481
+ }
1482
+ if (absolute === this._rootDir) {
1483
+ return '';
1484
+ }
1485
+ if (absolute.startsWith(this._rootDir + '/')) {
1486
+ return absolute.slice(this._rootDir.length + 1);
1487
+ }
1488
+ return null;
1489
+ }
1490
+
1491
+ private _formatRelativeTime(epochMs: number): string {
1492
+ if (!epochMs) {
1493
+ return 'unknown';
1494
+ }
1495
+ const diff = Date.now() - epochMs;
1496
+ if (diff < 60_000) {
1497
+ return 'now';
1498
+ }
1499
+ if (diff < 3_600_000) {
1500
+ return `${Math.floor(diff / 60_000)}m ago`;
1501
+ }
1502
+ if (diff < 86_400_000) {
1503
+ return `${Math.floor(diff / 3_600_000)}h ago`;
1504
+ }
1505
+ if (diff < 30 * 86_400_000) {
1506
+ return `${Math.floor(diff / 86_400_000)}d ago`;
1507
+ }
1508
+ if (diff < 365 * 86_400_000) {
1509
+ return `${Math.floor(diff / (30 * 86_400_000))}mo ago`;
1510
+ }
1511
+ return `${Math.floor(diff / (365 * 86_400_000))}y ago`;
1512
+ }
1513
+
1514
+ /** Local `YYYY-MM-DD HH:MM` - the absolute reference the relative label
1515
+ * cannot give. */
1516
+ private _formatAbsoluteTime(epochMs: number): string {
1517
+ const d = new Date(epochMs);
1518
+ const pad = (n: number): string => String(n).padStart(2, '0');
1519
+ return (
1520
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
1521
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`
1522
+ );
1523
+ }
1524
+
1525
+ /** Branch entry display: conversation name plus short session id. Branches
1526
+ * share a project path, so the name and id are all that tell them apart. The
1527
+ * suffix is dropped when the label already IS the short id. */
1528
+ private _branchDisplayName(b: IBranch): string {
1529
+ const shortId = b.session_id.slice(0, 8);
1530
+ if (this._hooks.branchLabel) {
1531
+ return this._hooks.branchLabel(b, shortId);
1532
+ }
1533
+ return b.label === shortId ? b.label : `${b.label} (${shortId})`;
1534
+ }
1535
+
1536
+ /** Menu-item label for a branch: display name, relative time, and a trailing
1537
+ * marker when a live worker owns the conversation. The marker lives in the
1538
+ * label rather than the icon, because both branch submenus need their icon
1539
+ * slot for their own intent, and words are what assistive tech reads. */
1540
+ private _branchMenuLabel(b: IBranch): string {
1541
+ const time = this._formatRelativeTime(b.file_mtime);
1542
+ const bg = this._descriptor.hasBgAgents && b.bg_id ? ' (bg)' : '';
1543
+ return `${this._branchDisplayName(b)} - ${time}${bg}`;
1544
+ }
1545
+
1546
+ private _setRefreshSpinning(on: boolean): void {
1547
+ this._refreshBtn?.classList.toggle('jp-mod-spinning', on);
1548
+ }
1549
+
1550
+ private _setActiveRow(row: HTMLElement | null): void {
1551
+ if (this._activeRowEl && this._activeRowEl !== row) {
1552
+ this._activeRowEl.classList.remove('jp-mod-active');
1553
+ }
1554
+ this._activeRowEl = row;
1555
+ row?.classList.add('jp-mod-active');
1556
+ }
1557
+
1558
+ // --------------------------------------------------------------- commands
1559
+
1560
+ private _cmd(action: string): string {
1561
+ return commandId(this._descriptor.id, action);
1562
+ }
1563
+
1564
+ /** Boolean launch modes that produce a menu variant - the assistant's own
1565
+ * skip-approval switch, under the assistant's own name. */
1566
+ private get _variantModes(): ILaunchMode[] {
1567
+ return this._descriptor.launchModes.filter(
1568
+ m => m.kind === 'boolean' && !!m.menuLabel
1569
+ );
1570
+ }
1571
+
1572
+ private _setupCommands(): void {
1573
+ this._commands = new CommandRegistry();
1574
+ const active = (): ISession | null => this._activeSession;
1575
+
1576
+ this._commands.addCommand(this._cmd('toggle-favourite'), {
1577
+ label: () =>
1578
+ active()?.favourite ? 'Remove from Favorites' : 'Add to Favorites',
1579
+ icon: starFilledIcon,
1580
+ execute: () => {
1581
+ const s = active();
1582
+ if (s) {
1583
+ void this._toggleFavourite(s);
1584
+ }
1585
+ }
1586
+ });
1587
+
1588
+ this._commands.addCommand(this._cmd('resume'), {
1589
+ // Joining a live worker and resuming a dormant conversation are
1590
+ // different verbs, so a provider that has both says which one this row
1591
+ // gets.
1592
+ label: () => this._hooks.resumeLabel?.(active()) ?? 'Resume',
1593
+ execute: () => {
1594
+ const s = active();
1595
+ if (s) {
1596
+ void this._openSession(s);
1597
+ }
1598
+ }
1599
+ });
1600
+
1601
+ for (const mode of this._variantModes) {
1602
+ this._commands.addCommand(this._cmd(`resume-${mode.id}`), {
1603
+ label: `Resume (${mode.menuLabel})`,
1604
+ icon: warningIcon,
1605
+ // A conversation a live worker already holds was started in whatever
1606
+ // mode that worker has; joining it cannot change that, so the variant
1607
+ // is disabled rather than silently joining in the wrong mode.
1608
+ isEnabled: () => !(this._descriptor.hasBgAgents && active()?.bg_id),
1609
+ execute: () => {
1610
+ const s = active();
1611
+ if (s) {
1612
+ void this._openSession(s, mode.id);
1613
+ }
1614
+ }
1615
+ });
1616
+ this._commands.addCommand(this._cmd(`new-session-${mode.id}`), {
1617
+ label: `New ${this._descriptor.label} Session (${mode.menuLabel})`,
1618
+ icon: warningIcon,
1619
+ execute: () => void this._newSession(mode.id)
1620
+ });
1621
+ this._commands.addCommand(this._cmd(`branch-session-${mode.id}`), {
1622
+ label: mode.menuLabel as string,
1623
+ icon: warningIcon,
1624
+ execute: () => void this._branchSession(mode.id)
1625
+ });
1626
+ }
1627
+
1628
+ this._commands.addCommand(this._cmd('open-terminal'), {
1629
+ label: 'Open Terminal',
1630
+ icon: terminalIcon,
1631
+ execute: () => {
1632
+ const s = active();
1633
+ if (!s) {
1634
+ return;
1635
+ }
1636
+ // JupyterLab's own command spawns a plain shell at the given cwd. That
1637
+ // cwd is interpreted relative to the contents manager root, not as an
1638
+ // absolute filesystem path, so it goes through _pathUnderRoot.
1639
+ const rel = this._pathUnderRoot(s.project_path);
1640
+ if (rel === null) {
1641
+ Notification.warning(
1642
+ 'Folder is outside the JupyterLab root - cannot open a terminal there.',
1643
+ { autoClose: 4000 }
1644
+ );
1645
+ return;
1646
+ }
1647
+ this._app.commands
1648
+ .execute('terminal:create-new', { cwd: rel })
1649
+ .catch(err => this._showError(err));
1650
+ }
1651
+ });
1652
+
1653
+ this._commands.addCommand(this._cmd('show-in-filebrowser'), {
1654
+ label: 'Show in File Browser',
1655
+ icon: folderIcon,
1656
+ execute: () => {
1657
+ const s = active();
1658
+ if (!s) {
1659
+ return;
1660
+ }
1661
+ const rel = this._pathUnderRoot(s.project_path);
1662
+ if (rel === null) {
1663
+ Notification.warning(
1664
+ 'Folder is outside the JupyterLab root - the file browser cannot show it.',
1665
+ { autoClose: 4000 }
1666
+ );
1667
+ return;
1668
+ }
1669
+ this._app.commands
1670
+ .execute('filebrowser:go-to-path', { path: rel })
1671
+ .catch(err => this._showError(err));
1672
+ }
1673
+ });
1674
+
1675
+ this._commands.addCommand(this._cmd('copy-path'), {
1676
+ label: 'Copy Path',
1677
+ execute: () => {
1678
+ const s = active();
1679
+ if (s) {
1680
+ Clipboard.copyToSystem(s.project_path);
1681
+ }
1682
+ }
1683
+ });
1684
+
1685
+ this._commands.addCommand(this._cmd('copy-session-id'), {
1686
+ label: 'Copy Session ID',
1687
+ execute: () => {
1688
+ const id = active()?.session_id;
1689
+ if (id) {
1690
+ Clipboard.copyToSystem(id);
1691
+ }
1692
+ }
1693
+ });
1694
+
1695
+ this._commands.addCommand(this._cmd('cleanup-parallel'), {
1696
+ label: () =>
1697
+ `Clean Up Parallel Sessions (${active()?.extra_sessions ?? 0})`,
1698
+ isVisible: () => (active()?.extra_sessions ?? 0) > 0,
1699
+ execute: () => {
1700
+ const s = active();
1701
+ if (s) {
1702
+ void this._cleanupParallel(s);
1703
+ }
1704
+ }
1705
+ });
1706
+
1707
+ this._commands.addCommand(this._cmd('switch-branch'), {
1708
+ label: args => String(args.label ?? ''),
1709
+ // The icon column carries the submenu's INTENT, never the row's state:
1710
+ // both branch submenus list the same conversations with the same labels,
1711
+ // so a state-driven icon would render them identically and a pointer
1712
+ // slip would switch the current conversation instead of opening one.
1713
+ icon: switchIcon,
1714
+ execute: args => {
1715
+ const s = active();
1716
+ const sessionId = String(args.session_id ?? '');
1717
+ if (s && sessionId) {
1718
+ void this._switchBranch(s, sessionId);
1719
+ }
1720
+ }
1721
+ });
1722
+
1723
+ this._commands.addCommand(this._cmd('manage-sessions'), {
1724
+ label: () => `Manage Sessions... (${this._lastBranches.length})`,
1725
+ execute: () => this._openManagePopup()
1726
+ });
1727
+
1728
+ this._commands.addCommand(this._cmd('open-branch'), {
1729
+ label: args => String(args.label ?? ''),
1730
+ // Intent glyph, not row state - see switch-branch above.
1731
+ icon: terminalIcon,
1732
+ execute: args => {
1733
+ const s = active();
1734
+ const sessionId = String(args.session_id ?? '');
1735
+ if (s && sessionId) {
1736
+ void this._openSession(s, undefined, sessionId);
1737
+ }
1738
+ }
1739
+ });
1740
+
1741
+ this._commands.addCommand(this._cmd('branch-session'), {
1742
+ label: 'Normal',
1743
+ execute: () => void this._branchSession()
1744
+ });
1745
+
1746
+ this._commands.addCommand(this._cmd('remove'), {
1747
+ label: `Remove from ${this._descriptor.label}`,
1748
+ icon: removeIcon,
1749
+ execute: () => {
1750
+ const s = active();
1751
+ if (s) {
1752
+ void this._removeProject(s);
1753
+ }
1754
+ }
1755
+ });
1756
+
1757
+ this._commands.addCommand(this._cmd('new-session'), {
1758
+ label: `New ${this._descriptor.label} Session`,
1759
+ execute: () => void this._newSession()
1760
+ });
1761
+
1762
+ this._buildMenus();
1763
+ }
1764
+
1765
+ private _buildMenus(): void {
1766
+ // Dropdown for the header's plus button - same registry and styling as the
1767
+ // row context menu.
1768
+ this._newSessionMenu = this._menu();
1769
+ this._newSessionMenu.addItem({ command: this._cmd('new-session') });
1770
+ for (const mode of this._variantModes) {
1771
+ this._newSessionMenu.addItem({
1772
+ command: this._cmd(`new-session-${mode.id}`)
1773
+ });
1774
+ }
1775
+
1776
+ // Lists the project's other conversations. Items are rebuilt on every
1777
+ // context-menu open from a fresh branches fetch.
1778
+ this._switchSubmenu = this._menu();
1779
+ this._switchSubmenu.title.label = 'Switch and Manage Sessions';
1780
+ this._switchSubmenu.title.icon = switchIcon;
1781
+
1782
+ // Opens a conversation directly in its own terminal, as opposed to the
1783
+ // switch submenu which only changes which one the row points at. Several
1784
+ // conversations can be open at once, independently.
1785
+ this._openBranchSubmenu = this._menu();
1786
+ this._openBranchSubmenu.title.label = 'Open Branched Conversation';
1787
+ this._openBranchSubmenu.title.icon = terminalIcon;
1788
+
1789
+ // Groups the branch launch modes.
1790
+ this._branchSessionMenu = this._menu();
1791
+ this._branchSessionMenu.title.label = 'Branch Session';
1792
+ this._branchSessionMenu.title.icon = branchIcon;
1793
+ this._branchSessionMenu.addItem({ command: this._cmd('branch-session') });
1794
+ for (const mode of this._variantModes) {
1795
+ this._branchSessionMenu.addItem({
1796
+ command: this._cmd(`branch-session-${mode.id}`)
1797
+ });
1798
+ }
1799
+
1800
+ this._contextMenu = this._menu();
1801
+ this._rebuildContextMenu(false);
1802
+
1803
+ this._contextMenu.aboutToClose.connect(() => {
1804
+ // Only clear the visual highlight - do NOT null _activeSession. Lumino
1805
+ // fires aboutToClose BEFORE the activated item's command runs, so the
1806
+ // callback still needs to read it. The field is overwritten on the next
1807
+ // contextmenu open.
1808
+ this._setActiveRow(null);
1809
+ });
1810
+ }
1811
+
1812
+ private _menu(): Menu {
1813
+ const menu = new Menu({ commands: this._commands });
1814
+ menu.addClass('jp-AiAssistantsContextMenu');
1815
+ return menu;
1816
+ }
1817
+
1818
+ /** Rebuild the context menu's items. Lumino submenu items have no
1819
+ * `isVisible` hook, so the menu is rebuilt per open and a branch submenu is
1820
+ * inserted only when the row actually has branches - a capability the
1821
+ * provider lacks is absent, not shown and disabled. */
1822
+ private _rebuildContextMenu(withBranches: boolean): void {
1823
+ this._contextMenu.clearItems();
1824
+ const add = (action: string): void => {
1825
+ this._contextMenu.addItem({ command: this._cmd(action) });
1826
+ };
1827
+
1828
+ add('resume');
1829
+ for (const mode of this._variantModes) {
1830
+ add(`resume-${mode.id}`);
1831
+ }
1832
+ add('open-terminal');
1833
+ add('show-in-filebrowser');
1834
+ add('toggle-favourite');
1835
+ add('copy-path');
1836
+ add('copy-session-id');
1837
+ this._contextMenu.addItem({ type: 'separator' });
1838
+ if (withBranches) {
1839
+ this._contextMenu.addItem({
1840
+ type: 'submenu',
1841
+ submenu: this._openBranchSubmenu
1842
+ });
1843
+ this._contextMenu.addItem({
1844
+ type: 'submenu',
1845
+ submenu: this._switchSubmenu
1846
+ });
1847
+ }
1848
+ this._contextMenu.addItem({
1849
+ type: 'submenu',
1850
+ submenu: this._branchSessionMenu
1851
+ });
1852
+ add('cleanup-parallel');
1853
+ add('remove');
1854
+ }
1855
+
1856
+ /** Open the row context menu, populating the branch submenus first when the
1857
+ * project holds more than one conversation. A fetch failure opens the menu
1858
+ * without them rather than not at all. */
1859
+ private async _openContextMenu(
1860
+ session: ISession,
1861
+ x: number,
1862
+ y: number
1863
+ ): Promise<void> {
1864
+ let hasBranches = false;
1865
+ if (session.extra_sessions > 0) {
1866
+ try {
1867
+ const data = await this._fetchBranches(session);
1868
+ this._lastBranches = data.branches;
1869
+ this._lastBranchesCurrent = data.current;
1870
+ const inline = data.branches.slice(0, INLINE_BRANCH_LIMIT);
1871
+
1872
+ // The submenu shows only the most recent few inline - fewest clicks
1873
+ // for often-used conversations; the full list plus management lives
1874
+ // behind the always-present Manage Sessions popup.
1875
+ this._switchSubmenu.clearItems();
1876
+ this._switchSubmenu.title.label = `Switch and Manage Sessions (${data.branches.length})`;
1877
+ for (const b of inline) {
1878
+ this._switchSubmenu.addItem({
1879
+ command: this._cmd('switch-branch'),
1880
+ args: { session_id: b.session_id, label: this._branchMenuLabel(b) }
1881
+ });
1882
+ }
1883
+ this._switchSubmenu.addItem({ type: 'separator' });
1884
+ this._switchSubmenu.addItem({ command: this._cmd('manage-sessions') });
1885
+
1886
+ // Same conversations, but each launches its own terminal directly.
1887
+ this._openBranchSubmenu.clearItems();
1888
+ this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
1889
+ for (const b of inline) {
1890
+ this._openBranchSubmenu.addItem({
1891
+ command: this._cmd('open-branch'),
1892
+ args: { session_id: b.session_id, label: this._branchMenuLabel(b) }
1893
+ });
1894
+ }
1895
+ this._openBranchSubmenu.addItem({ type: 'separator' });
1896
+ this._openBranchSubmenu.addItem({
1897
+ command: this._cmd('manage-sessions')
1898
+ });
1899
+ hasBranches = data.branches.length > 0;
1900
+ } catch {
1901
+ hasBranches = false;
1902
+ }
1903
+ }
1904
+ this._rebuildContextMenu(hasBranches);
1905
+ this._contextMenu.open(x, y);
1906
+ }
1907
+
1908
+ private _openManagePopup(): void {
1909
+ const session = this._activeSession;
1910
+ if (!session) {
1911
+ return;
1912
+ }
1913
+ showManageSessionsPopup({
1914
+ branches: this._lastBranches,
1915
+ current: this._lastBranchesCurrent || session.session_id,
1916
+ projectName: this._lookupName(session),
1917
+ deleteToTrash: this._deleteToTrash,
1918
+ branchName: b => this._branchDisplayName(b),
1919
+ formatTime: ms => this._formatRelativeTime(ms),
1920
+ branchBadge: this._hooks.branchBadge
1921
+ ? b => this._hooks.branchBadge!(b)
1922
+ : b => this._bgBadgeFor(b),
1923
+ onSwitch: id => void this._switchBranch(session, id),
1924
+ onOpen: id => void this._openSession(session, undefined, id),
1925
+ onDelete: ids => this._deleteBranches(session, ids),
1926
+ onRefetch: async () => (await this._fetchBranches(session)).branches,
1927
+ onSync: branches => {
1928
+ this._lastBranches = branches;
1929
+ }
1930
+ });
1931
+ }
1932
+
1933
+ /** The live-worker chip for a branch row, when the provider has workers at
1934
+ * all. Returning null lets the popup fall back to its fork marker. */
1935
+ private _bgBadgeFor(b: IBranch): HTMLElement | null {
1936
+ if (!this._descriptor.hasBgAgents || !b.bg_id) {
1937
+ return null;
1938
+ }
1939
+ const bg = document.createElement('span');
1940
+ bg.className = 'jp-AiAssistantsPanel-bgBadge';
1941
+ bg.textContent = 'bg';
1942
+ bg.title = `Background agent: ${b.bg_id}`;
1943
+ return bg;
1944
+ }
1945
+
1946
+ // ---------------------------------------------------------------- polling
1947
+
1948
+ private _startPolling(): void {
1949
+ if (this._pollHandle !== null) {
1950
+ return;
1951
+ }
1952
+ this._pollHandle = window.setInterval(() => {
1953
+ // Do not reshuffle rows while the user is in the context menu.
1954
+ if (this._contextMenu.isAttached) {
1955
+ return;
1956
+ }
1957
+ this._fetch().catch(err => this._showError(err));
1958
+ }, POLL_INTERVAL_MS);
1959
+ }
1960
+
1961
+ private _stopPolling(): void {
1962
+ if (this._pollHandle !== null) {
1963
+ window.clearInterval(this._pollHandle);
1964
+ this._pollHandle = null;
1965
+ }
1966
+ }
1967
+
1968
+ private readonly _app: JupyterFrontEnd;
1969
+ private readonly _descriptor: IProviderDescriptor;
1970
+ private readonly _hooks: IProviderHooks;
1971
+ private readonly _serverSettings: ServerConnection.ISettings;
1972
+ private readonly _rootDir: string;
1973
+ private readonly _deleteToTrash: boolean;
1974
+ private readonly _fileBrowser: IDefaultFileBrowser | null;
1975
+ private readonly _colours: ColourStore;
1976
+ private readonly _terminals: TerminalManager;
1977
+ private readonly _expandedKey: string;
1978
+ private readonly _removingPaths: Set<string> = new Set();
1979
+ private _bodyEl!: HTMLDivElement;
1980
+ private _loadingEl: HTMLElement | null = null;
1981
+ private _errorEl: HTMLElement | null = null;
1982
+ private _refreshBtn: HTMLButtonElement | null = null;
1983
+ private _filterBtn: HTMLButtonElement | null = null;
1984
+ private _searchEl: HTMLInputElement | null = null;
1985
+ private _searchWrapEl: HTMLDivElement | null = null;
1986
+ private _searchClearEl: HTMLButtonElement | null = null;
1987
+ private _sessions: ISession[] | null = null;
1988
+ private _expanded: Record<SectionKey, boolean>;
1989
+ private _commands!: CommandRegistry;
1990
+ private _contextMenu!: Menu;
1991
+ private _switchSubmenu!: Menu;
1992
+ private _openBranchSubmenu!: Menu;
1993
+ private _branchSessionMenu!: Menu;
1994
+ private _newSessionMenu!: Menu;
1995
+ private _lastBranches: IBranch[] = [];
1996
+ private _lastBranchesCurrent = '';
1997
+ private _activeSession: ISession | null = null;
1998
+ private _activeRowEl: HTMLElement | null = null;
1999
+ private _pollHandle: number | null = null;
2000
+ private _presentationMode: PresentationMode = DEFAULT_PRESENTATION_MODE;
2001
+ private _recentLimit: number = DEFAULT_RECENT_LIMIT;
2002
+ private _modes: Record<string, boolean | string> = {};
2003
+ private _displayNames: Map<string, string> = new Map();
2004
+ private _filter = '';
2005
+ }
2006
+
2007
+ export namespace AssistantSessionsPanel {
2008
+ export interface IOptions {
2009
+ app: JupyterFrontEnd;
2010
+ descriptor: IProviderDescriptor;
2011
+ hooks?: IProviderHooks;
2012
+ /** JupyterLab's server root, for path display and file-browser hand-offs. */
2013
+ rootDir: string;
2014
+ /** JupyterLab's move-to-trash setting, so dialogs name the right outcome. */
2015
+ deleteToTrash?: boolean;
2016
+ terminalTracker?: ITerminalTracker | null;
2017
+ fileBrowser?: IDefaultFileBrowser | null;
2018
+ colourfulTabs?: IColourfulTabs | null;
2019
+ }
2020
+ }