jupyterlab_ai_code_assistants_extension 1.2.38 → 1.2.45
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/README.md +1 -1
- package/lib/core/terminals.js +2 -2
- package/lib/index.js +43 -22
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +160 -13
- package/src/core/terminals.ts +2 -2
- package/src/index.ts +41 -24
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
27
27
|
|
|
28
28
|
- **One install, every assistant** - Claude Code, Codex, Kimi, Gemini and DeepSeek from a single package, each with its own right-side panel wearing its official mark
|
|
29
29
|
- **Provider registry** - assistant-specific behaviour lives in one module per assistant behind capability flags; no core file names an assistant, and adding one touches no core file
|
|
30
|
-
- **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload
|
|
30
|
+
- **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload, and a change reaches the other JupyterLab windows of the same browser
|
|
31
31
|
- **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
|
|
32
32
|
- **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated. Exception: a Codex or Kimi conversation started with `+`, and a Codex fork, run without an id until the CLI writes one, so a click on that row meanwhile opens a second terminal
|
|
33
33
|
- **Launcher tiles** - every enabled assistant has a tile in an "AI Assistants" section of the JupyterLab Launcher (after Other, under the extension's own robot-head icon); a click opens the assistant in the file browser's current folder, resuming the folder's conversation when one exists and starting a new one otherwise, through the companion `jupyterlab_basic_terminal_extension` (installed automatically). Disable an assistant and its tile goes; disable them all and the section goes with them
|
package/lib/core/terminals.js
CHANGED
|
@@ -41,8 +41,8 @@ function warnLegacyCompanion() {
|
|
|
41
41
|
// And said where the user is looking. The symptom is tabs that are simply
|
|
42
42
|
// plain, which reads as a feature that was never on rather than as something
|
|
43
43
|
// to fix, and a console line only reaches someone already suspecting the
|
|
44
|
-
// cause. The
|
|
45
|
-
//
|
|
44
|
+
// cause. The retired-extension notice in index.ts pairs its console line
|
|
45
|
+
// with a toast the same way. It does not
|
|
46
46
|
// close on its own, because the condition does not go away on its own.
|
|
47
47
|
Notification.warning(message, { autoClose: false });
|
|
48
48
|
}
|
package/lib/index.js
CHANGED
|
@@ -242,7 +242,7 @@ const plugin = {
|
|
|
242
242
|
/** Bring the docked panels in line with settings and CLI availability.
|
|
243
243
|
* Both must hold for a panel to be present. */
|
|
244
244
|
const reconcile = () => {
|
|
245
|
-
var _a, _b, _c
|
|
245
|
+
var _a, _b, _c;
|
|
246
246
|
for (const descriptor of registry.descriptors) {
|
|
247
247
|
const id = descriptor.id;
|
|
248
248
|
if (suppressed.has(id)) {
|
|
@@ -258,38 +258,26 @@ const plugin = {
|
|
|
258
258
|
// the server's word on each binary is final.
|
|
259
259
|
const roster = status;
|
|
260
260
|
const available = roster === null ? true : probe !== null && probe.available !== false;
|
|
261
|
-
// Enabled but binary absent
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
// Only when the probe SUCCEEDED and this provider is
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// assistant installed would otherwise raise three toasts about assistants
|
|
268
|
-
// the user has never heard of. The console line stays for that case.
|
|
269
|
-
const chosen = (settings === null || settings === void 0 ? void 0 : settings.get(`providers.${id}.enabled`).user) !== undefined;
|
|
261
|
+
// Enabled but binary absent is logged to the console, never announced:
|
|
262
|
+
// enabled means the extension supports the assistant, not that it is
|
|
263
|
+
// installed, so a toast would name a tool the user never installed
|
|
264
|
+
// (DEF-PANE-277). Only when the probe SUCCEEDED and this provider is
|
|
265
|
+
// the one missing - blaming PATH for a server the frontend never
|
|
266
|
+
// reached names the wrong cause. Once per id, not per reconcile.
|
|
270
267
|
if (probe !== null &&
|
|
271
268
|
probe.available === false &&
|
|
272
269
|
enabled &&
|
|
273
|
-
chosen &&
|
|
274
270
|
!warnedUnavailable.has(id)) {
|
|
275
271
|
warnedUnavailable.add(id);
|
|
276
272
|
const binary = (_b = (_a = registry.get(id)) === null || _a === void 0 ? void 0 : _a.descriptor.cliBinary) !== null && _b !== void 0 ? _b : id;
|
|
277
|
-
const label = (_d = (_c = registry.get(id)) === null || _c === void 0 ? void 0 : _c.descriptor.label) !== null && _d !== void 0 ? _d : id;
|
|
278
273
|
console.info(`${LOG_PREFIX} "${id}" is enabled but its \`${binary}\` binary was not found on the server's PATH; panel not shown.`);
|
|
279
|
-
// And say it where the user is looking. A console line satisfies the
|
|
280
|
-
// comment above only for someone who already suspects the cause; the
|
|
281
|
-
// symptom is a panel that never appears, which reads as a broken
|
|
282
|
-
// install rather than a PATH difference between the Jupyter server
|
|
283
|
-
// and the user's shell. The retired-extension case next door already
|
|
284
|
-
// gets a notification, so the COMMON failure was the quiet one.
|
|
285
|
-
Notification.warning(`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`, { autoClose: 8000 });
|
|
286
274
|
}
|
|
287
275
|
// The latch is per ABSENCE, not per page load. Once the server reports
|
|
288
|
-
// the binary present again, a later disappearance gets its own
|
|
276
|
+
// the binary present again, a later disappearance gets its own line:
|
|
289
277
|
// without this, one transient absence - a CLI upgrade unlinking the
|
|
290
278
|
// binary for a moment, or an obsolete roster from an overlapping probe -
|
|
291
279
|
// permanently suppresses the real one (DEF-125). It cannot make the
|
|
292
|
-
//
|
|
280
|
+
// line repeat while the state is unchanged, which is what the
|
|
293
281
|
// once-per-id latch above exists to prevent: the two conditions are
|
|
294
282
|
// mutually exclusive.
|
|
295
283
|
if (probe !== null && probe.available !== false) {
|
|
@@ -302,7 +290,7 @@ const plugin = {
|
|
|
302
290
|
// A panel docked before the first roster was built without the
|
|
303
291
|
// server root, and cannot resolve paths until it has one.
|
|
304
292
|
if (roster !== null) {
|
|
305
|
-
panel.setRoot((
|
|
293
|
+
panel.setRoot((_c = roster.root_dir) !== null && _c !== void 0 ? _c : '', roster.delete_to_trash !== false);
|
|
306
294
|
}
|
|
307
295
|
}
|
|
308
296
|
else {
|
|
@@ -414,6 +402,39 @@ const plugin = {
|
|
|
414
402
|
reprobe();
|
|
415
403
|
}
|
|
416
404
|
});
|
|
405
|
+
// Each JupyterLab window keeps its own copy of the settings and never
|
|
406
|
+
// reads them again, and every save writes that whole copy. So a window
|
|
407
|
+
// loaded before another window changed a setting put the old value back
|
|
408
|
+
// on its next save of any setting (DEF-PANE-282). A change in this window
|
|
409
|
+
// tells the other windows of this browser, and a window also checks when
|
|
410
|
+
// it gains focus, for a save from another browser or device. Either way
|
|
411
|
+
// it reloads only when the server's copy differs from its own: a reload
|
|
412
|
+
// replaces unsaved text in an open JSON settings editor, and the check
|
|
413
|
+
// also ends the echo, because the window that saved already holds the
|
|
414
|
+
// server's copy. Still open: another browser or device whose window saves
|
|
415
|
+
// without gaining focus first, or whose focusing click is itself the
|
|
416
|
+
// change - that needs a version check in JupyterLab's settings API
|
|
417
|
+
// (DEF-PANE-283).
|
|
418
|
+
if (settingRegistry && settings) {
|
|
419
|
+
const current = settings;
|
|
420
|
+
const channel = new BroadcastChannel(PLUGIN_ID);
|
|
421
|
+
const reload = async () => {
|
|
422
|
+
try {
|
|
423
|
+
const fetched = await settingRegistry.connector.fetch(PLUGIN_ID);
|
|
424
|
+
if (fetched && fetched.raw !== current.raw) {
|
|
425
|
+
await settingRegistry.reload(PLUGIN_ID);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
console.warn(`${LOG_PREFIX} could not reload settings.`, err);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
window.addEventListener('focus', () => void reload());
|
|
433
|
+
channel.onmessage = () => void reload();
|
|
434
|
+
current.changed.connect(() => {
|
|
435
|
+
channel.postMessage('changed');
|
|
436
|
+
});
|
|
437
|
+
}
|
|
417
438
|
console.log(`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`);
|
|
418
439
|
}
|
|
419
440
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.45",
|
|
4
4
|
"description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -61,6 +61,34 @@ async function flush(turns = 8): Promise<void> {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** jsdom has no BroadcastChannel. This one joins every window of the test
|
|
65
|
+
* process and delivers as a microtask, so `flush` settles it and, as in a
|
|
66
|
+
* browser, a sender never hears its own message. Two windows reloading each
|
|
67
|
+
* other would post forever and starve every timer, jest's own included, so
|
|
68
|
+
* past a cap no legitimate run reaches it throws instead of hanging. */
|
|
69
|
+
class TestBroadcastChannel {
|
|
70
|
+
private static _open: TestBroadcastChannel[] = [];
|
|
71
|
+
private static _posted = 0;
|
|
72
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(readonly name: string) {
|
|
75
|
+
TestBroadcastChannel._open.push(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
postMessage(data: unknown): void {
|
|
79
|
+
TestBroadcastChannel._posted += 1;
|
|
80
|
+
if (TestBroadcastChannel._posted > 1000) {
|
|
81
|
+
throw new Error('windows are reloading each other without end');
|
|
82
|
+
}
|
|
83
|
+
for (const other of TestBroadcastChannel._open) {
|
|
84
|
+
if (other !== this && other.name === this.name) {
|
|
85
|
+
queueMicrotask(() => other.onmessage?.({ data }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
(globalThis as any).BroadcastChannel = TestBroadcastChannel;
|
|
91
|
+
|
|
64
92
|
describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () => {
|
|
65
93
|
it('a panel docked before the first roster gains the root and the trash flag', async () => {
|
|
66
94
|
let roster: unknown = null;
|
|
@@ -82,12 +110,16 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
|
|
|
82
110
|
hasPlugin: () => false
|
|
83
111
|
} as any;
|
|
84
112
|
const settings = {
|
|
113
|
+
raw: '{}',
|
|
85
114
|
composite: {},
|
|
86
115
|
get: () => ({ composite: undefined, user: undefined }),
|
|
87
116
|
set: async () => undefined,
|
|
88
117
|
changed: new Signal<unknown, void>({})
|
|
89
118
|
};
|
|
90
|
-
const settingRegistry = {
|
|
119
|
+
const settingRegistry = {
|
|
120
|
+
connector: { fetch: async () => ({ raw: settings.raw }) },
|
|
121
|
+
load: async () => settings
|
|
122
|
+
} as any;
|
|
91
123
|
const fileBrowser = { model: { path: 'data/raw' } } as any;
|
|
92
124
|
|
|
93
125
|
await (plugin.activate as any)(
|
|
@@ -302,6 +334,10 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
302
334
|
});
|
|
303
335
|
|
|
304
336
|
describe('ACC-PROV - the activation contract', () => {
|
|
337
|
+
/** The settings file on the server, shared by every window activated here. */
|
|
338
|
+
let serverRaw = '{}';
|
|
339
|
+
let saves = 0;
|
|
340
|
+
|
|
305
341
|
/** A roster in which every assistant is present, or every one but `absent`. */
|
|
306
342
|
const roster = (absent?: string): unknown => ({
|
|
307
343
|
root_dir: '/srv/lab',
|
|
@@ -319,6 +355,11 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
319
355
|
restored: [AssistantSessionsPanel, string][];
|
|
320
356
|
/** How many times the status route has been asked. */
|
|
321
357
|
probes: () => number;
|
|
358
|
+
/** Plugin ids passed to `ISettingRegistry.reload`, in order. */
|
|
359
|
+
reloaded: string[];
|
|
360
|
+
/** Save from this window: the server and this window hold the new copy,
|
|
361
|
+
* and `changed` fires. */
|
|
362
|
+
save: () => void;
|
|
322
363
|
}
|
|
323
364
|
|
|
324
365
|
interface IOptions {
|
|
@@ -377,17 +418,29 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
377
418
|
|
|
378
419
|
const composite = options.composite;
|
|
379
420
|
const userSet = new Set(options.userSet ?? Object.keys(composite ?? {}));
|
|
380
|
-
const
|
|
421
|
+
const reloaded: string[] = [];
|
|
422
|
+
const changed = new Signal<unknown, void>({});
|
|
423
|
+
const loaded = composite && {
|
|
424
|
+
raw: serverRaw,
|
|
425
|
+
composite,
|
|
426
|
+
get: (key: string) => ({
|
|
427
|
+
composite: composite[key],
|
|
428
|
+
user: userSet.has(key) ? composite[key] : undefined
|
|
429
|
+
}),
|
|
430
|
+
set: async () => undefined,
|
|
431
|
+
changed
|
|
432
|
+
};
|
|
433
|
+
const settingRegistry = loaded
|
|
381
434
|
? ({
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
435
|
+
connector: { fetch: async () => ({ raw: serverRaw }) },
|
|
436
|
+
// JupyterLab's reload takes the server's copy and fires `changed`
|
|
437
|
+
// before it resolves.
|
|
438
|
+
reload: async (id: string) => {
|
|
439
|
+
reloaded.push(id);
|
|
440
|
+
loaded.raw = serverRaw;
|
|
441
|
+
changed.emit(undefined);
|
|
442
|
+
},
|
|
443
|
+
load: async () => loaded
|
|
391
444
|
} as any)
|
|
392
445
|
: null;
|
|
393
446
|
|
|
@@ -401,7 +454,21 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
401
454
|
null
|
|
402
455
|
);
|
|
403
456
|
await flush();
|
|
404
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
app,
|
|
459
|
+
docked,
|
|
460
|
+
restored,
|
|
461
|
+
probes: () => call,
|
|
462
|
+
reloaded,
|
|
463
|
+
save: () => {
|
|
464
|
+
saves += 1;
|
|
465
|
+
serverRaw = `{"saves": ${saves}}`;
|
|
466
|
+
if (loaded) {
|
|
467
|
+
loaded.raw = serverRaw;
|
|
468
|
+
}
|
|
469
|
+
changed.emit(undefined);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
405
472
|
}
|
|
406
473
|
|
|
407
474
|
/** Every assistant, turned off. */
|
|
@@ -500,6 +567,84 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
500
567
|
live.docked.forEach(widget => widget.dispose());
|
|
501
568
|
});
|
|
502
569
|
|
|
570
|
+
it('DEF-PANE-277 - an enabled assistant whose binary is absent gets no panel and no toast', async () => {
|
|
571
|
+
const toasted = jest
|
|
572
|
+
.spyOn(Notification, 'warning')
|
|
573
|
+
.mockImplementation(() => '');
|
|
574
|
+
const logged = jest
|
|
575
|
+
.spyOn(console, 'info')
|
|
576
|
+
.mockImplementation(() => undefined);
|
|
577
|
+
const absent = PROVIDERS[1].descriptor.id;
|
|
578
|
+
|
|
579
|
+
// Saved explicitly, as the settings editor writes it. Enabled means the
|
|
580
|
+
// extension supports the assistant, not that it is installed.
|
|
581
|
+
const live = await activate({
|
|
582
|
+
composite: { [`providers.${absent}.enabled`]: true },
|
|
583
|
+
answers: [() => Promise.resolve(roster(absent))]
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
expect(
|
|
587
|
+
live.docked
|
|
588
|
+
.filter(widget => !widget.isDisposed)
|
|
589
|
+
.map(widget => widget.descriptor.id)
|
|
590
|
+
).not.toContain(absent);
|
|
591
|
+
expect(toasted).not.toHaveBeenCalled();
|
|
592
|
+
// The reason stays in the browser console, once.
|
|
593
|
+
expect(
|
|
594
|
+
logged.mock.calls
|
|
595
|
+
.map(args => String(args[0]))
|
|
596
|
+
.filter(line => line.includes(`"${absent}"`))
|
|
597
|
+
).toHaveLength(1);
|
|
598
|
+
|
|
599
|
+
toasted.mockRestore();
|
|
600
|
+
logged.mockRestore();
|
|
601
|
+
live.docked.forEach(widget => widget.dispose());
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('DEF-PANE-282 - a focus with no newer save leaves the settings alone', async () => {
|
|
605
|
+
const live = await activate({ composite: {} });
|
|
606
|
+
|
|
607
|
+
// A reload fires `changed`, and an open JSON settings editor then drops
|
|
608
|
+
// its unsaved text - so a focus that brings nothing newer must not reload.
|
|
609
|
+
window.dispatchEvent(new Event('focus'));
|
|
610
|
+
await flush(32);
|
|
611
|
+
expect(live.reloaded).toEqual([]);
|
|
612
|
+
|
|
613
|
+
live.docked.forEach(widget => widget.dispose());
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('DEF-PANE-282 - a focus after another device saved reloads the settings', async () => {
|
|
617
|
+
const live = await activate({ composite: {} });
|
|
618
|
+
|
|
619
|
+
// Saved elsewhere since this window loaded; this window's next save
|
|
620
|
+
// would write the old copy back.
|
|
621
|
+
serverRaw = '{"savedElsewhere": true}';
|
|
622
|
+
window.dispatchEvent(new Event('focus'));
|
|
623
|
+
await flush(32);
|
|
624
|
+
expect(live.reloaded).toEqual([
|
|
625
|
+
'jupyterlab_ai_code_assistants_extension:plugin'
|
|
626
|
+
]);
|
|
627
|
+
|
|
628
|
+
live.docked.forEach(widget => widget.dispose());
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('DEF-PANE-282 - a change in one window makes the other windows of the browser reload', async () => {
|
|
632
|
+
const one = await activate({ composite: {} });
|
|
633
|
+
const other = await activate({ composite: {} });
|
|
634
|
+
|
|
635
|
+
one.save();
|
|
636
|
+
await flush(32);
|
|
637
|
+
expect(other.reloaded).toEqual([
|
|
638
|
+
'jupyterlab_ai_code_assistants_extension:plugin'
|
|
639
|
+
]);
|
|
640
|
+
// The other window's reload fires `changed` and posts in turn; the window
|
|
641
|
+
// that saved already holds the server's copy, so the echo stops there.
|
|
642
|
+
expect(one.reloaded).toEqual([]);
|
|
643
|
+
|
|
644
|
+
one.docked.forEach(widget => widget.dispose());
|
|
645
|
+
other.docked.forEach(widget => widget.dispose());
|
|
646
|
+
});
|
|
647
|
+
|
|
503
648
|
it('ACC-PROV-16 - a binary installed after start gains its panel on the next probe', async () => {
|
|
504
649
|
const late = PROVIDERS[2].descriptor.id;
|
|
505
650
|
let present = false;
|
|
@@ -700,9 +845,11 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
700
845
|
user: key in composite ? composite[key] : undefined
|
|
701
846
|
}),
|
|
702
847
|
set: async () => undefined,
|
|
703
|
-
changed
|
|
848
|
+
changed,
|
|
849
|
+
raw: '{}'
|
|
704
850
|
};
|
|
705
851
|
const settingRegistry = {
|
|
852
|
+
connector: { fetch: async () => ({ raw: settings.raw }) },
|
|
706
853
|
load: async () => {
|
|
707
854
|
if (options.loadThrows) {
|
|
708
855
|
throw new Error('settings registry unavailable');
|
package/src/core/terminals.ts
CHANGED
|
@@ -65,8 +65,8 @@ function warnLegacyCompanion(): void {
|
|
|
65
65
|
// And said where the user is looking. The symptom is tabs that are simply
|
|
66
66
|
// plain, which reads as a feature that was never on rather than as something
|
|
67
67
|
// to fix, and a console line only reaches someone already suspecting the
|
|
68
|
-
// cause. The
|
|
69
|
-
//
|
|
68
|
+
// cause. The retired-extension notice in index.ts pairs its console line
|
|
69
|
+
// with a toast the same way. It does not
|
|
70
70
|
// close on its own, because the condition does not go away on its own.
|
|
71
71
|
Notification.warning(message, { autoClose: false });
|
|
72
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -333,46 +333,30 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
333
333
|
const roster = status;
|
|
334
334
|
const available =
|
|
335
335
|
roster === null ? true : probe !== null && probe.available !== false;
|
|
336
|
-
// Enabled but binary absent
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// Only when the probe SUCCEEDED and this provider is
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// assistant installed would otherwise raise three toasts about assistants
|
|
343
|
-
// the user has never heard of. The console line stays for that case.
|
|
344
|
-
const chosen =
|
|
345
|
-
settings?.get(`providers.${id}.enabled`).user !== undefined;
|
|
336
|
+
// Enabled but binary absent is logged to the console, never announced:
|
|
337
|
+
// enabled means the extension supports the assistant, not that it is
|
|
338
|
+
// installed, so a toast would name a tool the user never installed
|
|
339
|
+
// (DEF-PANE-277). Only when the probe SUCCEEDED and this provider is
|
|
340
|
+
// the one missing - blaming PATH for a server the frontend never
|
|
341
|
+
// reached names the wrong cause. Once per id, not per reconcile.
|
|
346
342
|
if (
|
|
347
343
|
probe !== null &&
|
|
348
344
|
probe.available === false &&
|
|
349
345
|
enabled &&
|
|
350
|
-
chosen &&
|
|
351
346
|
!warnedUnavailable.has(id)
|
|
352
347
|
) {
|
|
353
348
|
warnedUnavailable.add(id);
|
|
354
349
|
const binary = registry.get(id)?.descriptor.cliBinary ?? id;
|
|
355
|
-
const label = registry.get(id)?.descriptor.label ?? id;
|
|
356
350
|
console.info(
|
|
357
351
|
`${LOG_PREFIX} "${id}" is enabled but its \`${binary}\` binary was not found on the server's PATH; panel not shown.`
|
|
358
352
|
);
|
|
359
|
-
// And say it where the user is looking. A console line satisfies the
|
|
360
|
-
// comment above only for someone who already suspects the cause; the
|
|
361
|
-
// symptom is a panel that never appears, which reads as a broken
|
|
362
|
-
// install rather than a PATH difference between the Jupyter server
|
|
363
|
-
// and the user's shell. The retired-extension case next door already
|
|
364
|
-
// gets a notification, so the COMMON failure was the quiet one.
|
|
365
|
-
Notification.warning(
|
|
366
|
-
`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`,
|
|
367
|
-
{ autoClose: 8000 }
|
|
368
|
-
);
|
|
369
353
|
}
|
|
370
354
|
// The latch is per ABSENCE, not per page load. Once the server reports
|
|
371
|
-
// the binary present again, a later disappearance gets its own
|
|
355
|
+
// the binary present again, a later disappearance gets its own line:
|
|
372
356
|
// without this, one transient absence - a CLI upgrade unlinking the
|
|
373
357
|
// binary for a moment, or an obsolete roster from an overlapping probe -
|
|
374
358
|
// permanently suppresses the real one (DEF-125). It cannot make the
|
|
375
|
-
//
|
|
359
|
+
// line repeat while the state is unchanged, which is what the
|
|
376
360
|
// once-per-id latch above exists to prevent: the two conditions are
|
|
377
361
|
// mutually exclusive.
|
|
378
362
|
if (probe !== null && probe.available !== false) {
|
|
@@ -518,6 +502,39 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
518
502
|
}
|
|
519
503
|
});
|
|
520
504
|
|
|
505
|
+
// Each JupyterLab window keeps its own copy of the settings and never
|
|
506
|
+
// reads them again, and every save writes that whole copy. So a window
|
|
507
|
+
// loaded before another window changed a setting put the old value back
|
|
508
|
+
// on its next save of any setting (DEF-PANE-282). A change in this window
|
|
509
|
+
// tells the other windows of this browser, and a window also checks when
|
|
510
|
+
// it gains focus, for a save from another browser or device. Either way
|
|
511
|
+
// it reloads only when the server's copy differs from its own: a reload
|
|
512
|
+
// replaces unsaved text in an open JSON settings editor, and the check
|
|
513
|
+
// also ends the echo, because the window that saved already holds the
|
|
514
|
+
// server's copy. Still open: another browser or device whose window saves
|
|
515
|
+
// without gaining focus first, or whose focusing click is itself the
|
|
516
|
+
// change - that needs a version check in JupyterLab's settings API
|
|
517
|
+
// (DEF-PANE-283).
|
|
518
|
+
if (settingRegistry && settings) {
|
|
519
|
+
const current = settings;
|
|
520
|
+
const channel = new BroadcastChannel(PLUGIN_ID);
|
|
521
|
+
const reload = async (): Promise<void> => {
|
|
522
|
+
try {
|
|
523
|
+
const fetched = await settingRegistry.connector.fetch(PLUGIN_ID);
|
|
524
|
+
if (fetched && fetched.raw !== current.raw) {
|
|
525
|
+
await settingRegistry.reload(PLUGIN_ID);
|
|
526
|
+
}
|
|
527
|
+
} catch (err) {
|
|
528
|
+
console.warn(`${LOG_PREFIX} could not reload settings.`, err);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
window.addEventListener('focus', () => void reload());
|
|
532
|
+
channel.onmessage = () => void reload();
|
|
533
|
+
current.changed.connect(() => {
|
|
534
|
+
channel.postMessage('changed');
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
521
538
|
console.log(
|
|
522
539
|
`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`
|
|
523
540
|
);
|