jupyterlab_ai_code_assistants_extension 1.0.32 → 1.0.39

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, while rows already rendered keep absolute paths until the
93
+ * next poll.
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.
@@ -707,10 +710,33 @@ export class AssistantSessionsPanel extends Widget {
707
710
  void this._fetch().catch(() => undefined);
708
711
  }
709
712
  }
713
+ /** The server root every path is shown and resolved against, plus
714
+ * JupyterLab's move-to-trash setting. Settable because a panel docked before
715
+ * the server has answered its first status probe is built without either
716
+ * (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.
719
+ *
720
+ * Trailing slashes go, but never the root itself: serving JupyterLab at
721
+ * `/` used to normalise to the empty string, which reads as "no root" -
722
+ * the + button then did nothing at all, and Open Terminal and Show in File
723
+ * Browser answered "outside the JupyterLab root" for every row inside it. */
724
+ setRoot(rootDir, deleteToTrash) {
725
+ var _a;
726
+ const root = (rootDir || '').replace(/\/+$/, '');
727
+ this._rootDir = root || (rootDir ? '/' : '');
728
+ this._deleteToTrash = deleteToTrash;
729
+ (_a = this._renameNewButton) === null || _a === void 0 ? void 0 : _a.call(this);
730
+ }
710
731
  /** Absolute path of the file browser's current folder; the server root when
711
732
  * no file browser is available. */
712
733
  _currentFolder() {
713
734
  var _a, _b, _c;
735
+ // No roster yet (DEF-132): a relative folder joined to an empty root
736
+ // would be an absolute path at the filesystem root.
737
+ if (!this._rootDir) {
738
+ return '';
739
+ }
714
740
  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
741
  if (!rel) {
716
742
  return this._rootDir;
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 &&
@@ -226,7 +228,13 @@ const plugin = {
226
228
  }
227
229
  if (enabled && available) {
228
230
  if (live.has(id)) {
229
- applySharedSettings(live.get(id).panel);
231
+ const panel = live.get(id).panel;
232
+ applySharedSettings(panel);
233
+ // A panel docked before the first roster was built without the
234
+ // server root, and cannot resolve paths until it has one.
235
+ if (roster !== null) {
236
+ panel.setRoot((_e = roster.root_dir) !== null && _e !== void 0 ? _e : '', roster.delete_to_trash !== false);
237
+ }
230
238
  }
231
239
  else {
232
240
  start(id);
@@ -323,8 +331,8 @@ const plugin = {
323
331
  void probeStatus().then(reconcile);
324
332
  }, STATUS_INTERVAL_MS);
325
333
  // 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
334
+ // when the probe fails, and the slow cadence above would leave the roster
335
+ // stale for up to a minute after the machine comes back. A wake fires
328
336
  // both events, so it costs two probes; deliberately, because a guard that
329
337
  // let the second join the first answered the wake with a request issued
330
338
  // 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.39",
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",
@@ -622,3 +622,43 @@ describe('DEF-46 - the row tooltip names only a mode the row launches with', ()
622
622
  expect(text).not.toContain('Launch mode');
623
623
  });
624
624
  });
625
+
626
+ describe('DEF-132 - a panel docked before the first roster has no root', () => {
627
+ function rootless(): AssistantSessionsPanel {
628
+ return new AssistantSessionsPanel({
629
+ app: {
630
+ serviceManager: { serverSettings: {} },
631
+ commands: { execute: jest.fn() }
632
+ } as any,
633
+ descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-panel-spec' },
634
+ rootDir: '',
635
+ fileBrowser: { model: { path: 'data/raw' } } as any
636
+ });
637
+ }
638
+
639
+ it('the + button is inert until setRoot supplies the root', async () => {
640
+ const p = rootless();
641
+ await flush();
642
+ request.mockClear();
643
+
644
+ // Without the guard this is `/data/raw` - a folder at the filesystem
645
+ // root, which the server accepts whenever it happens to exist.
646
+ expect((p as any)._currentFolder()).toBe('');
647
+ await (p as any)._newSession();
648
+ expect(request).not.toHaveBeenCalled();
649
+ // And the button says so, rather than looking live and doing nothing.
650
+ const newBtn = p.node.querySelector<HTMLButtonElement>(
651
+ '.jp-AiAssistantsPanel-iconButton'
652
+ )!;
653
+ expect(newBtn.disabled).toBe(true);
654
+ expect(newBtn.title).toBe('Waiting for the server root');
655
+
656
+ p.setRoot('/srv/lab/', true);
657
+ expect((p as any)._currentFolder()).toBe('/srv/lab/data/raw');
658
+ expect(newBtn.disabled).toBe(false);
659
+ expect(newBtn.title).toMatch(/^New session in current folder/);
660
+ p.setRoot('/', true);
661
+ expect((p as any)._currentFolder()).toBe('/data/raw');
662
+ p.dispose();
663
+ });
664
+ });
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.
@@ -841,9 +842,32 @@ export class AssistantSessionsPanel extends Widget {
841
842
  }
