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 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.41",
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 = { load: async () => settings } as any;
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 settingRegistry = composite
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
- load: async () => ({
383
- composite,
384
- get: (key: string) => ({
385
- composite: composite[key],
386
- user: userSet.has(key) ? composite[key] : undefined
387
- }),
388
- set: async () => undefined,
389
- changed: new Signal<unknown, void>({})
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 { app, docked, restored, probes: () => call };
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
  );