jupyterlab_ai_code_assistants_extension 1.0.32 → 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.
@@ -85,6 +85,18 @@ export declare class AssistantSessionsPanel extends Widget {
85
85
  * `resolveLaunchMode` for the precedence rule. */
86
86
  private _launchMode;
87
87
  private _openSession;
88
+ /** The server root every path is shown and resolved against, plus
89
+ * JupyterLab's move-to-trash setting. Settable because a panel docked before
90
+ * the server has answered its first status probe is built without either
91
+ * (DEF-132); the action paths read them lazily, so a late value applies on
92
+ * the next click, and a root arriving after rows were drawn redraws them
93
+ * once (DEF-136).
94
+ *
95
+ * Trailing slashes go, but never the root itself: serving JupyterLab at
96
+ * `/` used to normalise to the empty string, which reads as "no root" -
97
+ * the + button then did nothing at all, and Open Terminal and Show in File
98
+ * Browser answered "outside the JupyterLab root" for every row inside it. */
99
+ setRoot(rootDir: string, deleteToTrash: boolean): void;
88
100
  /** Absolute path of the file browser's current folder; the server root when
89
101
  * no file browser is available. */
90
102
  private _currentFolder;
@@ -271,8 +283,8 @@ export declare class AssistantSessionsPanel extends Widget {
271
283
  private readonly _descriptor;
272
284
  private readonly _hooks;
273
285
  private readonly _serverSettings;
274
- private readonly _rootDir;
275
- private readonly _deleteToTrash;
286
+ private _rootDir;
287
+ private _deleteToTrash;
276
288
  private readonly _fileBrowser;
277
289
  private readonly _colours;
278
290
  private readonly _terminals;
package/lib/core/panel.js CHANGED
@@ -94,6 +94,8 @@ export class AssistantSessionsPanel extends Widget {
94
94
  constructor(options) {
95
95
  var _a, _b, _c, _d;
96
96
  super();
97
+ this._rootDir = '';
98
+ this._deleteToTrash = true;
97
99
  this._removingPaths = new Set();
98
100
  this._loadingEl = null;
99
101
  this._errorEl = null;
@@ -132,13 +134,7 @@ export class AssistantSessionsPanel extends Widget {
132
134
  this._descriptor = options.descriptor;
133
135
  this._hooks = (_a = options.hooks) !== null && _a !== void 0 ? _a : {};
134
136
  this._serverSettings = options.app.serviceManager.serverSettings;
135
- // Trailing slashes go, but never the root itself: serving JupyterLab at
136
- // `/` used to normalise to the empty string, which reads as "no root" -
137
- // the + button then did nothing at all, and Open Terminal and Show in File
138
- // Browser answered "outside the JupyterLab root" for every row inside it.
139
- const root = (options.rootDir || '').replace(/\/+$/, '');
140
- this._rootDir = root || (options.rootDir ? '/' : '');
141
- this._deleteToTrash = options.deleteToTrash !== false;
137
+ this.setRoot(options.rootDir, options.deleteToTrash !== false);
142
138
  this._fileBrowser = (_b = options.fileBrowser) !== null && _b !== void 0 ? _b : null;
143
139
  this._expandedKey = `jupyterlab_ai_code_assistants_extension:${this._descriptor.id}:expanded`;
144
140
  this._expanded = loadExpanded(this._expandedKey);
@@ -259,8 +255,15 @@ export class AssistantSessionsPanel extends Widget {
259
255
  // and this button LAUNCHES on click instead of dropping a menu, so without
260
256
  // the suffix the one surface that starts an approval-free session is the
261
257
  // only one that never says so (DEF-36, DEF-113, DEF-114).
258
+ // Disabled, not silent, while the server root is unknown (a panel docked
259
+ // before the first status roster, DEF-132): a healthy-looking button that
260
+ // does nothing on click cannot be diagnosed by the user. `setRoot` re-runs
261
+ // this once the root lands, so the state is data-driven, not timed.
262
262
  const nameNewButton = () => {
263
- newBtn.title = `New session in current folder${this._variantSuffix()}`;
263
+ newBtn.disabled = !this._rootDir;
264
+ newBtn.title = this._rootDir
265
+ ? `New session in current folder${this._variantSuffix()}`
266
+ : 'Waiting for the server root';
264
267
  };
265
268
  // Focus as well as hover, or the icon-only button's accessible name is the
266
269
  // generic one for everybody not using a mouse.
@@ -505,6 +508,18 @@ export class AssistantSessionsPanel extends Widget {
505
508
  _showError(err) {
506
509
  const message = err instanceof Error ? err.message : String(err);
507
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
+ }
508
523
  const copy = isRequestTimeout(err)
509
524
  ? 'The server is not answering - retrying.'
510
525
  : 'Could not reach the server - retrying.';
@@ -707,10 +722,44 @@ export class AssistantSessionsPanel extends Widget {
707
722
  void this._fetch().catch(() => undefined);
708
723
  }
709
724
  }
725
+ /** The server root every path is shown and resolved against, plus
726
+ * JupyterLab's move-to-trash setting. Settable because a panel docked before
727
+ * the server has answered its first status probe is built without either
728
+ * (DEF-132); the action paths read them lazily, so a late value applies on
729
+ * the next click, and a root arriving after rows were drawn redraws them
730
+ * once (DEF-136).
731
+ *
732
+ * Trailing slashes go, but never the root itself: serving JupyterLab at
733
+ * `/` used to normalise to the empty string, which reads as "no root" -
734
+ * the + button then did nothing at all, and Open Terminal and Show in File
735
+ * Browser answered "outside the JupyterLab root" for every row inside it. */
736
+ setRoot(rootDir, deleteToTrash) {
737
+ var _a;
738
+ const root = (rootDir || '').replace(/\/+$/, '');
739
+ const next = root || (rootDir ? '/' : '');
740
+ const changed = next !== this._rootDir;
741
+ this._rootDir = next;
742
+ this._deleteToTrash = deleteToTrash;
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
+ }
753
+ }
710
754
  /** Absolute path of the file browser's current folder; the server root when
711
755
  * no file browser is available. */
712
756
  _currentFolder() {
713
757
  var _a, _b, _c;
758
+ // No roster yet (DEF-132): a relative folder joined to an empty root
759
+ // would be an absolute path at the filesystem root.
760
+ if (!this._rootDir) {
761
+ return '';
762
+ }
714
763
  const rel = ((_c = (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
715
764
  if (!rel) {
716
765
  return this._rootDir;
@@ -1744,7 +1793,11 @@ export class AssistantSessionsPanel extends Widget {
1744
1793
  // absolute filesystem path, so it goes through _pathUnderRoot.
1745
1794
  const rel = this._pathUnderRoot(s.project_path);
1746
1795
  if (rel === null) {
1747
- 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 });
1748
1801
  return;
1749
1802
  }
1750
1803
  this._app.commands
@@ -1762,7 +1815,11 @@ export class AssistantSessionsPanel extends Widget {
1762
1815
  }
1763
1816
  const rel = this._pathUnderRoot(s.project_path);
1764
1817
  if (rel === null) {
1765
- 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 });
1766
1823
  return;
1767
1824
  }
1768
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
@@ -64,34 +64,27 @@ const plugin = {
64
64
  let status = null;
65
65
  let settings = null;
66
66
  let sidebar = DEFAULT_SIDEBAR;
67
- let probeGeneration = 0;
68
67
  /** Ask the server which providers exist and which of their binaries are on
69
- * PATH. An unsuperseded failure leaves the extension activated with no
70
- * panels until a later probe succeeds - never a dialog, never a thrown
71
- * activation.
68
+ * PATH.
72
69
  *
73
- * Probes overlap: a probe can outlive one issued after it, so verdicts do
74
- * not arrive in the order they were asked for. A ROSTER is always written,
75
- * whichever settles last - both are answers from the same server, and the
76
- * loser is at worst one client deadline stale (DEF-125). A FAILURE is not an answer, so it
77
- * is written only while no newer probe has been issued; otherwise a
78
- * timeout could discard a roster the server has since confirmed, undocking
79
- * every panel while it is healthy (DEF-121). No request is suppressed. */
70
+ * `status` is the last roster the server gave, and null means it has never
71
+ * given one - two different facts that used to share one value. A probe
72
+ * that FAILS writes nothing: not answering is not an answer, and reading it
73
+ * as "no assistant is installed" is what emptied the sidebar on every
74
+ * reload whose first request landed before the server was listening
75
+ * (DEF-132), and on every wake whose probe fired before the network was
76
+ * back (DEF-119). No request is suppressed, and a failure never needs
77
+ * ordering against a success, because it has nothing to write. */
80
78
  const probeStatus = async () => {
81
- const mine = ++probeGeneration;
82
79
  try {
83
80
  status = await requestAPI('status', serverSettings, {
84
81
  cache: 'no-store'
85
82
  });
86
83
  }
87
84
  catch (err) {
88
- // No cadence named here: this also prints from the activation probe,
89
- // and neither the interval nor the wake listeners are armed until
90
- // after `await migrate()` below (DEF-122).
91
- console.warn(`${LOG_PREFIX} status probe failed; panels dock again once a later probe succeeds.`, err);
92
- if (mine === probeGeneration) {
93
- status = null;
94
- }
85
+ // No cadence named: this also prints from the activation probe, before
86
+ // the interval and the wake listeners are armed (DEF-122).
87
+ console.warn(`${LOG_PREFIX} status probe failed; the last known roster stands (every enabled assistant, when there is none yet) until a later probe answers.`, err);
95
88
  }
96
89
  };
97
90
  const statusFor = (id) => { var _a, _b; return (_b = (_a = status === null || status === void 0 ? void 0 : status.providers) === null || _a === void 0 ? void 0 : _a.find(p => p.id === id)) !== null && _b !== void 0 ? _b : null; };
@@ -153,7 +146,10 @@ const plugin = {
153
146
  descriptor: module.descriptor,
154
147
  hooks: module.hooks,
155
148
  rootDir: (_a = status === null || status === void 0 ? void 0 : status.root_dir) !== null && _a !== void 0 ? _a : '',
156
- deleteToTrash: (status === null || status === void 0 ? void 0 : status.delete_to_trash) !== false,
149
+ // Unknown reads as "not trash": the popup then arms its Delete
150
+ // button before it executes, which is the only confirmation a
151
+ // permanent bulk delete has (DEF-135).
152
+ deleteToTrash: status ? status.delete_to_trash !== false : false,
157
153
  terminalTracker,
158
154
  fileBrowser,
159
155
  colourfulTabs
@@ -188,7 +184,7 @@ const plugin = {
188
184
  /** Bring the docked panels in line with settings and CLI availability.
189
185
  * Both must hold for a panel to be present. */
190
186
  const reconcile = () => {
191
- var _a, _b, _c, _d;
187
+ var _a, _b, _c, _d, _e;
192
188
  for (const descriptor of registry.descriptors) {
193
189
  const id = descriptor.id;
194
190
  if (suppressed.has(id)) {
@@ -196,16 +192,22 @@ const plugin = {
196
192
  }
197
193
  const enabled = setting(`providers.${id}.enabled`, true) !== false;
198
194
  const probe = statusFor(id);
199
- const available = probe ? probe.available !== false : false;
195
+ // Unknown is not absent. With no roster yet, an enabled assistant is
196
+ // docked: JupyterLab restores the sidebar tab it had open only if that
197
+ // tab exists when activation ends, and it waits for activation but
198
+ // not for a later probe - so a panel that arrives a minute late is one
199
+ // the layout has already given up on (DEF-132). Once a roster exists
200
+ // the server's word on each binary is final.
201
+ const roster = status;
202
+ const available = roster === null ? true : probe !== null && probe.available !== false;
200
203
  // Enabled but binary absent must not be a silent state: the user sees
201
204
  // a missing panel with no clue that PATH under the Jupyter server
202
205
  // differs from their shell. Once per id, not per reconcile.
203
206
  // Only when the probe SUCCEEDED and this provider is the one missing.
204
- // A failed probe sets every provider unavailable, and blaming PATH for
205
- // a server the frontend never reached names the wrong cause; and every
206
- // provider defaults to enabled, so a machine with one assistant
207
- // installed would otherwise raise three toasts about assistants the
208
- // user has never heard of. The console line stays for that case.
207
+ // Blaming PATH for a server the frontend never reached names the wrong
208
+ // cause; and every provider defaults to enabled, so a machine with one
209
+ // assistant installed would otherwise raise three toasts about assistants
210
+ // the user has never heard of. The console line stays for that case.
209
211
  const chosen = (settings === null || settings === void 0 ? void 0 : settings.get(`providers.${id}.enabled`).user) !== undefined;
210
212
  if (probe !== null &&
211
213
  probe.available === false &&
@@ -224,9 +226,26 @@ const plugin = {
224
226
  // gets a notification, so the COMMON failure was the quiet one.
225
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 });
226
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
+ }
227
240
  if (enabled && available) {
228
241
  if (live.has(id)) {
229
- applySharedSettings(live.get(id).panel);
242
+ const panel = live.get(id).panel;
243
+ applySharedSettings(panel);
244
+ // A panel docked before the first roster was built without the
245
+ // server root, and cannot resolve paths until it has one.
246
+ if (roster !== null) {
247
+ panel.setRoot((_e = roster.root_dir) !== null && _e !== void 0 ? _e : '', roster.delete_to_trash !== false);
248
+ }
230
249
  }
231
250
  else {
232
251
  start(id);
@@ -323,8 +342,8 @@ const plugin = {
323
342
  void probeStatus().then(reconcile);
324
343
  }, STATUS_INTERVAL_MS);
325
344
  // A suspended network stack - laptop asleep, tab backgrounded - is exactly
326
- // when the probe fails, and the slow cadence above would leave the sidebar
327
- // panel-less for up to a minute after the machine comes back. A wake fires
345
+ // when the probe fails, and the slow cadence above would leave the roster
346
+ // stale for up to a minute after the machine comes back. A wake fires
328
347
  // both events, so it costs two probes; deliberately, because a guard that
329
348
  // let the second join the first answered the wake with a request issued
330
349
  // before it, and starved the interval above of its own tick (DEF-117).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.0.32",
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';
@@ -622,3 +627,167 @@ describe('DEF-46 - the row tooltip names only a mode the row launches with', ()
622
627
  expect(text).not.toContain('Launch mode');
623
628
  });
624
629
  });
630
+
631
+ describe('DEF-132 - a panel docked before the first roster has no root', () => {
632
+ function rootless(): AssistantSessionsPanel {
633
+ return new AssistantSessionsPanel({
634
+ app: {
635
+ serviceManager: { serverSettings: {} },
636
+ commands: { execute: jest.fn() }
637
+ } as any,
638
+ descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-panel-spec' },
639
+ rootDir: '',
640
+ fileBrowser: { model: { path: 'data/raw' } } as any
641
+ });
642
+ }
643
+
644
+ it('the + button is inert until setRoot supplies the root', async () => {
645
+ const p = rootless();
646
+ await flush();
647
+ request.mockClear();
648
+
649
+ // Without the guard this is `/data/raw` - a folder at the filesystem
650
+ // root, which the server accepts whenever it happens to exist.
651
+ expect((p as any)._currentFolder()).toBe('');
652
+ await (p as any)._newSession();
653
+ expect(request).not.toHaveBeenCalled();
654
+ // And the button says so, rather than looking live and doing nothing.
655
+ const newBtn = p.node.querySelector<HTMLButtonElement>(
656
+ '.jp-AiAssistantsPanel-iconButton'
657
+ )!;
658
+ expect(newBtn.disabled).toBe(true);
659
+ expect(newBtn.title).toBe('Waiting for the server root');
660
+
661
+ p.setRoot('/srv/lab/', true);
662
+ expect((p as any)._currentFolder()).toBe('/srv/lab/data/raw');
663
+ expect(newBtn.disabled).toBe(false);
664
+ expect(newBtn.title).toMatch(/^New session in current folder/);
665
+ p.setRoot('/', true);
666
+ expect((p as any)._currentFolder()).toBe('/data/raw');
667
+ p.dispose();
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
+ });
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
@@ -171,13 +171,7 @@ export class AssistantSessionsPanel extends Widget {
171
171
  this._descriptor = options.descriptor;
172
172
  this._hooks = options.hooks ?? {};
173
173
  this._serverSettings = options.app.serviceManager.serverSettings;
174
- // Trailing slashes go, but never the root itself: serving JupyterLab at
175
- // `/` used to normalise to the empty string, which reads as "no root" -
176
- // the + button then did nothing at all, and Open Terminal and Show in File
177
- // Browser answered "outside the JupyterLab root" for every row inside it.
178
- const root = (options.rootDir || '').replace(/\/+$/, '');
179
- this._rootDir = root || (options.rootDir ? '/' : '');
180
- this._deleteToTrash = options.deleteToTrash !== false;
174
+ this.setRoot(options.rootDir, options.deleteToTrash !== false);
181
175
  this._fileBrowser = options.fileBrowser ?? null;
182
176
  this._expandedKey = `jupyterlab_ai_code_assistants_extension:${this._descriptor.id}:expanded`;
183
177
  this._expanded = loadExpanded(this._expandedKey);
@@ -323,8 +317,15 @@ export class AssistantSessionsPanel extends Widget {
323
317
  // and this button LAUNCHES on click instead of dropping a menu, so without
324
318
  // the suffix the one surface that starts an approval-free session is the
325
319
  // only one that never says so (DEF-36, DEF-113, DEF-114).
320
+ // Disabled, not silent, while the server root is unknown (a panel docked
321
+ // before the first status roster, DEF-132): a healthy-looking button that
322
+ // does nothing on click cannot be diagnosed by the user. `setRoot` re-runs
323
+ // this once the root lands, so the state is data-driven, not timed.
326
324
  const nameNewButton = (): void => {
327
- newBtn.title = `New session in current folder${this._variantSuffix()}`;
325
+ newBtn.disabled = !this._rootDir;
326
+ newBtn.title = this._rootDir
327
+ ? `New session in current folder${this._variantSuffix()}`
328
+ : 'Waiting for the server root';
328
329
  };
329
330
  // Focus as well as hover, or the icon-only button's accessible name is the
330
331
  // generic one for everybody not using a mouse.
@@ -589,6 +590,22 @@ export class AssistantSessionsPanel extends Widget {
589
590
  private _showError(err: unknown): void {
590
591
  const message = err instanceof Error ? err.message : String(err);
591
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
+
592
609
  const copy = isRequestTimeout(err)
593
610
  ? 'The server is not answering - retrying.'
594
611
  : 'Could not reach the server - retrying.';
@@ -841,9 +858,43 @@ export class AssistantSessionsPanel extends Widget {
841
858
  }
842
859
  }
843
860
 
861
+ /** The server root every path is shown and resolved against, plus
862
+ * JupyterLab's move-to-trash setting. Settable because a panel docked before
863
+ * the server has answered its first status probe is built without either
864
+ * (DEF-132); the action paths read them lazily, so a late value applies on
865
+ * the next click, and a root arriving after rows were drawn redraws them
866
+ * once (DEF-136).
867
+ *
868
+ * Trailing slashes go, but never the root itself: serving JupyterLab at
869
+ * `/` used to normalise to the empty string, which reads as "no root" -
870
+ * the + button then did nothing at all, and Open Terminal and Show in File
871
+ * Browser answered "outside the JupyterLab root" for every row inside it. */
872
+ setRoot(rootDir: string, deleteToTrash: boolean): void {
873
+ const root = (rootDir || '').replace(/\/+$/, '');
874
+ const next = root || (rootDir ? '/' : '');
875
+ const changed = next !== this._rootDir;
876
+ this._rootDir = next;
877
+ this._deleteToTrash = deleteToTrash;
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
+ }
888
+ }
889
+
844
890
  /** Absolute path of the file browser's current folder; the server root when
845
891
  * no file browser is available. */
846
892
  private _currentFolder(): string {
893
+ // No roster yet (DEF-132): a relative folder joined to an empty root
894
+ // would be an absolute path at the filesystem root.
895
+ if (!this._rootDir) {
896
+ return '';
897
+ }
847
898
  const rel = (this._fileBrowser?.model?.path ?? '').replace(/^\/+/, '');
848
899
  if (!rel) {
849
900
  return this._rootDir;
@@ -2054,8 +2105,12 @@ export class AssistantSessionsPanel extends Widget {
2054
2105
  // absolute filesystem path, so it goes through _pathUnderRoot.
2055
2106
  const rel = this._pathUnderRoot(s.project_path);
2056
2107
  if (rel === null) {
2108
+ // A root the server has not sent yet is not a folder outside it
2109
+ // (DEF-134).
2057
2110
  Notification.warning(
2058
- '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.',
2059
2114
  { autoClose: 4000 }
2060
2115
  );
2061
2116
  return;
@@ -2081,8 +2136,12 @@ export class AssistantSessionsPanel extends Widget {
2081
2136
  }
2082
2137
  const rel = this._pathUnderRoot(s.project_path);
2083
2138
  if (rel === null) {
2139
+ // A root the server has not sent yet is not a folder outside it
2140
+ // (DEF-134).
2084
2141
  Notification.warning(
2085
- '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.',
2086
2145
  { autoClose: 4000 }
2087
2146
  );
2088
2147
  return;
@@ -2603,8 +2662,8 @@ export class AssistantSessionsPanel extends Widget {
2603
2662
  private readonly _descriptor: IProviderDescriptor;
2604
2663
  private readonly _hooks: IProviderHooks;
2605
2664
  private readonly _serverSettings: ServerConnection.ISettings;
2606
- private readonly _rootDir: string;
2607
- private readonly _deleteToTrash: boolean;
2665
+ private _rootDir = '';
2666
+ private _deleteToTrash = true;
2608
2667
  private readonly _fileBrowser: IDefaultFileBrowser | null;
2609
2668
  private readonly _colours: ColourStore;
2610
2669
  private readonly _terminals: TerminalManager;
@@ -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
@@ -116,37 +116,29 @@ const plugin: JupyterFrontEndPlugin<void> = {
116
116
  let settings: ISettingRegistry.ISettings | null = null;
117
117
  let sidebar: Sidebar = DEFAULT_SIDEBAR;
118
118
 
119
- let probeGeneration = 0;
120
-
121
119
  /** Ask the server which providers exist and which of their binaries are on
122
- * PATH. An unsuperseded failure leaves the extension activated with no
123
- * panels until a later probe succeeds - never a dialog, never a thrown
124
- * activation.
120
+ * PATH.
125
121
  *
126
- * Probes overlap: a probe can outlive one issued after it, so verdicts do
127
- * not arrive in the order they were asked for. A ROSTER is always written,
128
- * whichever settles last - both are answers from the same server, and the
129
- * loser is at worst one client deadline stale (DEF-125). A FAILURE is not an answer, so it
130
- * is written only while no newer probe has been issued; otherwise a
131
- * timeout could discard a roster the server has since confirmed, undocking
132
- * every panel while it is healthy (DEF-121). No request is suppressed. */
122
+ * `status` is the last roster the server gave, and null means it has never
123
+ * given one - two different facts that used to share one value. A probe
124
+ * that FAILS writes nothing: not answering is not an answer, and reading it
125
+ * as "no assistant is installed" is what emptied the sidebar on every
126
+ * reload whose first request landed before the server was listening
127
+ * (DEF-132), and on every wake whose probe fired before the network was
128
+ * back (DEF-119). No request is suppressed, and a failure never needs
129
+ * ordering against a success, because it has nothing to write. */
133
130
  const probeStatus = async (): Promise<void> => {
134
- const mine = ++probeGeneration;
135
131
  try {
136
132
  status = await requestAPI<IStatusResponse>('status', serverSettings, {
137
133
  cache: 'no-store'
138
134
  });
139
135
  } catch (err) {
140
- // No cadence named here: this also prints from the activation probe,
141
- // and neither the interval nor the wake listeners are armed until
142
- // after `await migrate()` below (DEF-122).
136
+ // No cadence named: this also prints from the activation probe, before
137
+ // the interval and the wake listeners are armed (DEF-122).
143
138
  console.warn(
144
- `${LOG_PREFIX} status probe failed; panels dock again once a later probe succeeds.`,
139
+ `${LOG_PREFIX} status probe failed; the last known roster stands (every enabled assistant, when there is none yet) until a later probe answers.`,
145
140
  err
146
141
  );
147
- if (mine === probeGeneration) {
148
- status = null;
149
- }
150
142
  }
151
143
  };
152
144
 
@@ -214,7 +206,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
214
206
  descriptor: module.descriptor,
215
207
  hooks: module.hooks,
216
208
  rootDir: status?.root_dir ?? '',
217
- deleteToTrash: status?.delete_to_trash !== false,
209
+ // Unknown reads as "not trash": the popup then arms its Delete
210
+ // button before it executes, which is the only confirmation a
211
+ // permanent bulk delete has (DEF-135).
212
+ deleteToTrash: status ? status.delete_to_trash !== false : false,
218
213
  terminalTracker,
219
214
  fileBrowser,
220
215
  colourfulTabs
@@ -258,16 +253,23 @@ const plugin: JupyterFrontEndPlugin<void> = {
258
253
  const enabled =
259
254
  setting<boolean>(`providers.${id}.enabled`, true) !== false;
260
255
  const probe = statusFor(id);
261
- const available = probe ? probe.available !== false : false;
256
+ // Unknown is not absent. With no roster yet, an enabled assistant is
257
+ // docked: JupyterLab restores the sidebar tab it had open only if that
258
+ // tab exists when activation ends, and it waits for activation but
259
+ // not for a later probe - so a panel that arrives a minute late is one
260
+ // the layout has already given up on (DEF-132). Once a roster exists
261
+ // the server's word on each binary is final.
262
+ const roster = status;
263
+ const available =
264
+ roster === null ? true : probe !== null && probe.available !== false;
262
265
  // Enabled but binary absent must not be a silent state: the user sees
263
266
  // a missing panel with no clue that PATH under the Jupyter server
264
267
  // differs from their shell. Once per id, not per reconcile.
265
268
  // Only when the probe SUCCEEDED and this provider is the one missing.
266
- // A failed probe sets every provider unavailable, and blaming PATH for
267
- // a server the frontend never reached names the wrong cause; and every
268
- // provider defaults to enabled, so a machine with one assistant
269
- // installed would otherwise raise three toasts about assistants the
270
- // user has never heard of. The console line stays for that case.
269
+ // Blaming PATH for a server the frontend never reached names the wrong
270
+ // cause; and every provider defaults to enabled, so a machine with one
271
+ // assistant installed would otherwise raise three toasts about assistants
272
+ // the user has never heard of. The console line stays for that case.
271
273
  const chosen =
272
274
  settings?.get(`providers.${id}.enabled`).user !== undefined;
273
275
  if (
@@ -294,9 +296,29 @@ const plugin: JupyterFrontEndPlugin<void> = {
294
296
  { autoClose: 8000 }
295
297
  );
296
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
+ }
297
310
  if (enabled && available) {
298
311
  if (live.has(id)) {
299
- applySharedSettings(live.get(id)!.panel);
312
+ const panel = live.get(id)!.panel;
313
+ applySharedSettings(panel);
314
+ // A panel docked before the first roster was built without the
315
+ // server root, and cannot resolve paths until it has one.
316
+ if (roster !== null) {
317
+ panel.setRoot(
318
+ roster.root_dir ?? '',
319
+ roster.delete_to_trash !== false
320
+ );
321
+ }
300
322
  } else {
301
323
  start(id);
302
324
  }
@@ -410,8 +432,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
410
432
  }, STATUS_INTERVAL_MS);
411
433
 
412
434
  // A suspended network stack - laptop asleep, tab backgrounded - is exactly
413
- // when the probe fails, and the slow cadence above would leave the sidebar
414
- // panel-less for up to a minute after the machine comes back. A wake fires
435
+ // when the probe fails, and the slow cadence above would leave the roster
436
+ // stale for up to a minute after the machine comes back. A wake fires
415
437
  // both events, so it costs two probes; deliberately, because a guard that
416
438
  // let the second join the first answered the wake with a request issued
417
439
  // before it, and starved the interval above of its own tick (DEF-117).
package/style/base.css CHANGED
@@ -61,7 +61,14 @@
61
61
  color: var(--jp-ui-font-color2);
62
62
  }
63
63
 
64
- .jp-AiAssistantsPanel-iconButton:hover {
64
+ /* The + button while the server root is unknown (DEF-132): the glyph is an
65
+ icon fill, not `color`, so the UA's disabled styling never reaches it. */
66
+ .jp-AiAssistantsPanel-iconButton:disabled {
67
+ cursor: default;
68
+ opacity: 0.4;
69
+ }
70
+
71
+ .jp-AiAssistantsPanel-iconButton:hover:not(:disabled) {
65
72
  background: var(--jp-layout-color3);
66
73
  color: var(--jp-ui-font-color1);
67
74
  }