842
843
  }
843
844
 
845
+ /** The server root every path is shown and resolved against, plus
846
+ * JupyterLab's move-to-trash setting. Settable because a panel docked before
847
+ * the server has answered its first status probe is built without either
848
+ * (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.
851
+ *
852
+ * Trailing slashes go, but never the root itself: serving JupyterLab at
853
+ * `/` used to normalise to the empty string, which reads as "no root" -
854
+ * the + button then did nothing at all, and Open Terminal and Show in File
855
+ * Browser answered "outside the JupyterLab root" for every row inside it. */
856
+ setRoot(rootDir: string, deleteToTrash: boolean): void {
857
+ const root = (rootDir || '').replace(/\/+$/, '');
858
+ this._rootDir = root || (rootDir ? '/' : '');
859
+ this._deleteToTrash = deleteToTrash;
860
+ this._renameNewButton?.();
861
+ }
862
+
844
863
  /** Absolute path of the file browser's current folder; the server root when
845
864
  * no file browser is available. */
846
865
  private _currentFolder(): string {
866
+ // No roster yet (DEF-132): a relative folder joined to an empty root
867
+ // would be an absolute path at the filesystem root.
868
+ if (!this._rootDir) {
869
+ return '';
870
+ }
847
871
  const rel = (this._fileBrowser?.model?.path ?? '').replace(/^\/+/, '');
848
872
  if (!rel) {
849
873
  return this._rootDir;
@@ -2603,8 +2627,8 @@ export class AssistantSessionsPanel extends Widget {
2603
2627
  private readonly _descriptor: IProviderDescriptor;
2604
2628
  private readonly _hooks: IProviderHooks;
2605
2629
  private readonly _serverSettings: ServerConnection.ISettings;
2606
- private readonly _rootDir: string;
2607
- private readonly _deleteToTrash: boolean;
2630
+ private _rootDir = '';
2631
+ private _deleteToTrash = true;
2608
2632
  private readonly _fileBrowser: IDefaultFileBrowser | null;
2609
2633
  private readonly _colours: ColourStore;
2610
2634
  private readonly _terminals: TerminalManager;
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 (
@@ -296,7 +298,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
296
298
  }
297
299
  if (enabled && available) {
298
300
  if (live.has(id)) {
299
- applySharedSettings(live.get(id)!.panel);
301
+ const panel = live.get(id)!.panel;
302
+ applySharedSettings(panel);
303
+ // A panel docked before the first roster was built without the
304
+ // server root, and cannot resolve paths until it has one.
305
+ if (roster !== null) {
306
+ panel.setRoot(
307
+ roster.root_dir ?? '',
308
+ roster.delete_to_trash !== false
309
+ );
310
+ }
300
311
  } else {
301
312
  start(id);
302
313
  }
@@ -410,8 +421,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
410
421
  }, STATUS_INTERVAL_MS);
411
422
 
412
423
  // 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
424
+ // when the probe fails, and the slow cadence above would leave the roster
425
+ // stale for up to a minute after the machine comes back. A wake fires
415
426
  // both events, so it costs two probes; deliberately, because a guard that
416
427
  // let the second join the first answered the wake with a request issued
417
428
  // 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
  }