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.
- package/lib/core/panel.d.ts +14 -2
- package/lib/core/panel.js +67 -10
- package/lib/core/request.d.ts +1 -0
- package/lib/core/request.js +19 -1
- package/lib/index.js +49 -30
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +127 -0
- package/src/__tests__/panel.spec.ts +170 -1
- package/src/__tests__/request.spec.ts +47 -0
- package/src/core/panel.ts +71 -12
- package/src/core/request.ts +21 -1
- package/src/index.ts +52 -30
- package/style/base.css +8 -1
package/lib/core/panel.d.ts
CHANGED
|
@@ -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
|
|
275
|
-
private
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/core/request.d.ts
CHANGED
|
@@ -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
|
package/lib/core/request.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
70
|
-
* panels until a later probe succeeds - never a dialog, never a thrown
|
|
71
|
-
* activation.
|
|
68
|
+
* PATH.
|
|
72
69
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
89
|
-
//
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
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
|
-
|
|
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
|
|
327
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2607
|
-
private
|
|
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;
|
package/src/core/request.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
123
|
-
* panels until a later probe succeeds - never a dialog, never a thrown
|
|
124
|
-
* activation.
|
|
120
|
+
* PATH.
|
|
125
121
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
141
|
-
//
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
-
|
|
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
|
|
414
|
-
//
|
|
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
|
-
|
|
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
|
}
|