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