jupyterlab_ai_code_assistants_extension 1.2.163 → 1.2.199
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/dialogs.d.ts +4 -2
- package/lib/core/dialogs.js +4 -2
- package/lib/core/icons.d.ts +5 -1
- package/lib/core/icons.js +13 -3
- package/lib/core/panel.js +4 -4
- package/lib/core/popup.js +12 -1
- package/lib/core/terminals.d.ts +14 -5
- package/lib/core/terminals.js +83 -16
- package/package.json +4 -1
- package/src/__tests__/icon-size.spec.ts +4 -2
- package/src/__tests__/launcher-icon.spec.ts +9 -3
- package/src/__tests__/panel.spec.ts +82 -73
- package/src/__tests__/popup.spec.ts +47 -0
- package/src/__tests__/terminals.spec.ts +116 -0
- package/src/core/dialogs.ts +4 -2
- package/src/core/icons.ts +16 -3
- package/src/core/panel.ts +6 -6
- package/src/core/popup.ts +12 -1
- package/src/core/terminals.ts +90 -17
- package/style/base.css +20 -7
package/lib/core/dialogs.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { Dialog } from '@jupyterlab/apputils';
|
|
|
2
2
|
import { Widget } from '@lumino/widgets';
|
|
3
3
|
/**
|
|
4
4
|
* The renderer every dialog of this extension is built with. It marks the
|
|
5
|
-
* footer, and `style/base.css` scopes its dialog rules - no focus rim on
|
|
6
|
-
* button
|
|
5
|
+
* footer, and `style/base.css` scopes its dialog rules - no focus rim on a
|
|
6
|
+
* button focused by the mouse or by a dialog opened with the mouse (keyboard
|
|
7
|
+
* focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
|
|
8
|
+
* Open Callback popup) as
|
|
7
9
|
* secondary surface buttons so the fill alone marks the primary - to a dialog
|
|
8
10
|
* that holds the mark, so JupyterLab's own
|
|
9
11
|
* dialogs keep their look. A renderer is the one hook that also reaches the
|
package/lib/core/dialogs.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Dialog } from '@jupyterlab/apputils';
|
|
2
2
|
/**
|
|
3
3
|
* The renderer every dialog of this extension is built with. It marks the
|
|
4
|
-
* footer, and `style/base.css` scopes its dialog rules - no focus rim on
|
|
5
|
-
* button
|
|
4
|
+
* footer, and `style/base.css` scopes its dialog rules - no focus rim on a
|
|
5
|
+
* button focused by the mouse or by a dialog opened with the mouse (keyboard
|
|
6
|
+
* focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
|
|
7
|
+
* Open Callback popup) as
|
|
6
8
|
* secondary surface buttons so the fill alone marks the primary - to a dialog
|
|
7
9
|
* that holds the mark, so JupyterLab's own
|
|
8
10
|
* dialogs keep their look. A renderer is the one hook that also reaches the
|
package/lib/core/icons.d.ts
CHANGED
|
@@ -28,7 +28,11 @@ export declare function providerIcon(name: string, svgstr: string): LabIcon;
|
|
|
28
28
|
* preset, and each tile under `launcherCard`. This view of `tile` renders the
|
|
29
29
|
* extension's joint icon under the section preset and `tile` everywhere else,
|
|
30
30
|
* so the header carries no vendor's mark while every tile keeps its own
|
|
31
|
-
* (ACC-LNCH-163).
|
|
31
|
+
* (ACC-LNCH-163). Under `launcherCard` it also adds the class
|
|
32
|
+
* `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
|
|
33
|
+
* so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
|
|
34
|
+
* the sidebar tab, panel header and menus keep the standard size
|
|
35
|
+
* (ACC-LNCH-215).
|
|
32
36
|
*
|
|
33
37
|
* Built the way `LabIcon.bindprops` builds its views - a prototype child of
|
|
34
38
|
* the tile with its own `react` - so `LabIcon.resolve` still recognises it.
|
package/lib/core/icons.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LabIcon } from '@jupyterlab/ui-components';
|
|
1
|
+
import { classes, LabIcon } from '@jupyterlab/ui-components';
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
// Shared panel chrome icons. Provider icons are NOT here - each provider
|
|
4
4
|
// carries its own SVG on its descriptor and the registry builds the LabIcon
|
|
@@ -148,7 +148,11 @@ export function providerIcon(name, svgstr) {
|
|
|
148
148
|
* preset, and each tile under `launcherCard`. This view of `tile` renders the
|
|
149
149
|
* extension's joint icon under the section preset and `tile` everywhere else,
|
|
150
150
|
* so the header carries no vendor's mark while every tile keeps its own
|
|
151
|
-
* (ACC-LNCH-163).
|
|
151
|
+
* (ACC-LNCH-163). Under `launcherCard` it also adds the class
|
|
152
|
+
* `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
|
|
153
|
+
* so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
|
|
154
|
+
* the sidebar tab, panel header and menus keep the standard size
|
|
155
|
+
* (ACC-LNCH-215).
|
|
152
156
|
*
|
|
153
157
|
* Built the way `LabIcon.bindprops` builds its views - a prototype child of
|
|
154
158
|
* the tile with its own `react` - so `LabIcon.resolve` still recognises it.
|
|
@@ -157,7 +161,13 @@ export function launcherTileIcon(tile) {
|
|
|
157
161
|
const view = Object.create(tile);
|
|
158
162
|
const react = React.forwardRef((props, ref) => React.createElement(props.stylesheet === 'launcherSection'
|
|
159
163
|
? assistantsIcon.react
|
|
160
|
-
: tile.react, {
|
|
164
|
+
: tile.react, {
|
|
165
|
+
...props,
|
|
166
|
+
...(props.stylesheet === 'launcherCard' && {
|
|
167
|
+
className: classes(props.className, 'jp-AiAssistantsLauncher-tileGlyph')
|
|
168
|
+
}),
|
|
169
|
+
ref
|
|
170
|
+
}));
|
|
161
171
|
// `react` is readonly on the type; the view shadows the tile's own.
|
|
162
172
|
Object.defineProperty(view, 'react', { value: react });
|
|
163
173
|
return view;
|
package/lib/core/panel.js
CHANGED
|
@@ -1057,10 +1057,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1057
1057
|
}, fork.session_id, spinner);
|
|
1058
1058
|
}
|
|
1059
1059
|
catch (err) {
|
|
1060
|
-
if (
|
|
1061
|
-
Notification.error(`${this._descriptor.label} cannot branch this conversation.`, { autoClose: 4000 });
|
|
1062
|
-
}
|
|
1063
|
-
else if (minted) {
|
|
1060
|
+
if (minted) {
|
|
1064
1061
|
// The branch was created and only its terminal was refused - the
|
|
1065
1062
|
// launch preflight's 409 carries words for the user. "Could not
|
|
1066
1063
|
// branch this session" would be false, and the early return would
|
|
@@ -1073,6 +1070,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1073
1070
|
this._notifyLaunchError('Branch created; its terminal did not open', err);
|
|
1074
1071
|
await this._fetch().catch(() => undefined);
|
|
1075
1072
|
}
|
|
1073
|
+
else if (isResponseStatus(err, 400)) {
|
|
1074
|
+
Notification.error(`${this._descriptor.label} cannot branch this conversation.`, { autoClose: 4000 });
|
|
1075
|
+
}
|
|
1076
1076
|
else {
|
|
1077
1077
|
this._showActionError('Could not branch this session - try again.', err);
|
|
1078
1078
|
}
|
package/lib/core/popup.js
CHANGED
|
@@ -50,6 +50,9 @@ export function showManageSessionsPopup(options) {
|
|
|
50
50
|
// removal is permanent.
|
|
51
51
|
let armed = false;
|
|
52
52
|
let armTimer = 0;
|
|
53
|
+
// The arming click's event.timeStamp, which the confirm click is measured
|
|
54
|
+
// against.
|
|
55
|
+
let armedAt = 0;
|
|
53
56
|
const body = document.createElement('div');
|
|
54
57
|
body.className = 'jp-AiAssistantsPanel-branchPopup';
|
|
55
58
|
const search = document.createElement('input');
|
|
@@ -348,10 +351,17 @@ export function showManageSessionsPopup(options) {
|
|
|
348
351
|
list.removeAttribute('aria-busy');
|
|
349
352
|
}
|
|
350
353
|
};
|
|
351
|
-
deleteBtn.addEventListener('click',
|
|
354
|
+
deleteBtn.addEventListener('click', event => {
|
|
352
355
|
if (deleting || selected.size === 0) {
|
|
353
356
|
return;
|
|
354
357
|
}
|
|
358
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
359
|
+
// a multi-click within 600 ms of arming is ignored. A later click confirms
|
|
360
|
+
// whatever its detail - the OS double-click interval can be longer than
|
|
361
|
+
// the user's pause, and the click would be dropped (DEF-PANE-338).
|
|
362
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
355
365
|
// No confirmation DIALOG here: a second Lumino dialog stacked on this popup
|
|
356
366
|
// renders detached. The deletion confirms in the footer itself - the first
|
|
357
367
|
// click arms the button, the second executes, and any selection change or
|
|
@@ -360,6 +370,7 @@ export function showManageSessionsPopup(options) {
|
|
|
360
370
|
// refused move may remove the conversation permanently or leave it.
|
|
361
371
|
if (!armed) {
|
|
362
372
|
armed = true;
|
|
373
|
+
armedAt = event.timeStamp;
|
|
363
374
|
window.clearTimeout(armTimer);
|
|
364
375
|
armTimer = window.setTimeout(() => {
|
|
365
376
|
disarm();
|
package/lib/core/terminals.d.ts
CHANGED
|
@@ -20,9 +20,10 @@ export declare function endSpinner(token: number): void;
|
|
|
20
20
|
* tint pass.
|
|
21
21
|
*
|
|
22
22
|
* Split out of the panel because none of it is presentation - it is process
|
|
23
|
-
* identity. The panel asks for a conversation and gets a
|
|
24
|
-
*
|
|
25
|
-
* about sections, filters or
|
|
23
|
+
* identity. The panel asks for a conversation and gets a terminal running
|
|
24
|
+
* exactly that conversation, or a fresh one, focused unless the user hid the
|
|
25
|
+
* action's spinner dialog; nothing in here knows about sections, filters or
|
|
26
|
+
* menus.
|
|
26
27
|
*/
|
|
27
28
|
export declare class TerminalManager {
|
|
28
29
|
constructor(options: TerminalManager.IOptions);
|
|
@@ -52,9 +53,13 @@ export declare class TerminalManager {
|
|
|
52
53
|
* `spinner` is the token of the click this launch belongs to. The launch
|
|
53
54
|
* joins that click's spinner instead of beginning its own, so the user sees
|
|
54
55
|
* one dialog from the click to the terminal, and a click the user hid stays
|
|
55
|
-
* hidden.
|
|
56
|
+
* hidden and its terminal opens behind the current tab.
|
|
56
57
|
*/
|
|
57
58
|
launch(request: ILaunchRequest, tagSessionId?: string, spinner?: number): Promise<any>;
|
|
59
|
+
/** Show the current widget's tab again. Opening a terminal selects its tab,
|
|
60
|
+
* so for a hidden action the tab the user was on goes back in front
|
|
61
|
+
* (DEF-PANE-339). */
|
|
62
|
+
private _showCurrentTab;
|
|
58
63
|
/**
|
|
59
64
|
* Bring a terminal tab to the front AND hand it keyboard focus, so typing
|
|
60
65
|
* starts without a second click. `activateById` only reveals the tab; the
|
|
@@ -68,8 +73,12 @@ export declare class TerminalManager {
|
|
|
68
73
|
* under it ends up without the keyboard. Closing emits `disposed` before
|
|
69
74
|
* that return, and the activation the tab then receives is processed after
|
|
70
75
|
* it, so the terminal is the last to take the focus.
|
|
76
|
+
*
|
|
77
|
+
* `spinner` is the token of the action that asked. When the user hid that
|
|
78
|
+
* action's dialog the terminal is left where it is, and so is a terminal
|
|
79
|
+
* waiting for the dialog the user then hides (DEF-PANE-339).
|
|
71
80
|
*/
|
|
72
|
-
focus(widget: any): void;
|
|
81
|
+
focus(widget: any, spinner?: number): void;
|
|
73
82
|
/** Find an open terminal running exactly this conversation in this project,
|
|
74
83
|
* or null.
|
|
75
84
|
*
|
package/lib/core/terminals.js
CHANGED
|
@@ -75,7 +75,8 @@ function titleSpinner(dialog, title) {
|
|
|
75
75
|
*
|
|
76
76
|
* Hide does not abort the request - it only takes the app-wide scrim away, so
|
|
77
77
|
* a stalled action cannot leave the user staring at a modal with no exit. If
|
|
78
|
-
* the action later succeeds the terminal
|
|
78
|
+
* the action later succeeds the terminal opens as a tab behind the current
|
|
79
|
+
* one (DEF-PANE-339). */
|
|
79
80
|
function showLaunchSpinner(title, trans) {
|
|
80
81
|
const body = new Widget();
|
|
81
82
|
body.node.className = 'jp-AiAssistantsPanel-launchOverlay';
|
|
@@ -87,10 +88,13 @@ function showLaunchSpinner(title, trans) {
|
|
|
87
88
|
title,
|
|
88
89
|
body,
|
|
89
90
|
// `Hide`, not `Cancel`. The button takes the scrim away and nothing else -
|
|
90
|
-
// the request is already on the wire and the terminal will still open
|
|
91
|
-
//
|
|
92
|
-
// button.
|
|
91
|
+
// the request is already on the wire and the terminal will still open, as
|
|
92
|
+
// a tab behind the current one that does not take the keyboard. A button
|
|
93
|
+
// named Cancel that does not cancel is worse than no button.
|
|
93
94
|
buttons: [Dialog.okButton({ label: trans.__('Hide') })],
|
|
95
|
+
// Hide is the only exit: the second click of a double-click on the row
|
|
96
|
+
// lands on the backdrop, and that is not a choice to hide (DEF-PANE-340).
|
|
97
|
+
hasClose: false,
|
|
94
98
|
renderer: spinnerRenderer
|
|
95
99
|
});
|
|
96
100
|
// Scopes the compact layout in base.css to this one dialog.
|
|
@@ -110,6 +114,12 @@ function showLaunchSpinner(title, trans) {
|
|
|
110
114
|
let spinnerDialog = null;
|
|
111
115
|
let spinnerSeq = 0;
|
|
112
116
|
const spinnerTitles = new Map();
|
|
117
|
+
/** Whether the user hid the dialog of `token`, an action still running. Hide
|
|
118
|
+
* disposes the dialog, and an action begun after it raises a fresh dialog that
|
|
119
|
+
* does not stand for the hidden one (DEF-PANE-339). */
|
|
120
|
+
function spinnerHidden(token) {
|
|
121
|
+
return (!spinnerDialog || spinnerDialog.isDisposed || !spinnerTitles.has(token));
|
|
122
|
+
}
|
|
113
123
|
/** Show the spinner dialog for one action, or retitle the one already up so it
|
|
114
124
|
* names the newest action. Pass the token to `endSpinner` when the action
|
|
115
125
|
* settles. Hide disposes the dialog for the actions then running: they stay
|
|
@@ -146,9 +156,10 @@ export function endSpinner(token) {
|
|
|
146
156
|
* tint pass.
|
|
147
157
|
*
|
|
148
158
|
* Split out of the panel because none of it is presentation - it is process
|
|
149
|
-
* identity. The panel asks for a conversation and gets a
|
|
150
|
-
*
|
|
151
|
-
* about sections, filters or
|
|
159
|
+
* identity. The panel asks for a conversation and gets a terminal running
|
|
160
|
+
* exactly that conversation, or a fresh one, focused unless the user hid the
|
|
161
|
+
* action's spinner dialog; nothing in here knows about sections, filters or
|
|
162
|
+
* menus.
|
|
152
163
|
*/
|
|
153
164
|
export class TerminalManager {
|
|
154
165
|
constructor(options) {
|
|
@@ -259,7 +270,7 @@ export class TerminalManager {
|
|
|
259
270
|
// when it carries the wanted conversation.
|
|
260
271
|
const cached = this._byProject.get(session.project_path);
|
|
261
272
|
if (cached && !cached.widget.isDisposed && cached.sessionId === wanted) {
|
|
262
|
-
this.focus(cached.widget);
|
|
273
|
+
this.focus(cached.widget, spinner);
|
|
263
274
|
return;
|
|
264
275
|
}
|
|
265
276
|
// 2. Walk every live terminal, asking the server what each is running in
|
|
@@ -273,7 +284,7 @@ export class TerminalManager {
|
|
|
273
284
|
sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
|
|
274
285
|
});
|
|
275
286
|
this._wireDisposal(session.project_path, found.widget);
|
|
276
|
-
this.focus(found.widget);
|
|
287
|
+
this.focus(found.widget, spinner);
|
|
277
288
|
return;
|
|
278
289
|
}
|
|
279
290
|
// 3. Fresh launch. The server picks the verb (resume, attach, bare) at
|
|
@@ -294,7 +305,7 @@ export class TerminalManager {
|
|
|
294
305
|
* `spinner` is the token of the click this launch belongs to. The launch
|
|
295
306
|
* joins that click's spinner instead of beginning its own, so the user sees
|
|
296
307
|
* one dialog from the click to the terminal, and a click the user hid stays
|
|
297
|
-
* hidden.
|
|
308
|
+
* hidden and its terminal opens behind the current tab.
|
|
298
309
|
*/
|
|
299
310
|
async launch(request, tagSessionId, spinner) {
|
|
300
311
|
const own = spinner === undefined
|
|
@@ -302,16 +313,43 @@ export class TerminalManager {
|
|
|
302
313
|
: undefined;
|
|
303
314
|
try {
|
|
304
315
|
const launched = await requestProvider(this._descriptor.id, 'launch', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
316
|
+
const token = spinner !== null && spinner !== void 0 ? spinner : own;
|
|
317
|
+
const name = launched.terminal_name;
|
|
318
|
+
// `terminal:open` shows the new terminal's tab and moves the focus into
|
|
319
|
+
// it before it returns. When the user has hidden this action's dialog,
|
|
320
|
+
// that focus is stopped before the shell sees it, as JupyterLab's Dialog
|
|
321
|
+
// stops focus that leaves it, so the user's widget stays the current one.
|
|
322
|
+
// The focus goes back to the element it left once the user's tab is
|
|
323
|
+
// shown again (DEF-PANE-339).
|
|
324
|
+
let left = null;
|
|
325
|
+
const keep = (event) => {
|
|
326
|
+
const target = event.target;
|
|
327
|
+
if (spinnerHidden(token) &&
|
|
328
|
+
this._liveTerminals().some(w => { var _a, _b; return ((_b = (_a = w.content) === null || _a === void 0 ? void 0 : _a.session) === null || _b === void 0 ? void 0 : _b.name) === name && w.node.contains(target); })) {
|
|
329
|
+
event.stopPropagation();
|
|
330
|
+
left = event.relatedTarget;
|
|
331
|
+
target.blur();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
document.addEventListener('focus', keep, true);
|
|
335
|
+
let widget;
|
|
336
|
+
try {
|
|
337
|
+
widget = await this._app.commands.execute('terminal:open', { name });
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
document.removeEventListener('focus', keep, true);
|
|
341
|
+
}
|
|
342
|
+
if (spinnerHidden(token)) {
|
|
343
|
+
this._showCurrentTab();
|
|
344
|
+
left === null || left === void 0 ? void 0 : left.focus();
|
|
345
|
+
}
|
|
308
346
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
309
347
|
this._byProject.set(request.project_path, {
|
|
310
348
|
widget,
|
|
311
349
|
sessionId: tagSessionId
|
|
312
350
|
});
|
|
313
351
|
this._wireDisposal(request.project_path, widget);
|
|
314
|
-
this.focus(widget);
|
|
352
|
+
this.focus(widget, token);
|
|
315
353
|
}
|
|
316
354
|
return widget !== null && widget !== void 0 ? widget : null;
|
|
317
355
|
}
|
|
@@ -321,6 +359,17 @@ export class TerminalManager {
|
|
|
321
359
|
}
|
|
322
360
|
}
|
|
323
361
|
}
|
|
362
|
+
/** Show the current widget's tab again. Opening a terminal selects its tab,
|
|
363
|
+
* so for a hidden action the tab the user was on goes back in front
|
|
364
|
+
* (DEF-PANE-339). */
|
|
365
|
+
_showCurrentTab() {
|
|
366
|
+
const shell = this._app.shell;
|
|
367
|
+
const current = shell.currentWidget;
|
|
368
|
+
const bar = current && shell.getMainAreaTabBar(current);
|
|
369
|
+
if (bar) {
|
|
370
|
+
bar.currentTitle = current.title;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
324
373
|
/**
|
|
325
374
|
* Bring a terminal tab to the front AND hand it keyboard focus, so typing
|
|
326
375
|
* starts without a second click. `activateById` only reveals the tab; the
|
|
@@ -334,13 +383,31 @@ export class TerminalManager {
|
|
|
334
383
|
* under it ends up without the keyboard. Closing emits `disposed` before
|
|
335
384
|
* that return, and the activation the tab then receives is processed after
|
|
336
385
|
* it, so the terminal is the last to take the focus.
|
|
386
|
+
*
|
|
387
|
+
* `spinner` is the token of the action that asked. When the user hid that
|
|
388
|
+
* action's dialog the terminal is left where it is, and so is a terminal
|
|
389
|
+
* waiting for the dialog the user then hides (DEF-PANE-339).
|
|
337
390
|
*/
|
|
338
|
-
focus(widget) {
|
|
391
|
+
focus(widget, spinner) {
|
|
339
392
|
if (!widget || widget.isDisposed) {
|
|
340
393
|
return;
|
|
341
394
|
}
|
|
395
|
+
if (spinner !== undefined && spinnerHidden(spinner)) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
342
398
|
if (spinnerDialog && !spinnerDialog.isDisposed) {
|
|
343
|
-
spinnerDialog.disposed.connect(() =>
|
|
399
|
+
spinnerDialog.disposed.connect(() => {
|
|
400
|
+
// The dialog closes on its own only with its last action. Closed with
|
|
401
|
+
// an action still running, the user hid it: the terminal is not
|
|
402
|
+
// activated, and its tab, shown behind the dialog, goes back behind
|
|
403
|
+
// the user's tab.
|
|
404
|
+
if (spinnerTitles.size > 0) {
|
|
405
|
+
this._showCurrentTab();
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
this.focus(widget);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
344
411
|
return;
|
|
345
412
|
}
|
|
346
413
|
this._app.shell.activateById(widget.id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.199",
|
|
4
4
|
"description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -169,5 +169,8 @@
|
|
|
169
169
|
"selector-no-vendor-prefix": null,
|
|
170
170
|
"value-no-vendor-prefix": null
|
|
171
171
|
}
|
|
172
|
+
},
|
|
173
|
+
"allowScripts": {
|
|
174
|
+
"@fortawesome/fontawesome-free": true
|
|
172
175
|
}
|
|
173
176
|
}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* icons (DEF-PANE-332).
|
|
4
4
|
*
|
|
5
5
|
* The Launcher card, the sidebar tab and the menus draw every icon in one box
|
|
6
|
-
* size, so how big a glyph looks there is fixed by the SVG
|
|
7
|
-
*
|
|
6
|
+
* size, so how big a glyph looks there is fixed by the SVG: the share of the
|
|
7
|
+
* viewBox its drawn paths cover. In the Launcher card the assistant svg is then
|
|
8
|
+
* scaled to 0.75 (ACC-LNCH-215, style/base.css), so the tile glyph is three
|
|
9
|
+
* quarters of that share. The reference is measured from
|
|
8
10
|
* JupyterLab's terminal icon, the Terminal card of the same Launcher, rather
|
|
9
11
|
* than written down, and each provider glyph is measured the same way.
|
|
10
12
|
*/
|
|
@@ -30,8 +30,10 @@ import { PROVIDERS } from '../providers';
|
|
|
30
30
|
|
|
31
31
|
type Preset = 'launcherSection' | 'launcherCard';
|
|
32
32
|
|
|
33
|
-
function markup(icon: LabIcon, stylesheet: Preset): string {
|
|
34
|
-
return renderToStaticMarkup(
|
|
33
|
+
function markup(icon: LabIcon, stylesheet: Preset, className?: string): string {
|
|
34
|
+
return renderToStaticMarkup(
|
|
35
|
+
React.createElement(icon.react, { stylesheet, className })
|
|
36
|
+
);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const tiles = PROVIDERS.map(module => [
|
|
@@ -55,7 +57,11 @@ describe('ACC-LNCH-163 - the section header draws the joint icon', () => {
|
|
|
55
57
|
|
|
56
58
|
it.each(tiles)('%s: its own glyph on the tile', (_id, tile) => {
|
|
57
59
|
const view = launcherTileIcon(tile);
|
|
58
|
-
|
|
60
|
+
// The tile's glyph carries the class style/base.css scales to 75%
|
|
61
|
+
// (ACC-LNCH-215).
|
|
62
|
+
expect(markup(view, 'launcherCard')).toBe(
|
|
63
|
+
markup(tile, 'launcherCard', 'jp-AiAssistantsLauncher-tileGlyph')
|
|
64
|
+
);
|
|
59
65
|
expect(markup(view, 'launcherCard')).not.toBe(
|
|
60
66
|
markup(assistantsIcon, 'launcherCard')
|
|
61
67
|
);
|
|
@@ -585,83 +585,92 @@ describe('click-time spinner dialog (DEF-PANE-318, DEF-PANE-327-BUSY-MODAL-1)',
|
|
|
585
585
|
});
|
|
586
586
|
|
|
587
587
|
describe('a branch whose terminal launch was refused after the mint', () => {
|
|
588
|
-
it
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
588
|
+
it.each<[number, string]>([
|
|
589
|
+
[
|
|
590
|
+
409,
|
|
591
|
+
'the TUI profile is not mounted; run dsh plugin --profile tui add @deepseek-harness-tui/dsh-tui'
|
|
592
|
+
],
|
|
593
|
+
// DEF-PANE-337: the launch preflight's 400 for a project folder that is
|
|
594
|
+
// gone is not the branch POST's 400, and must not read as one.
|
|
595
|
+
[400, 'invalid_project_path']
|
|
596
|
+
])(
|
|
597
|
+
'says the branch was created and still lists it when the launch is refused with %i',
|
|
598
|
+
async (status, refusal) => {
|
|
599
|
+
// The DeepSeek preflight refuses the follow-up launch with a 409 whose
|
|
600
|
+
// body is words, but by then the branch POST has already returned 200 with
|
|
601
|
+
// the minted id - the row exists server-side. The user is told that, and
|
|
602
|
+
// the refresh that surfaces the row still runs; "Could not branch this
|
|
603
|
+
// session" would report the whole action as failed and leave the branch
|
|
604
|
+
// invisible until the next poll.
|
|
605
|
+
const target = makePanel({ ...DESCRIPTOR, forkStrategy: 'server-copy' });
|
|
606
|
+
const parent = session({ name: 'analysis', name_source: 'session' });
|
|
607
|
+
const child = session({
|
|
608
|
+
name: 'analysis_1',
|
|
609
|
+
session_id: 'child',
|
|
610
|
+
encoded_path: 'enc-child'
|
|
611
|
+
});
|
|
612
|
+
(target as any)._terminals.launch = jest
|
|
613
|
+
.fn()
|
|
614
|
+
.mockRejectedValue(
|
|
615
|
+
Object.assign(new Error(refusal), { response: { status } })
|
|
616
|
+
);
|
|
617
|
+
(target as any)._activeSession = parent;
|
|
618
|
+
request.mockImplementation((_id: string, path: string) =>
|
|
619
|
+
Promise.resolve(
|
|
620
|
+
path === 'branch'
|
|
621
|
+
? { session_id: 'child' }
|
|
622
|
+
: path === 'sessions'
|
|
623
|
+
? { sessions: [parent, child] }
|
|
624
|
+
: {}
|
|
625
|
+
)
|
|
608
626
|
);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
: path === 'sessions'
|
|
615
|
-
? { sessions: [parent, child] }
|
|
616
|
-
: {}
|
|
617
|
-
)
|
|
618
|
-
);
|
|
619
|
-
const errored = jest
|
|
620
|
-
.spyOn(Notification, 'error')
|
|
621
|
-
.mockImplementation(() => '');
|
|
622
|
-
try {
|
|
623
|
-
await (target as any)._branchSession();
|
|
627
|
+
const errored = jest
|
|
628
|
+
.spyOn(Notification, 'error')
|
|
629
|
+
.mockImplementation(() => '');
|
|
630
|
+
try {
|
|
631
|
+
await (target as any)._branchSession();
|
|
624
632
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
633
|
+
// The refused launch still closes the click-time spinner dialog.
|
|
634
|
+
await flush();
|
|
635
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
628
636
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
637
|
+
// What the user is told: the branch was created, its terminal did not
|
|
638
|
+
// open, and the refusal's own words - neither sentence that blames the
|
|
639
|
+
// branch itself.
|
|
640
|
+
expect(errored).toHaveBeenCalledTimes(1);
|
|
641
|
+
const told = String(errored.mock.calls[0][0]);
|
|
642
|
+
expect(told).toContain('Branch created; its terminal did not open');
|
|
643
|
+
expect(told).toContain(refusal);
|
|
644
|
+
expect(told).not.toContain('cannot branch this conversation');
|
|
645
|
+
expect(told).not.toContain('Could not branch this session');
|
|
646
|
+
|
|
647
|
+
// The toast budget, by arithmetic rather than by eye: JupyterLab shows
|
|
648
|
+
// a toast's first 140 characters and clips the rest, and a store may
|
|
649
|
+
// raise a message of up to 97 (tests/test_provider_stores.py holds the
|
|
650
|
+
// DeepSeek refusal to that), so the panel's copy plus ": " gets
|
|
651
|
+
// 140 - 2 - 97 = 41 characters. This reddens when a reworded copy
|
|
652
|
+
// grows past the budget - the 55-character "The branch was created,
|
|
653
|
+
// but its terminal could not open" only showed the cut in a browser
|
|
654
|
+
// toast, with the store's command sliced mid-name.
|
|
655
|
+
const copy = told.slice(0, told.indexOf(refusal) - 2);
|
|
656
|
+
expect(copy.length + 2 + 97).toBeLessThanOrEqual(140);
|
|
657
|
+
|
|
658
|
+
// The list was re-read, so the new branch is on screen rather than
|
|
659
|
+
// waiting for the next poll.
|
|
660
|
+
expect(
|
|
661
|
+
(target as any)._sessions.map((s: ISession) => s.session_id)
|
|
662
|
+
).toContain('child');
|
|
663
|
+
const names = Array.from(
|
|
664
|
+
target.node.querySelectorAll('.jp-AiAssistantsPanel-nameText')
|
|
665
|
+
).map(node => node.textContent);
|
|
666
|
+
expect(names).toContain('analysis_1');
|
|
667
|
+
} finally {
|
|
668
|
+
errored.mockRestore();
|
|
669
|
+
target.dispose();
|
|
670
|
+
request.mockImplementation(() => Promise.resolve({}));
|
|
671
|
+
}
|
|
663
672
|
}
|
|
664
|
-
|
|
673
|
+
);
|
|
665
674
|
});
|
|
666
675
|
|
|
667
676
|
describe('DEF-46 - the row tooltip names only a mode the row launches with', () => {
|
|
@@ -276,4 +276,51 @@ describe('Manage Sessions popup permanent Delete', () => {
|
|
|
276
276
|
expect(del.textContent).toBe('Delete (1)');
|
|
277
277
|
expect(onDelete).not.toHaveBeenCalled();
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
it('DEF-PANE-336 - a double-click arms and does not delete', async () => {
|
|
281
|
+
checkbox(rows()[1]).click();
|
|
282
|
+
const del = deleteButton();
|
|
283
|
+
const click = (detail: number): void => {
|
|
284
|
+
del.dispatchEvent(new MouseEvent('click', { bubbles: true, detail }));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// The two clicks a double-click dispatches: detail 1, then detail 2.
|
|
288
|
+
click(1);
|
|
289
|
+
click(2);
|
|
290
|
+
await Promise.resolve();
|
|
291
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
292
|
+
expect(del.classList.contains('jp-mod-armed')).toBe(true);
|
|
293
|
+
|
|
294
|
+
// A separate click confirms.
|
|
295
|
+
click(1);
|
|
296
|
+
await Promise.resolve();
|
|
297
|
+
expect(onDelete).toHaveBeenCalledTimes(1);
|
|
298
|
+
expect(onDelete).toHaveBeenCalledWith([FIRST]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('DEF-PANE-338-CONFIRM-WINDOW - a multi-click 600 ms after arming confirms', async () => {
|
|
302
|
+
checkbox(rows()[1]).click();
|
|
303
|
+
const del = deleteButton();
|
|
304
|
+
// The arm window is measured on the event clock, so each click carries
|
|
305
|
+
// its own timeStamp.
|
|
306
|
+
const click = (detail: number, timeStamp: number): void => {
|
|
307
|
+
const event = new MouseEvent('click', { bubbles: true, detail });
|
|
308
|
+
Object.defineProperty(event, 'timeStamp', { value: timeStamp });
|
|
309
|
+
del.dispatchEvent(event);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// Within 600 ms of arming, a detail-2 click is the second click of a
|
|
313
|
+
// double-click.
|
|
314
|
+
click(1, 1000);
|
|
315
|
+
click(2, 1500);
|
|
316
|
+
await Promise.resolve();
|
|
317
|
+
expect(onDelete).not.toHaveBeenCalled();
|
|
318
|
+
expect(del.classList.contains('jp-mod-armed')).toBe(true);
|
|
319
|
+
|
|
320
|
+
// Past 600 ms, a click the OS still counts into the double-click confirms.
|
|
321
|
+
click(2, 1700);
|
|
322
|
+
await Promise.resolve();
|
|
323
|
+
expect(onDelete).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(onDelete).toHaveBeenCalledWith([FIRST]);
|
|
325
|
+
});
|
|
279
326
|
});
|
|
@@ -373,4 +373,120 @@ describe('one spinner dialog (DEF-PANE-327-BUSY-MODAL-1)', () => {
|
|
|
373
373
|
endSpinner(hidden);
|
|
374
374
|
}
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
it('activates a terminal waiting on the dialog only when the dialog closes on its own (DEF-PANE-339)', async () => {
|
|
378
|
+
manager = makeManager();
|
|
379
|
+
const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
|
|
380
|
+
|
|
381
|
+
// Another action keeps the dialog up after this launch has ended, so its
|
|
382
|
+
// terminal waits for the dialog to close. The user hides the dialog.
|
|
383
|
+
const other = beginSpinner('Branching Testbed session', trans);
|
|
384
|
+
try {
|
|
385
|
+
await manager.launch(
|
|
386
|
+
{ project_path: PROJECT, session_id: CURRENT },
|
|
387
|
+
CURRENT
|
|
388
|
+
);
|
|
389
|
+
await flush();
|
|
390
|
+
expect(spinners()).toHaveLength(1);
|
|
391
|
+
spinners()[0]
|
|
392
|
+
.querySelector<HTMLElement>('.jp-Dialog-footer .jp-Dialog-button')!
|
|
393
|
+
.click();
|
|
394
|
+
await flush();
|
|
395
|
+
expect(spinners()).toHaveLength(0);
|
|
396
|
+
expect(activated).not.toHaveBeenCalled();
|
|
397
|
+
} finally {
|
|
398
|
+
endSpinner(other);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Without Hide the dialog closes with the last action and the terminal
|
|
402
|
+
// is activated.
|
|
403
|
+
const next = beginSpinner('Branching Testbed session', trans);
|
|
404
|
+
let opened: any;
|
|
405
|
+
try {
|
|
406
|
+
opened = await manager.launch(
|
|
407
|
+
{ project_path: PROJECT, session_id: CURRENT },
|
|
408
|
+
CURRENT
|
|
409
|
+
);
|
|
410
|
+
await flush();
|
|
411
|
+
expect(activated).not.toHaveBeenCalled();
|
|
412
|
+
} finally {
|
|
413
|
+
endSpinner(next);
|
|
414
|
+
}
|
|
415
|
+
await flush();
|
|
416
|
+
expect(activated).toHaveBeenCalledWith(opened.id);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("leaves a hidden action's terminal alone when a later action's dialog closes (DEF-PANE-339)", async () => {
|
|
420
|
+
manager = makeManager();
|
|
421
|
+
const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
|
|
422
|
+
|
|
423
|
+
// The user hides the dialog, then a later action raises a fresh one that
|
|
424
|
+
// stands for the later action alone.
|
|
425
|
+
const hidden = beginSpinner('Opening Testbed', trans);
|
|
426
|
+
let later: number | undefined;
|
|
427
|
+
try {
|
|
428
|
+
await flush();
|
|
429
|
+
spinners()[0]
|
|
430
|
+
.querySelector<HTMLElement>('.jp-Dialog-footer .jp-Dialog-button')!
|
|
431
|
+
.click();
|
|
432
|
+
later = beginSpinner('Branching Testbed session', trans);
|
|
433
|
+
await flush();
|
|
434
|
+
expect(spinners()).toHaveLength(1);
|
|
435
|
+
|
|
436
|
+
// The hidden action's terminal opens while the later dialog is up, and
|
|
437
|
+
// that dialog then closes on its own: the terminal is not activated.
|
|
438
|
+
await manager.launch(
|
|
439
|
+
{ project_path: PROJECT, session_id: CURRENT },
|
|
440
|
+
CURRENT,
|
|
441
|
+
hidden
|
|
442
|
+
);
|
|
443
|
+
endSpinner(later);
|
|
444
|
+
await flush();
|
|
445
|
+
expect(spinners()).toHaveLength(0);
|
|
446
|
+
expect(activated).not.toHaveBeenCalled();
|
|
447
|
+
} finally {
|
|
448
|
+
if (later !== undefined) {
|
|
449
|
+
endSpinner(later);
|
|
450
|
+
}
|
|
451
|
+
endSpinner(hidden);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('keeps the dialog up through the second click of a double-click, and the terminal then takes the keyboard (DEF-PANE-340)', async () => {
|
|
456
|
+
manager = makeManager();
|
|
457
|
+
const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
|
|
458
|
+
const answer = request.getMockImplementation()!;
|
|
459
|
+
let release!: () => void;
|
|
460
|
+
const held = new Promise<void>(resolve => {
|
|
461
|
+
release = resolve;
|
|
462
|
+
});
|
|
463
|
+
request.mockImplementation(((...args: Parameters<typeof answer>) =>
|
|
464
|
+
args[1] === 'launch'
|
|
465
|
+
? held.then(() => answer(...args))
|
|
466
|
+
: answer(...args)) as any);
|
|
467
|
+
const call = manager.launch(
|
|
468
|
+
{ project_path: PROJECT, session_id: CURRENT },
|
|
469
|
+
CURRENT
|
|
470
|
+
);
|
|
471
|
+
let opened: any;
|
|
472
|
+
try {
|
|
473
|
+
await flush();
|
|
474
|
+
expect(spinners()).toHaveLength(1);
|
|
475
|
+
// The second click of a double-click on the row lands on the backdrop,
|
|
476
|
+
// outside the dialog's content.
|
|
477
|
+
for (const type of ['mousedown', 'click']) {
|
|
478
|
+
spinners()[0].dispatchEvent(
|
|
479
|
+
new MouseEvent(type, { bubbles: true, cancelable: true, detail: 2 })
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
await flush();
|
|
483
|
+
expect(spinners()).toHaveLength(1);
|
|
484
|
+
} finally {
|
|
485
|
+
release();
|
|
486
|
+
opened = await call;
|
|
487
|
+
}
|
|
488
|
+
await flush();
|
|
489
|
+
expect(spinners()).toHaveLength(0);
|
|
490
|
+
expect(activated).toHaveBeenCalledWith(opened.id);
|
|
491
|
+
});
|
|
376
492
|
});
|
package/src/core/dialogs.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { Widget } from '@lumino/widgets';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The renderer every dialog of this extension is built with. It marks the
|
|
6
|
-
* footer, and `style/base.css` scopes its dialog rules - no focus rim on
|
|
7
|
-
* button
|
|
6
|
+
* footer, and `style/base.css` scopes its dialog rules - no focus rim on a
|
|
7
|
+
* button focused by the mouse or by a dialog opened with the mouse (keyboard
|
|
8
|
+
* focus keeps JupyterLab's rim), the reject buttons (Cancel, and Close in the
|
|
9
|
+
* Open Callback popup) as
|
|
8
10
|
* secondary surface buttons so the fill alone marks the primary - to a dialog
|
|
9
11
|
* that holds the mark, so JupyterLab's own
|
|
10
12
|
* dialogs keep their look. A renderer is the one hook that also reaches the
|
package/src/core/icons.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LabIcon } from '@jupyterlab/ui-components';
|
|
1
|
+
import { classes, LabIcon } from '@jupyterlab/ui-components';
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
|
|
4
4
|
// Shared panel chrome icons. Provider icons are NOT here - each provider
|
|
@@ -175,7 +175,11 @@ export function providerIcon(name: string, svgstr: string): LabIcon {
|
|
|
175
175
|
* preset, and each tile under `launcherCard`. This view of `tile` renders the
|
|
176
176
|
* extension's joint icon under the section preset and `tile` everywhere else,
|
|
177
177
|
* so the header carries no vendor's mark while every tile keeps its own
|
|
178
|
-
* (ACC-LNCH-163).
|
|
178
|
+
* (ACC-LNCH-163). Under `launcherCard` it also adds the class
|
|
179
|
+
* `jp-AiAssistantsLauncher-tileGlyph`, which `style/base.css` scales to 75%,
|
|
180
|
+
* so the tile glyph is drawn smaller than JupyterLab's own tile glyphs while
|
|
181
|
+
* the sidebar tab, panel header and menus keep the standard size
|
|
182
|
+
* (ACC-LNCH-215).
|
|
179
183
|
*
|
|
180
184
|
* Built the way `LabIcon.bindprops` builds its views - a prototype child of
|
|
181
185
|
* the tile with its own `react` - so `LabIcon.resolve` still recognises it.
|
|
@@ -188,7 +192,16 @@ export function launcherTileIcon(tile: LabIcon): LabIcon {
|
|
|
188
192
|
props.stylesheet === 'launcherSection'
|
|
189
193
|
? assistantsIcon.react
|
|
190
194
|
: tile.react,
|
|
191
|
-
{
|
|
195
|
+
{
|
|
196
|
+
...props,
|
|
197
|
+
...(props.stylesheet === 'launcherCard' && {
|
|
198
|
+
className: classes(
|
|
199
|
+
props.className,
|
|
200
|
+
'jp-AiAssistantsLauncher-tileGlyph'
|
|
201
|
+
)
|
|
202
|
+
}),
|
|
203
|
+
ref
|
|
204
|
+
}
|
|
192
205
|
)
|
|
193
206
|
);
|
|
194
207
|
// `react` is readonly on the type; the view shadows the tile's own.
|
package/src/core/panel.ts
CHANGED
|
@@ -1254,12 +1254,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1254
1254
|
spinner
|
|
1255
1255
|
);
|
|
1256
1256
|
} catch (err) {
|
|
1257
|
-
if (
|
|
1258
|
-
Notification.error(
|
|
1259
|
-
`${this._descriptor.label} cannot branch this conversation.`,
|
|
1260
|
-
{ autoClose: 4000 }
|
|
1261
|
-
);
|
|
1262
|
-
} else if (minted) {
|
|
1257
|
+
if (minted) {
|
|
1263
1258
|
// The branch was created and only its terminal was refused - the
|
|
1264
1259
|
// launch preflight's 409 carries words for the user. "Could not
|
|
1265
1260
|
// branch this session" would be false, and the early return would
|
|
@@ -1274,6 +1269,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1274
1269
|
err
|
|
1275
1270
|
);
|
|
1276
1271
|
await this._fetch().catch(() => undefined);
|
|
1272
|
+
} else if (isResponseStatus(err, 400)) {
|
|
1273
|
+
Notification.error(
|
|
1274
|
+
`${this._descriptor.label} cannot branch this conversation.`,
|
|
1275
|
+
{ autoClose: 4000 }
|
|
1276
|
+
);
|
|
1277
1277
|
} else {
|
|
1278
1278
|
this._showActionError(
|
|
1279
1279
|
'Could not branch this session - try again.',
|
package/src/core/popup.ts
CHANGED
|
@@ -116,6 +116,9 @@ export function showManageSessionsPopup(
|
|
|
116
116
|
// removal is permanent.
|
|
117
117
|
let armed = false;
|
|
118
118
|
let armTimer = 0;
|
|
119
|
+
// The arming click's event.timeStamp, which the confirm click is measured
|
|
120
|
+
// against.
|
|
121
|
+
let armedAt = 0;
|
|
119
122
|
|
|
120
123
|
const body = document.createElement('div');
|
|
121
124
|
body.className = 'jp-AiAssistantsPanel-branchPopup';
|
|
@@ -437,10 +440,17 @@ export function showManageSessionsPopup(
|
|
|
437
440
|
}
|
|
438
441
|
};
|
|
439
442
|
|
|
440
|
-
deleteBtn.addEventListener('click',
|
|
443
|
+
deleteBtn.addEventListener('click', event => {
|
|
441
444
|
if (deleting || selected.size === 0) {
|
|
442
445
|
return;
|
|
443
446
|
}
|
|
447
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
448
|
+
// a multi-click within 600 ms of arming is ignored. A later click confirms
|
|
449
|
+
// whatever its detail - the OS double-click interval can be longer than
|
|
450
|
+
// the user's pause, and the click would be dropped (DEF-PANE-338).
|
|
451
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
444
454
|
// No confirmation DIALOG here: a second Lumino dialog stacked on this popup
|
|
445
455
|
// renders detached. The deletion confirms in the footer itself - the first
|
|
446
456
|
// click arms the button, the second executes, and any selection change or
|
|
@@ -449,6 +459,7 @@ export function showManageSessionsPopup(
|
|
|
449
459
|
// refused move may remove the conversation permanently or leave it.
|
|
450
460
|
if (!armed) {
|
|
451
461
|
armed = true;
|
|
462
|
+
armedAt = event.timeStamp;
|
|
452
463
|
window.clearTimeout(armTimer);
|
|
453
464
|
armTimer = window.setTimeout(() => {
|
|
454
465
|
disarm();
|
package/src/core/terminals.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
1
|
+
import { ILabShell, JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
2
|
import { Dialog, Notification } from '@jupyterlab/apputils';
|
|
3
3
|
import { TranslationBundle, nullTranslator } from '@jupyterlab/translation';
|
|
4
4
|
import { ServerConnection } from '@jupyterlab/services';
|
|
@@ -103,7 +103,8 @@ function titleSpinner(dialog: Dialog<unknown>, title: string): void {
|
|
|
103
103
|
*
|
|
104
104
|
* Hide does not abort the request - it only takes the app-wide scrim away, so
|
|
105
105
|
* a stalled action cannot leave the user staring at a modal with no exit. If
|
|
106
|
-
* the action later succeeds the terminal
|
|
106
|
+
* the action later succeeds the terminal opens as a tab behind the current
|
|
107
|
+
* one (DEF-PANE-339). */
|
|
107
108
|
function showLaunchSpinner(
|
|
108
109
|
title: string,
|
|
109
110
|
trans: TranslationBundle
|
|
@@ -120,10 +121,13 @@ function showLaunchSpinner(
|
|
|
120
121
|
title,
|
|
121
122
|
body,
|
|
122
123
|
// `Hide`, not `Cancel`. The button takes the scrim away and nothing else -
|
|
123
|
-
// the request is already on the wire and the terminal will still open
|
|
124
|
-
//
|
|
125
|
-
// button.
|
|
124
|
+
// the request is already on the wire and the terminal will still open, as
|
|
125
|
+
// a tab behind the current one that does not take the keyboard. A button
|
|
126
|
+
// named Cancel that does not cancel is worse than no button.
|
|
126
127
|
buttons: [Dialog.okButton({ label: trans.__('Hide') })],
|
|
128
|
+
// Hide is the only exit: the second click of a double-click on the row
|
|
129
|
+
// lands on the backdrop, and that is not a choice to hide (DEF-PANE-340).
|
|
130
|
+
hasClose: false,
|
|
127
131
|
renderer: spinnerRenderer
|
|
128
132
|
});
|
|
129
133
|
// Scopes the compact layout in base.css to this one dialog.
|
|
@@ -145,6 +149,15 @@ let spinnerDialog: Dialog<unknown> | null = null;
|
|
|
145
149
|
let spinnerSeq = 0;
|
|
146
150
|
const spinnerTitles = new Map<number, string>();
|
|
147
151
|
|
|
152
|
+
/** Whether the user hid the dialog of `token`, an action still running. Hide
|
|
153
|
+
* disposes the dialog, and an action begun after it raises a fresh dialog that
|
|
154
|
+
* does not stand for the hidden one (DEF-PANE-339). */
|
|
155
|
+
function spinnerHidden(token: number): boolean {
|
|
156
|
+
return (
|
|
157
|
+
!spinnerDialog || spinnerDialog.isDisposed || !spinnerTitles.has(token)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
/** Show the spinner dialog for one action, or retitle the one already up so it
|
|
149
162
|
* names the newest action. Pass the token to `endSpinner` when the action
|
|
150
163
|
* settles. Hide disposes the dialog for the actions then running: they stay
|
|
@@ -182,9 +195,10 @@ export function endSpinner(token: number): void {
|
|
|
182
195
|
* tint pass.
|
|
183
196
|
*
|
|
184
197
|
* Split out of the panel because none of it is presentation - it is process
|
|
185
|
-
* identity. The panel asks for a conversation and gets a
|
|
186
|
-
*
|
|
187
|
-
* about sections, filters or
|
|
198
|
+
* identity. The panel asks for a conversation and gets a terminal running
|
|
199
|
+
* exactly that conversation, or a fresh one, focused unless the user hid the
|
|
200
|
+
* action's spinner dialog; nothing in here knows about sections, filters or
|
|
201
|
+
* menus.
|
|
188
202
|
*/
|
|
189
203
|
export class TerminalManager {
|
|
190
204
|
constructor(options: TerminalManager.IOptions) {
|
|
@@ -274,7 +288,7 @@ export class TerminalManager {
|
|
|
274
288
|
// when it carries the wanted conversation.
|
|
275
289
|
const cached = this._byProject.get(session.project_path);
|
|
276
290
|
if (cached && !cached.widget.isDisposed && cached.sessionId === wanted) {
|
|
277
|
-
this.focus(cached.widget);
|
|
291
|
+
this.focus(cached.widget, spinner);
|
|
278
292
|
return;
|
|
279
293
|
}
|
|
280
294
|
|
|
@@ -289,7 +303,7 @@ export class TerminalManager {
|
|
|
289
303
|
sessionId: found.runningId ?? undefined
|
|
290
304
|
});
|
|
291
305
|
this._wireDisposal(session.project_path, found.widget);
|
|
292
|
-
this.focus(found.widget);
|
|
306
|
+
this.focus(found.widget, spinner);
|
|
293
307
|
return;
|
|
294
308
|
}
|
|
295
309
|
|
|
@@ -316,7 +330,7 @@ export class TerminalManager {
|
|
|
316
330
|
* `spinner` is the token of the click this launch belongs to. The launch
|
|
317
331
|
* joins that click's spinner instead of beginning its own, so the user sees
|
|
318
332
|
* one dialog from the click to the terminal, and a click the user hid stays
|
|
319
|
-
* hidden.
|
|
333
|
+
* hidden and its terminal opens behind the current tab.
|
|
320
334
|
*/
|
|
321
335
|
async launch(
|
|
322
336
|
request: ILaunchRequest,
|
|
@@ -337,16 +351,46 @@ export class TerminalManager {
|
|
|
337
351
|
this._serverSettings,
|
|
338
352
|
{ method: 'POST', body: JSON.stringify(request) }
|
|
339
353
|
);
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
354
|
+
const token = spinner ?? own!;
|
|
355
|
+
const name = launched.terminal_name;
|
|
356
|
+
// `terminal:open` shows the new terminal's tab and moves the focus into
|
|
357
|
+
// it before it returns. When the user has hidden this action's dialog,
|
|
358
|
+
// that focus is stopped before the shell sees it, as JupyterLab's Dialog
|
|
359
|
+
// stops focus that leaves it, so the user's widget stays the current one.
|
|
360
|
+
// The focus goes back to the element it left once the user's tab is
|
|
361
|
+
// shown again (DEF-PANE-339).
|
|
362
|
+
let left = null as HTMLElement | null;
|
|
363
|
+
const keep = (event: FocusEvent): void => {
|
|
364
|
+
const target = event.target as HTMLElement;
|
|
365
|
+
if (
|
|
366
|
+
spinnerHidden(token) &&
|
|
367
|
+
this._liveTerminals().some(
|
|
368
|
+
w => w.content?.session?.name === name && w.node.contains(target)
|
|
369
|
+
)
|
|
370
|
+
) {
|
|
371
|
+
event.stopPropagation();
|
|
372
|
+
left = event.relatedTarget as HTMLElement | null;
|
|
373
|
+
target.blur();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
document.addEventListener('focus', keep, true);
|
|
377
|
+
let widget: any;
|
|
378
|
+
try {
|
|
379
|
+
widget = await this._app.commands.execute('terminal:open', { name });
|
|
380
|
+
} finally {
|
|
381
|
+
document.removeEventListener('focus', keep, true);
|
|
382
|
+
}
|
|
383
|
+
if (spinnerHidden(token)) {
|
|
384
|
+
this._showCurrentTab();
|
|
385
|
+
left?.focus();
|
|
386
|
+
}
|
|
343
387
|
if (widget?.id) {
|
|
344
388
|
this._byProject.set(request.project_path, {
|
|
345
389
|
widget,
|
|
346
390
|
sessionId: tagSessionId
|
|
347
391
|
});
|
|
348
392
|
this._wireDisposal(request.project_path, widget);
|
|
349
|
-
this.focus(widget);
|
|
393
|
+
this.focus(widget, token);
|
|
350
394
|
}
|
|
351
395
|
return widget ?? null;
|
|
352
396
|
} finally {
|
|
@@ -356,6 +400,18 @@ export class TerminalManager {
|
|
|
356
400
|
}
|
|
357
401
|
}
|
|
358
402
|
|
|
403
|
+
/** Show the current widget's tab again. Opening a terminal selects its tab,
|
|
404
|
+
* so for a hidden action the tab the user was on goes back in front
|
|
405
|
+
* (DEF-PANE-339). */
|
|
406
|
+
private _showCurrentTab(): void {
|
|
407
|
+
const shell = this._app.shell as ILabShell;
|
|
408
|
+
const current = shell.currentWidget;
|
|
409
|
+
const bar = current && shell.getMainAreaTabBar(current);
|
|
410
|
+
if (bar) {
|
|
411
|
+
bar.currentTitle = current.title;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
359
415
|
/**
|
|
360
416
|
* Bring a terminal tab to the front AND hand it keyboard focus, so typing
|
|
361
417
|
* starts without a second click. `activateById` only reveals the tab; the
|
|
@@ -369,13 +425,30 @@ export class TerminalManager {
|
|
|
369
425
|
* under it ends up without the keyboard. Closing emits `disposed` before
|
|
370
426
|
* that return, and the activation the tab then receives is processed after
|
|
371
427
|
* it, so the terminal is the last to take the focus.
|
|
428
|
+
*
|
|
429
|
+
* `spinner` is the token of the action that asked. When the user hid that
|
|
430
|
+
* action's dialog the terminal is left where it is, and so is a terminal
|
|
431
|
+
* waiting for the dialog the user then hides (DEF-PANE-339).
|
|
372
432
|
*/
|
|
373
|
-
focus(widget: any): void {
|
|
433
|
+
focus(widget: any, spinner?: number): void {
|
|
374
434
|
if (!widget || widget.isDisposed) {
|
|
375
435
|
return;
|
|
376
436
|
}
|
|
437
|
+
if (spinner !== undefined && spinnerHidden(spinner)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
377
440
|
if (spinnerDialog && !spinnerDialog.isDisposed) {
|
|
378
|
-
spinnerDialog.disposed.connect(() =>
|
|
441
|
+
spinnerDialog.disposed.connect(() => {
|
|
442
|
+
// The dialog closes on its own only with its last action. Closed with
|
|
443
|
+
// an action still running, the user hid it: the terminal is not
|
|
444
|
+
// activated, and its tab, shown behind the dialog, goes back behind
|
|
445
|
+
// the user's tab.
|
|
446
|
+
if (spinnerTitles.size > 0) {
|
|
447
|
+
this._showCurrentTab();
|
|
448
|
+
} else {
|
|
449
|
+
this.focus(widget);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
379
452
|
return;
|
|
380
453
|
}
|
|
381
454
|
this._app.shell.activateById(widget.id);
|
package/style/base.css
CHANGED
|
@@ -616,17 +616,23 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
616
616
|
border-width: 3px;
|
|
617
617
|
}
|
|
618
618
|
|
|
619
|
-
/*
|
|
620
|
-
|
|
619
|
+
/* A focus rim on a button of this extension's dialogs only when the focus
|
|
620
|
+
came from the keyboard; the primary action is marked by its fill
|
|
621
|
+
(jp-mod-accept). JupyterLab draws the rim from
|
|
621
622
|
`button.jp-Dialog-button.jp-mod-styled.jp-mod-accept:focus` and its warn and
|
|
622
623
|
reject siblings in apputils' dialog.css - on :focus, not :focus-visible -
|
|
623
624
|
and a Dialog focuses its default button when it attaches, so the rim showed
|
|
624
|
-
on a dialog opened with the mouse too.
|
|
625
|
-
the
|
|
626
|
-
|
|
627
|
-
.
|
|
625
|
+
on a dialog opened with the mouse too (DEF-PANE-328-FOCUS-RIM). The rule
|
|
626
|
+
removes it where the browser does not mark the focus :focus-visible: after
|
|
627
|
+
a mouse click, and on the default button of a dialog opened with the
|
|
628
|
+
mouse. Keyboard focus keeps JupyterLab's rim (ACC-PANE-217-FOCUS-OUTLINE).
|
|
629
|
+
Scoped through the footer mark of the extension's dialog renderer
|
|
630
|
+
(src/core/dialogs.ts); the second selector outranks those four-class
|
|
631
|
+
rules. */
|
|
628
632
|
.jp-Dialog:has(.jp-AiAssistantsPanel-dialogFooter)
|
|
629
|
-
button
|
|
633
|
+
button:focus:not(:focus-visible),
|
|
634
|
+
.jp-Dialog:has(.jp-AiAssistantsPanel-dialogFooter)
|
|
635
|
+
button.jp-Dialog-button.jp-mod-styled:focus:not(:focus-visible) {
|
|
630
636
|
outline: none;
|
|
631
637
|
}
|
|
632
638
|
|
|
@@ -1126,3 +1132,10 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
1126
1132
|
.jp-AiAssistantsCallback-status[data-level='error'] {
|
|
1127
1133
|
color: var(--jp-error-color1);
|
|
1128
1134
|
}
|
|
1135
|
+
|
|
1136
|
+
/* Launcher tile glyph at 75% of JupyterLab's own tile glyph (ACC-LNCH-215).
|
|
1137
|
+
A transform, so the 52px icon box and the card layout stay as they are and
|
|
1138
|
+
the svg shrinks about its own centre. */
|
|
1139
|
+
.jp-LauncherCard-icon .jp-AiAssistantsLauncher-tileGlyph > svg {
|
|
1140
|
+
transform: scale(0.75);
|
|
1141
|
+
}
|