jupyterlab_ai_code_assistants_extension 1.2.41 → 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/index.js +33 -0
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +126 -13
- package/src/index.ts +33 -0
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/index.js
CHANGED
|
@@ -402,6 +402,39 @@ const plugin = {
|
|
|
402
402
|
reprobe();
|
|
403
403
|
}
|
|
404
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
|
+
}
|
|
405
438
|
console.log(`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`);
|
|
406
439
|
}
|
|
407
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. */
|
|
@@ -534,6 +601,50 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
534
601
|
live.docked.forEach(widget => widget.dispose());
|
|
535
602
|
});
|
|
536
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
|
+
|
|
537
648
|
it('ACC-PROV-16 - a binary installed after start gains its panel on the next probe', async () => {
|
|
538
649
|
const late = PROVIDERS[2].descriptor.id;
|
|
539
650
|
let present = false;
|
|
@@ -734,9 +845,11 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
734
845
|
user: key in composite ? composite[key] : undefined
|
|
735
846
|
}),
|
|
736
847
|
set: async () => undefined,
|
|
737
|
-
changed
|
|
848
|
+
changed,
|
|
849
|
+
raw: '{}'
|
|
738
850
|
};
|
|
739
851
|
const settingRegistry = {
|
|
852
|
+
connector: { fetch: async () => ({ raw: settings.raw }) },
|
|
740
853
|
load: async () => {
|
|
741
854
|
if (options.loadThrows) {
|
|
742
855
|
throw new Error('settings registry unavailable');
|
package/src/index.ts
CHANGED
|
@@ -502,6 +502,39 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
502
502
|
}
|
|
503
503
|
});
|
|
504
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
|
+
|
|
505
538
|
console.log(
|
|
506
539
|
`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`
|
|
507
540
|
);
|