jupyterlab_ai_code_assistants_extension 1.0.39 → 1.0.41

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.
@@ -89,8 +89,8 @@ export declare class AssistantSessionsPanel extends Widget {
89
89
  * JupyterLab's move-to-trash setting. Settable because a panel docked before
90
90
  * the server has answered its first status probe is built without either
91
91
  * (DEF-132); the action paths read them lazily, so a late value applies on
92
- * the next click, while rows already rendered keep absolute paths until the
93
- * next poll.
92
+ * the next click, and a root arriving after rows were drawn redraws them
93
+ * once (DEF-136).
94
94
  *
95
95
  * Trailing slashes go, but never the root itself: serving JupyterLab at
96
96
  * `/` used to normalise to the empty string, which reads as "no root" -
package/lib/core/panel.js CHANGED
@@ -508,6 +508,18 @@ export class AssistantSessionsPanel extends Widget {
508
508
  _showError(err) {
509
509
  const message = err instanceof Error ? err.message : String(err);
510
510
  this._logError(message);
511
+ // DEF-138: the server ANSWERED, and its answer is that this assistant's
512
+ // binary is not on the PATH the Jupyter server runs with. Blaming the
513
+ // server names the wrong cause - the panel is docked only because no
514
+ // roster has arrived yet, and the roster is what removes it (DEF-132).
515
+ // The error code is matched as well as the status, because a 503 raised
516
+ // by a proxy or a stopped single-user server is NOT this: its body never
517
+ // parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
518
+ if (isResponseStatus(err, 503) && message === 'cli_not_found') {
519
+ this._setInlineError(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
520
+ `server's PATH.`);
521
+ return;
522
+ }
511
523
  const copy = isRequestTimeout(err)
512
524
  ? 'The server is not answering - retrying.'
513
525
  : 'Could not reach the server - retrying.';
@@ -714,8 +726,8 @@ export class AssistantSessionsPanel extends Widget {
714
726
  * JupyterLab's move-to-trash setting. Settable because a panel docked before
715
727
  * the server has answered its first status probe is built without either
716
728
  * (DEF-132); the action paths read them lazily, so a late value applies on
717
- * the next click, while rows already rendered keep absolute paths until the
718
- * next poll.
729
+ * the next click, and a root arriving after rows were drawn redraws them
730
+ * once (DEF-136).
719
731
  *
720
732
  * Trailing slashes go, but never the root itself: serving JupyterLab at
721
733
  * `/` used to normalise to the empty string, which reads as "no root" -
@@ -724,9 +736,20 @@ export class AssistantSessionsPanel extends Widget {
724
736
  setRoot(rootDir, deleteToTrash) {
725
737
  var _a;
726
738
  const root = (rootDir || '').replace(/\/+$/, '');
727
- this._rootDir = root || (rootDir ? '/' : '');
739
+ const next = root || (rootDir ? '/' : '');
740
+ const changed = next !== this._rootDir;
741
+ this._rootDir = next;
728
742
  this._deleteToTrash = deleteToTrash;
729
743
  (_a = this._renameNewButton) === null || _a === void 0 ? void 0 : _a.call(this);
744
+ // Guarded on the VALUE, not on the call: `reconcile` re-sends the same
745
+ // root every status tick, so a changed-guard redraws exactly once, on the
746
+ // '' -> root transition. `_sessions` stands in for "the shell is built and
747
+ // rows are on screen" - it is null until the first `_fetch` answers, which
748
+ // is what keeps the constructor's own `setRoot` from reaching `_render`
749
+ // before `_buildShell` has created `_bodyEl`.
750
+ if (changed && this._sessions !== null) {
751
+ this._render();
752
+ }
730
753
  }
731
754
  /** Absolute path of the file browser's current folder; the server root when
732
755
  * no file browser is available. */
@@ -1770,7 +1793,11 @@ export class AssistantSessionsPanel extends Widget {
1770
1793
  // absolute filesystem path, so it goes through _pathUnderRoot.
1771
1794
  const rel = this._pathUnderRoot(s.project_path);
1772
1795
  if (rel === null) {
1773
- Notification.warning('Folder is outside the JupyterLab root - cannot open a terminal there.', { autoClose: 4000 });
1796
+ // A root the server has not sent yet is not a folder outside it
1797
+ // (DEF-134).
1798
+ Notification.warning(this._rootDir
1799
+ ? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
1800
+ : 'Waiting for the server root - cannot open a terminal yet.', { autoClose: 4000 });
1774
1801
  return;
1775
1802
  }
1776
1803
  this._app.commands
@@ -1788,7 +1815,11 @@ export class AssistantSessionsPanel extends Widget {
1788
1815
  }
1789
1816
  const rel = this._pathUnderRoot(s.project_path);
1790
1817
  if (rel === null) {
1791
- Notification.warning('Folder is outside the JupyterLab root - the file browser cannot show it.', { autoClose: 4000 });
1818
+ // A root the server has not sent yet is not a folder outside it
1819
+ // (DEF-134).
1820
+ Notification.warning(this._rootDir
1821
+ ? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
1822
+ : 'Waiting for the server root - the file browser cannot show it yet.', { autoClose: 4000 });
1792
1823
  return;
1793
1824
  }
1794
1825
  this._app.commands
@@ -56,6 +56,7 @@ export declare function isRequestTimeout(err: unknown): boolean;
56
56
  * @returns The response body interpreted as JSON
57
57
  * @throws RequestTimeoutError when the deadline passes before the server
58
58
  * answers
59
+ * @throws Error when a 200 carries a body this helper cannot parse
59
60
  */
60
61
  export declare function requestAPI<T>(endPoint: string, serverSettings: ServerConnection.ISettings, init?: RequestInit): Promise<T>;
61
62
  /** Build a provider-scoped path: `providers/<id>/<path>`. The id is encoded so
@@ -63,6 +63,7 @@ export function isRequestTimeout(err) {
63
63
  * @returns The response body interpreted as JSON
64
64
  * @throws RequestTimeoutError when the deadline passes before the server
65
65
  * answers
66
+ * @throws Error when a 200 carries a body this helper cannot parse
66
67
  */
67
68
  export async function requestAPI(endPoint, serverSettings, init = {}) {
68
69
  var _a, _b;
@@ -111,7 +112,24 @@ export async function requestAPI(endPoint, serverSettings, init = {}) {
111
112
  data = JSON.parse(data);
112
113
  }
113
114
  catch (_c) {
114
- console.log(`${LOG_PREFIX} Not a JSON response body.`, response);
115
+ if (response.ok) {
116
+ // DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
117
+ // a 200 this helper cannot parse did not come from this server - it is
118
+ // an SSO or proxy interstitial served in its place. Returning the raw
119
+ // string let every caller read `undefined` off it: `probeStatus` wrote
120
+ // a roster with no `providers` and `reconcile` undocked all four
121
+ // panels, `_fetch` rendered an empty session list, and
122
+ // `ColourStore.load` replaced the cache with an empty map. Refusing it
123
+ // makes the probe FAIL, and a failed probe writes nothing (DEF-132),
124
+ // so the last known roster stands.
125
+ throw new Error(`Response to ${endPoint} was not JSON (HTTP ${response.status})`);
126
+ }
127
+ // Not ok: the `ResponseError` below carries this body as its message, so
128
+ // this side is no longer a silent path and a proxy's error page here is
129
+ // noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
130
+ // only on the refusal side; the 200 side is now louder than the `warn`
131
+ // it asked for.
132
+ console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
115
133
  }
116
134
  }
117
135
  if (!response.ok) {
package/lib/index.js CHANGED
@@ -226,6 +226,17 @@ const plugin = {
226
226
  // gets a notification, so the COMMON failure was the quiet one.
227
227
  Notification.warning(`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`, { autoClose: 8000 });
228
228
  }
229
+ // The latch is per ABSENCE, not per page load. Once the server reports
230
+ // the binary present again, a later disappearance gets its own warning:
231
+ // without this, one transient absence - a CLI upgrade unlinking the
232
+ // binary for a moment, or an obsolete roster from an overlapping probe -
233
+ // permanently suppresses the real one (DEF-125). It cannot make the
234
+ // warning repeat while the state is unchanged, which is what the
235
+ // once-per-id latch above exists to prevent: the two conditions are
236
+ // mutually exclusive.
237
+ if (probe !== null && probe.available !== false) {
238
+ warnedUnavailable.delete(id);
239
+ }
229
240
  if (enabled && available) {
230
241
  if (live.has(id)) {
231
242
  const panel = live.get(id).panel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.0.39",
3
+ "version": "1.0.41",
4
4
  "description": "Jupyterlab extension to replace individual jupyterlab_*_code_extension and integrate into one, that offers joint capability, joint settings page and individual right toolbar panels per code assistant, with the ability in settings to decide which code assistant support is on (default - all are enabled)",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The plugin's own activation path - the only tier that reaches it.
3
+ *
4
+ * `src/index.ts` carried no Jest coverage at all (0 of 151 lines), and the
5
+ * Galata DEF-132 spec stops at the panels docking, before the first probe ever
6
+ * answers. The branch below is what makes a panel docked without a roster
7
+ * usable once one arrives: `reconcile` pushes the late `root_dir` and trash
8
+ * flag into the already-live panel (DEF-GUARD-139).
9
+ *
10
+ * Nothing here waits on a timer. The activation probe fails, the roster is
11
+ * delivered by dispatching `online`, and the promise chain is drained through
12
+ * the microtask queue.
13
+ */
14
+
15
+ // `@jupyterlab/apputils`' barrel reaches two ESM-only packages the repo jest
16
+ // config's `esModules` allowlist does not carry - the same two `panel.spec.ts`
17
+ // stubs, for the same reason.
18
+ jest.mock('@jupyter/react-components', () => ({}));
19
+ jest.mock('@jupyter/web-components', () => ({
20
+ addJupyterLabThemeChangeListener: () => undefined,
21
+ applyJupyterTheme: () => undefined,
22
+ jpButton: () => undefined,
23
+ jpToolbar: () => undefined,
24
+ provideJupyterDesignSystem: () => ({ register: () => undefined })
25
+ }));
26
+ // Both tokens are named in the plugin's `optional:` list, so both packages
27
+ // load for real; `@jupyterlab/terminal` pulls the ESM-only `color` package and
28
+ // the tab extension ships untransformed ESM. Neither token is used here.
29
+ jest.mock('@jupyterlab/terminal', () => ({ ITerminalTracker: {} }));
30
+ jest.mock('jupyterlab_colourful_tab_extension', () => ({ IColourfulTabs: {} }));
31
+ // No route in this spec reaches the server.
32
+ jest.mock('../core/request', () => ({
33
+ requestAPI: jest.fn(),
34
+ requestProvider: jest.fn(() => Promise.resolve({})),
35
+ isRequestTimeout: () => false,
36
+ isResponseStatus: () => false,
37
+ withQuery: (path: string) => path
38
+ }));
39
+
40
+ import { CommandRegistry } from '@lumino/commands';
41
+ import { Signal } from '@lumino/signaling';
42
+
43
+ import { AssistantSessionsPanel } from '../core/panel';
44
+ import { requestAPI } from '../core/request';
45
+ import plugin from '../index';
46
+
47
+ const request = requestAPI as jest.Mock;
48
+
49
+ /** Drain the microtask queue - `probeStatus().then(reconcile)` and nothing
50
+ * longer-lived, so no timer is involved. */
51
+ async function flush(turns = 8): Promise<void> {
52
+ for (let i = 0; i < turns; i += 1) {
53
+ await Promise.resolve();
54
+ }
55
+ }
56
+
57
+ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () => {
58
+ it('a panel docked before the first roster gains the root and the trash flag', async () => {
59
+ let roster: unknown = null;
60
+ request.mockImplementation((path: string) =>
61
+ path === 'status' && roster
62
+ ? Promise.resolve(roster)
63
+ : Promise.reject(new Error('server down'))
64
+ );
65
+
66
+ const docked: AssistantSessionsPanel[] = [];
67
+ const labShell = {
68
+ add: (w: AssistantSessionsPanel) => {
69
+ docked.push(w);
70
+ }
71
+ } as any;
72
+ const app = {
73
+ serviceManager: { serverSettings: {} },
74
+ commands: new CommandRegistry(),
75
+ hasPlugin: () => false
76
+ } as any;
77
+ const settings = {
78
+ composite: {},
79
+ get: () => ({ composite: undefined, user: undefined }),
80
+ set: async () => undefined,
81
+ changed: new Signal<unknown, void>({})
82
+ };
83
+ const settingRegistry = { load: async () => settings } as any;
84
+ const fileBrowser = { model: { path: 'data/raw' } } as any;
85
+
86
+ await (plugin.activate as any)(
87
+ app,
88
+ labShell,
89
+ null,
90
+ settingRegistry,
91
+ null,
92
+ fileBrowser,
93
+ null
94
+ );
95
+
96
+ // The activation probe failed, so every enabled assistant docked with no
97
+ // root and, with the roster unknown, with trash treated as unavailable.
98
+ const panel = docked.find(w => w.node.dataset.provider === 'claude')!;
99
+ expect(panel).toBeDefined();
100
+ expect((panel as any)._rootDir).toBe('');
101
+ expect((panel as any)._deleteToTrash).toBe(false);
102
+
103
+ // A later probe answers. The panel is already live, so `start` is not the
104
+ // path that carries the root to it - `reconcile` is.
105
+ roster = {
106
+ providers: [
107
+ { id: 'claude', available: true },
108
+ { id: 'codex', available: true },
109
+ { id: 'kimi', available: true },
110
+ { id: 'gemini', available: true }
111
+ ],
112
+ root_dir: '/srv/lab',
113
+ delete_to_trash: true
114
+ };
115
+ window.dispatchEvent(new Event('online'));
116
+ await flush();
117
+
118
+ // How this fails: drop the `panel.setRoot(...)` call in `reconcile` and
119
+ // the root stays empty forever - the + button never arms and every path
120
+ // resolution answers "outside the JupyterLab root".
121
+ expect((panel as any)._rootDir).toBe('/srv/lab');
122
+ expect((panel as any)._deleteToTrash).toBe(true);
123
+ expect((panel as any)._currentFolder()).toBe('/srv/lab/data/raw');
124
+
125
+ docked.forEach(w => w.dispose());
126
+ });
127
+ });
@@ -37,10 +37,15 @@ jest.mock('@jupyter/web-components', () => ({
37
37
  // before each act rather than asserted from construction.
38
38
  jest.mock('../core/request', () => ({
39
39
  requestProvider: jest.fn(() => Promise.resolve({})),
40
- isResponseStatus: () => false,
40
+ // Shape-based rather than `instanceof`, so a test can hand the panel the
41
+ // refusal a route answers with without building a real `Response`.
42
+ isRequestTimeout: (err: any) => err?.name === 'RequestTimeoutError',
43
+ isResponseStatus: (err: any, status: number) =>
44
+ err?.response?.status === status,
41
45
  withQuery: (path: string) => path
42
46
  }));
43
47
 
48
+ import { Notification } from '@jupyterlab/apputils';
44
49
  import { TAB_COLOUR_IDS, fnv1aColour } from '../core/colour';
45
50
  import { addIcon, branchIcon, cleanupIcon, shieldIcon } from '../core/icons';
46
51
  import { AssistantSessionsPanel, commandId } from '../core/panel';
@@ -661,4 +666,128 @@ describe('DEF-132 - a panel docked before the first roster has no root', () => {
661
666
  expect((p as any)._currentFolder()).toBe('/data/raw');
662
667
  p.dispose();
663
668
  });
669
+
670
+ it('the path actions name the missing root, not a folder outside it', async () => {
671
+ const p = rootless();
672
+ await flush();
673
+ const warn = jest.spyOn(Notification, 'warning').mockReturnValue('' as any);
674
+
675
+ // The folder is INSIDE the root the server has not sent yet, so blaming
676
+ // the root is the one diagnosis that cannot be acted on (DEF-134).
677
+ (p as any)._activeSession = session({ project_path: '/srv/lab/proj' });
678
+ await (p as any)._commands.execute(commandId('testbed', 'open-terminal'));
679
+ await (p as any)._commands.execute(
680
+ commandId('testbed', 'show-in-filebrowser')
681
+ );
682
+ expect(warn.mock.calls.map(c => String(c[0]))).toEqual([
683
+ 'Waiting for the server root - cannot open a terminal yet.',
684
+ 'Waiting for the server root - the file browser cannot show it yet.'
685
+ ]);
686
+
687
+ // And the outside case is still named, rather than deleted with it.
688
+ warn.mockClear();
689
+ p.setRoot('/srv/lab', true);
690
+ (p as any)._activeSession = session({ project_path: '/elsewhere/proj' });
691
+ await (p as any)._commands.execute(commandId('testbed', 'open-terminal'));
692
+ expect(String(warn.mock.calls[0][0])).toContain(
693
+ 'outside the JupyterLab root'
694
+ );
695
+ p.dispose();
696
+ });
697
+
698
+ // DEF-136 - the root can land after the rows are already on screen.
699
+
700
+ it('a root arriving after the rows redraws their tooltips', async () => {
701
+ const p = rootless();
702
+ await flush();
703
+ render(p, [session()]);
704
+
705
+ // Pre-roster the absolute path is the honest fallback.
706
+ const row = p.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-row')!;
707
+ expect(row.title).toContain('Path: /home/user/proj');
708
+
709
+ p.setRoot('/home/user', true);
710
+ expect(
711
+ p.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-row')!.title
712
+ ).toContain('Path: proj');
713
+ p.dispose();
714
+ });
715
+
716
+ it('a root arriving after the rows redraws their labels in path mode', async () => {
717
+ const p = rootless();
718
+ await flush();
719
+ p.setPresentationMode('path');
720
+ render(p, [session()]);
721
+ expect(
722
+ p.node.querySelector('.jp-AiAssistantsPanel-nameText')!.textContent
723
+ ).toBe('/home/user/proj');
724
+
725
+ p.setRoot('/home/user', true);
726
+ expect(
727
+ p.node.querySelector('.jp-AiAssistantsPanel-nameText')!.textContent
728
+ ).toBe('proj');
729
+ p.dispose();
730
+ });
731
+
732
+ it('an unchanged root does not redraw - the reconcile tick repeats it', async () => {
733
+ const p = rootless();
734
+ await flush();
735
+ render(p, [session()]);
736
+ p.setRoot('/home/user', true);
737
+
738
+ const spy = jest.spyOn(p as any, '_render');
739
+ p.setRoot('/home/user', true);
740
+ p.setRoot('/home/user/', true);
741
+ expect(spy).not.toHaveBeenCalled();
742
+ p.dispose();
743
+ });
744
+ });
745
+
746
+ // --------------------------------------------------------------- DEF-138
747
+
748
+ describe('DEF-138 - a refused listing names the missing binary', () => {
749
+ const banner = (): string =>
750
+ panel.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-error')!
751
+ .textContent ?? '';
752
+
753
+ beforeEach(() => {
754
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
755
+ });
756
+
757
+ /** What `requestAPI` throws for the server's `{"error": "cli_not_found"}` -
758
+ * the only 503 a listing can be refused with. */
759
+ const cliNotFound = (): Error =>
760
+ Object.assign(new Error('cli_not_found'), {
761
+ name: 'ResponseError',
762
+ response: { status: 503 }
763
+ });
764
+
765
+ it('names the binary instead of blaming the server', () => {
766
+ (panel as any)._showError(cliNotFound());
767
+ expect(banner()).toContain('`testbed` was not found');
768
+ expect(banner()).not.toContain('Could not reach the server');
769
+ });
770
+
771
+ it("leaves a 503 that is not this extension's own alone", () => {
772
+ // What a hub proxy or a stopped single-user server answers: a 503 whose
773
+ // body is HTML, so `requestAPI` hands the raw text through as the message.
774
+ (panel as any)._showError(
775
+ Object.assign(new Error('<html>503 Service Unavailable</html>'), {
776
+ name: 'ResponseError',
777
+ response: { status: 503 }
778
+ })
779
+ );
780
+ expect(banner()).toContain('Could not reach the server');
781
+ expect(banner()).not.toContain('was not found');
782
+ });
783
+
784
+ it('leaves the two unanswered-server verdicts alone', () => {
785
+ (panel as any)._showError(
786
+ Object.assign(new Error('timed out'), { name: 'RequestTimeoutError' })
787
+ );
788
+ expect(banner()).toContain('The server is not answering');
789
+
790
+ (panel as any)._showError(new TypeError('Failed to fetch'));
791
+ expect(banner()).toContain('Could not reach the server');
792
+ });
664
793
  });
@@ -195,3 +195,50 @@ describe('requestAPI with a caller-supplied signal', () => {
195
195
  ).resolves.toEqual({ ok: true });
196
196
  });
197
197
  });
198
+
199
+ describe('requestAPI on a body it cannot parse', () => {
200
+ it('refuses a 200 that is not JSON instead of returning the raw string', async () => {
201
+ // DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so a
202
+ // 200 this helper cannot parse came from something in front of the server.
203
+ // Returned as data, `status.providers` read `undefined` and every panel
204
+ // undocked; thrown, the probe FAILS and the last roster stands (DEF-132).
205
+ makeRequest.mockResolvedValue({
206
+ ok: true,
207
+ status: 200,
208
+ text: async () =>
209
+ '<!doctype html><html><head><title>Sign in</title></head><body>SSO</body></html>'
210
+ } as unknown as Response);
211
+
212
+ await expect(requestAPI('status', SETTINGS)).rejects.toThrow(
213
+ /was not JSON/
214
+ );
215
+ });
216
+
217
+ it('still resolves an empty 200 body to the empty string', async () => {
218
+ // The `data.length > 0` guard is untouched: a 204-shaped answer is not a
219
+ // parse failure and must not become one.
220
+ makeRequest.mockResolvedValue({
221
+ ok: true,
222
+ status: 200,
223
+ text: async () => ''
224
+ } as unknown as Response);
225
+
226
+ await expect(requestAPI('status', SETTINGS)).resolves.toBe('');
227
+ });
228
+
229
+ it('keeps a refused non-JSON body a ResponseError', async () => {
230
+ // The refusal side is the DEF-116 case - a proxy error page - and it must
231
+ // keep carrying the body as the `ResponseError` message rather than
232
+ // becoming the new error.
233
+ makeRequest.mockResolvedValue({
234
+ ok: false,
235
+ status: 502,
236
+ text: async () => '<html>bad gateway</html>'
237
+ } as unknown as Response);
238
+
239
+ const error = await capture(requestAPI('status', SETTINGS));
240
+
241
+ expect(error).toBeInstanceOf(ServerConnection.ResponseError);
242
+ expect((error as Error).message).toContain('bad gateway');
243
+ });
244
+ });
package/src/core/panel.ts CHANGED
@@ -590,6 +590,22 @@ export class AssistantSessionsPanel extends Widget {
590
590
  private _showError(err: unknown): void {
591
591
  const message = err instanceof Error ? err.message : String(err);
592
592
  this._logError(message);
593
+
594
+ // DEF-138: the server ANSWERED, and its answer is that this assistant's
595
+ // binary is not on the PATH the Jupyter server runs with. Blaming the
596
+ // server names the wrong cause - the panel is docked only because no
597
+ // roster has arrived yet, and the roster is what removes it (DEF-132).
598
+ // The error code is matched as well as the status, because a 503 raised
599
+ // by a proxy or a stopped single-user server is NOT this: its body never
600
+ // parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
601
+ if (isResponseStatus(err, 503) && message === 'cli_not_found') {
602
+ this._setInlineError(
603
+ `\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
604
+ `server's PATH.`
605
+ );
606
+ return;
607
+ }
608
+
593
609
  const copy = isRequestTimeout(err)
594
610
  ? 'The server is not answering - retrying.'
595
611
  : 'Could not reach the server - retrying.';
@@ -846,8 +862,8 @@ export class AssistantSessionsPanel extends Widget {
846
862
  * JupyterLab's move-to-trash setting. Settable because a panel docked before
847
863
  * the server has answered its first status probe is built without either
848
864
  * (DEF-132); the action paths read them lazily, so a late value applies on
849
- * the next click, while rows already rendered keep absolute paths until the
850
- * next poll.
865
+ * the next click, and a root arriving after rows were drawn redraws them
866
+ * once (DEF-136).
851
867
  *
852
868
  * Trailing slashes go, but never the root itself: serving JupyterLab at
853
869
  * `/` used to normalise to the empty string, which reads as "no root" -
@@ -855,9 +871,20 @@ export class AssistantSessionsPanel extends Widget {
855
871
  * Browser answered "outside the JupyterLab root" for every row inside it. */
856
872
  setRoot(rootDir: string, deleteToTrash: boolean): void {
857
873
  const root = (rootDir || '').replace(/\/+$/, '');
858
- this._rootDir = root || (rootDir ? '/' : '');
874
+ const next = root || (rootDir ? '/' : '');
875
+ const changed = next !== this._rootDir;
876
+ this._rootDir = next;
859
877
  this._deleteToTrash = deleteToTrash;
860
878
  this._renameNewButton?.();
879
+ // Guarded on the VALUE, not on the call: `reconcile` re-sends the same
880
+ // root every status tick, so a changed-guard redraws exactly once, on the
881
+ // '' -> root transition. `_sessions` stands in for "the shell is built and
882
+ // rows are on screen" - it is null until the first `_fetch` answers, which
883
+ // is what keeps the constructor's own `setRoot` from reaching `_render`
884
+ // before `_buildShell` has created `_bodyEl`.
885
+ if (changed && this._sessions !== null) {
886
+ this._render();
887
+ }
861
888
  }
862
889
 
863
890
  /** Absolute path of the file browser's current folder; the server root when
@@ -2078,8 +2105,12 @@ export class AssistantSessionsPanel extends Widget {
2078
2105
  // absolute filesystem path, so it goes through _pathUnderRoot.
2079
2106
  const rel = this._pathUnderRoot(s.project_path);
2080
2107
  if (rel === null) {
2108
+ // A root the server has not sent yet is not a folder outside it
2109
+ // (DEF-134).
2081
2110
  Notification.warning(
2082
- 'Folder is outside the JupyterLab root - cannot open a terminal there.',
2111
+ this._rootDir
2112
+ ? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
2113
+ : 'Waiting for the server root - cannot open a terminal yet.',
2083
2114
  { autoClose: 4000 }
2084
2115
  );
2085
2116
  return;
@@ -2105,8 +2136,12 @@ export class AssistantSessionsPanel extends Widget {
2105
2136
  }
2106
2137
  const rel = this._pathUnderRoot(s.project_path);
2107
2138
  if (rel === null) {
2139
+ // A root the server has not sent yet is not a folder outside it
2140
+ // (DEF-134).
2108
2141
  Notification.warning(
2109
- 'Folder is outside the JupyterLab root - the file browser cannot show it.',
2142
+ this._rootDir
2143
+ ? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
2144
+ : 'Waiting for the server root - the file browser cannot show it yet.',
2110
2145
  { autoClose: 4000 }
2111
2146
  );
2112
2147
  return;
@@ -71,6 +71,7 @@ export function isRequestTimeout(err: unknown): boolean {
71
71
  * @returns The response body interpreted as JSON
72
72
  * @throws RequestTimeoutError when the deadline passes before the server
73
73
  * answers
74
+ * @throws Error when a 200 carries a body this helper cannot parse
74
75
  */
75
76
  export async function requestAPI<T>(
76
77
  endPoint: string,
@@ -129,7 +130,26 @@ export async function requestAPI<T>(
129
130
  try {
130
131
  data = JSON.parse(data);
131
132
  } catch {
132
- console.log(`${LOG_PREFIX} Not a JSON response body.`, response);
133
+ if (response.ok) {
134
+ // DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
135
+ // a 200 this helper cannot parse did not come from this server - it is
136
+ // an SSO or proxy interstitial served in its place. Returning the raw
137
+ // string let every caller read `undefined` off it: `probeStatus` wrote
138
+ // a roster with no `providers` and `reconcile` undocked all four
139
+ // panels, `_fetch` rendered an empty session list, and
140
+ // `ColourStore.load` replaced the cache with an empty map. Refusing it
141
+ // makes the probe FAIL, and a failed probe writes nothing (DEF-132),
142
+ // so the last known roster stands.
143
+ throw new Error(
144
+ `Response to ${endPoint} was not JSON (HTTP ${response.status})`
145
+ );
146
+ }
147
+ // Not ok: the `ResponseError` below carries this body as its message, so
148
+ // this side is no longer a silent path and a proxy's error page here is
149
+ // noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
150
+ // only on the refusal side; the 200 side is now louder than the `warn`
151
+ // it asked for.
152
+ console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
133
153
  }
134
154
  }
135
155
 
package/src/index.ts CHANGED
@@ -296,6 +296,17 @@ const plugin: JupyterFrontEndPlugin<void> = {
296
296
  { autoClose: 8000 }
297
297
  );
298
298
  }
299
+ // The latch is per ABSENCE, not per page load. Once the server reports
300
+ // the binary present again, a later disappearance gets its own warning:
301
+ // without this, one transient absence - a CLI upgrade unlinking the
302
+ // binary for a moment, or an obsolete roster from an overlapping probe -
303
+ // permanently suppresses the real one (DEF-125). It cannot make the
304
+ // warning repeat while the state is unchanged, which is what the
305
+ // once-per-id latch above exists to prevent: the two conditions are
306
+ // mutually exclusive.
307
+ if (probe !== null && probe.available !== false) {
308
+ warnedUnavailable.delete(id);
309
+ }
299
310
  if (enabled && available) {
300
311
  if (live.has(id)) {
301
312
  const panel = live.get(id)!.panel;