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.
- package/LICENSE +29 -0
- package/README.md +44 -0
- package/lib/core/colour.d.ts +57 -0
- package/lib/core/colour.js +190 -0
- package/lib/core/icons.d.ts +12 -0
- package/lib/core/icons.js +99 -0
- package/lib/core/modes.d.ts +13 -0
- package/lib/core/modes.js +36 -0
- package/lib/core/panel.d.ts +233 -0
- package/lib/core/panel.js +1673 -0
- package/lib/core/popup.d.ts +40 -0
- package/lib/core/popup.js +404 -0
- package/lib/core/registry.d.ts +24 -0
- package/lib/core/registry.js +48 -0
- package/lib/core/request.d.ts +25 -0
- package/lib/core/request.js +64 -0
- package/lib/core/terminals.d.ts +129 -0
- package/lib/core/terminals.js +344 -0
- package/lib/core/types.d.ts +269 -0
- package/lib/core/types.js +6 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +277 -0
- package/lib/providers/claude.d.ts +3 -0
- package/lib/providers/claude.js +88 -0
- package/lib/providers/codex.d.ts +3 -0
- package/lib/providers/codex.js +79 -0
- package/lib/providers/gemini.d.ts +2 -0
- package/lib/providers/gemini.js +73 -0
- package/lib/providers/index.d.ts +3 -0
- package/lib/providers/index.js +16 -0
- package/lib/providers/kimi.d.ts +23 -0
- package/lib/providers/kimi.js +139 -0
- package/package.json +169 -0
- package/schema/plugin.json +93 -0
- package/src/__tests__/colour.spec.ts +153 -0
- package/src/__tests__/core-neutrality.spec.ts +86 -0
- package/src/__tests__/labels.spec.ts +89 -0
- package/src/__tests__/launch-mode.spec.ts +53 -0
- package/src/__tests__/registry.spec.ts +178 -0
- package/src/core/colour.ts +224 -0
- package/src/core/icons.ts +121 -0
- package/src/core/modes.ts +45 -0
- package/src/core/panel.ts +2020 -0
- package/src/core/popup.ts +475 -0
- package/src/core/registry.ts +60 -0
- package/src/core/request.ts +96 -0
- package/src/core/terminals.ts +446 -0
- package/src/core/types.ts +305 -0
- package/src/index.ts +356 -0
- package/src/providers/claude.ts +102 -0
- package/src/providers/codex.ts +89 -0
- package/src/providers/gemini.ts +80 -0
- package/src/providers/index.ts +21 -0
- package/src/providers/kimi.ts +155 -0
- package/style/base.css +888 -0
- package/style/index.css +1 -0
- 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
|
+
}
|