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 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
@@ -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 two neighbouring "an installed thing is wrong" announcements in
45
- // this extension both pair the console line with this toast. It does not
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, _d, _e;
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 must not be a silent state: the user sees
262
- // a missing panel with no clue that PATH under the Jupyter server
263
- // differs from their shell. Once per id, not per reconcile.
264
- // Only when the probe SUCCEEDED and this provider is the one missing.
265
- // Blaming PATH for a server the frontend never reached names the wrong
266
- // cause; and every provider defaults to enabled, so a machine with one
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 warning:
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
- // warning repeat while the state is unchanged, which is what the
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((_e = roster.root_dir) !== null && _e !== void 0 ? _e : '', roster.delete_to_trash !== false);
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.38",
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. */
@@ -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');
@@ -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 two neighbouring "an installed thing is wrong" announcements in
69
- // this extension both pair the console line with this toast. It does not
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 must not be a silent state: the user sees
337
- // a missing panel with no clue that PATH under the Jupyter server
338
- // differs from their shell. Once per id, not per reconcile.
339
- // Only when the probe SUCCEEDED and this provider is the one missing.
340
- // Blaming PATH for a server the frontend never reached names the wrong
341
- // cause; and every provider defaults to enabled, so a machine with one
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 warning:
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
- // warning repeat while the state is unchanged, which is what the
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
  );