jupyterlab_ai_code_assistants_extension 1.2.47 → 1.2.50
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/README.md +1 -1
- package/lib/core/callback.js +107 -32
- package/lib/core/icons.js +9 -8
- package/package.json +1 -1
- package/src/__tests__/callback.spec.ts +143 -42
- package/src/core/callback.ts +144 -54
- package/src/core/icons.ts +9 -8
- package/style/base.css +52 -0
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
47
47
|
- **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant instead of showing a duplicate
|
|
48
48
|
- **DeepSeek in the browser** - the DeepSeek Harness has no terminal chat; its panel lists the projects under `~/.dsh/sessions` (or `$DSH_HOME`) and `Open Web UI` starts `dsh --profile web --no-open --port 0` in the project's terminal, which prints the URL to open. The conversation is then chosen in the harness's own browser UI; branching and deleting work from the panel as for every other assistant. The URL is on the machine running JupyterLab, so a remote server needs a port forward to reach it
|
|
49
49
|
- **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
|
|
50
|
-
- **Open Callback** - when an assistant's login sends your browser to an `http://127.0.0.1:...` link that fails to load, because the login is waiting on the machine running JupyterLab, run `Open Callback` from the command palette and paste the link; the Jupyter server requests it for you. Only `http` links to that machine's own loopback are accepted
|
|
50
|
+
- **Open Callback** - when an assistant's login sends your browser to an `http://127.0.0.1:...` link that fails to load, because the login is waiting on the machine running JupyterLab, run `Open Callback` from the command palette and paste the link; the Jupyter server requests it for you, and the popup says whether the login accepted it. Only `http` links to that machine's own loopback are accepted
|
|
51
51
|
|
|
52
52
|
## Requirements
|
|
53
53
|
|
package/lib/core/callback.js
CHANGED
|
@@ -1,7 +1,103 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Dialog, Notification } from '@jupyterlab/apputils';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
2
3
|
import { requestAPI } from './request';
|
|
3
4
|
/** The command that delivers a pasted login callback link. */
|
|
4
5
|
export const CALLBACK_COMMAND = 'open:callback';
|
|
6
|
+
async function deliver(url, app, trans) {
|
|
7
|
+
try {
|
|
8
|
+
const answer = await requestAPI('callback', app.serviceManager.serverSettings, { method: 'POST', body: JSON.stringify({ url }) });
|
|
9
|
+
return answer.status < 400
|
|
10
|
+
? {
|
|
11
|
+
level: 'success',
|
|
12
|
+
message: trans.__('The login accepted the link. Check the terminal where the login is running.')
|
|
13
|
+
}
|
|
14
|
+
: {
|
|
15
|
+
level: 'warning',
|
|
16
|
+
message: trans.__('The login did not accept this link. Start the login again and paste the new link.')
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const code = err instanceof Error ? err.message : '';
|
|
21
|
+
return {
|
|
22
|
+
level: 'error',
|
|
23
|
+
message: code === 'callback_not_loopback'
|
|
24
|
+
? trans.__('This link cannot be used. It must start with http://127.0.0.1 or http://localhost.')
|
|
25
|
+
: code === 'callback_unreachable'
|
|
26
|
+
? trans.__('No login is waiting for this link. Start the login again and paste the new link.')
|
|
27
|
+
: trans.__('The link could not be sent to the Jupyter server. Try again.')
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The popup's body: the link field and, below it, what the last send came
|
|
32
|
+
* to. */
|
|
33
|
+
class CallbackForm extends Widget {
|
|
34
|
+
constructor(trans) {
|
|
35
|
+
super();
|
|
36
|
+
this.addClass('jp-AiAssistantsCallback');
|
|
37
|
+
const label = document.createElement('label');
|
|
38
|
+
label.textContent = trans.__('Callback link from the browser address bar');
|
|
39
|
+
this._input = document.createElement('input');
|
|
40
|
+
this._input.type = 'text';
|
|
41
|
+
this._input.className = 'jp-mod-styled';
|
|
42
|
+
this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
|
|
43
|
+
label.appendChild(this._input);
|
|
44
|
+
this._status = document.createElement('div');
|
|
45
|
+
this._status.className = 'jp-AiAssistantsCallback-status';
|
|
46
|
+
this._status.setAttribute('role', 'status');
|
|
47
|
+
this.node.append(label, this._status);
|
|
48
|
+
}
|
|
49
|
+
get url() {
|
|
50
|
+
return this._input.value.trim();
|
|
51
|
+
}
|
|
52
|
+
report(level, message) {
|
|
53
|
+
this._status.dataset.level = level;
|
|
54
|
+
this._status.textContent = message;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** OK sends the link and leaves the window open, so the result is read - and
|
|
58
|
+
* a refused link replaced - in the same place. Close and Escape end it. */
|
|
59
|
+
class CallbackDialog extends Dialog {
|
|
60
|
+
constructor(_form, _send, _trans) {
|
|
61
|
+
super({
|
|
62
|
+
title: _trans.__('Open Callback'),
|
|
63
|
+
body: _form,
|
|
64
|
+
buttons: [
|
|
65
|
+
Dialog.cancelButton({ label: _trans.__('Close') }),
|
|
66
|
+
Dialog.okButton({ label: _trans.__('OK') })
|
|
67
|
+
],
|
|
68
|
+
defaultButton: 1,
|
|
69
|
+
focusNodeSelector: 'input'
|
|
70
|
+
});
|
|
71
|
+
this._form = _form;
|
|
72
|
+
this._send = _send;
|
|
73
|
+
this._trans = _trans;
|
|
74
|
+
this._sending = false;
|
|
75
|
+
}
|
|
76
|
+
resolve(index) {
|
|
77
|
+
if (index === 0) {
|
|
78
|
+
super.resolve(index);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void this._deliver();
|
|
82
|
+
}
|
|
83
|
+
async _deliver() {
|
|
84
|
+
const url = this._form.url;
|
|
85
|
+
if (this._sending) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!url) {
|
|
89
|
+
this._form.report('error', this._trans.__('Paste the link first.'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this._sending = true;
|
|
93
|
+
this._form.report('pending', this._trans.__('Sending the link...'));
|
|
94
|
+
const outcome = await this._send(url);
|
|
95
|
+
this._sending = false;
|
|
96
|
+
if (!this.isDisposed) {
|
|
97
|
+
this._form.report(outcome.level, outcome.message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
5
101
|
/**
|
|
6
102
|
* Register the command that sends a pasted login callback link to the login
|
|
7
103
|
* waiting on the Jupyter server.
|
|
@@ -21,44 +117,23 @@ export function addCallbackCommand(app, palette, category, trans) {
|
|
|
21
117
|
properties: {
|
|
22
118
|
url: {
|
|
23
119
|
type: 'string',
|
|
24
|
-
description: 'The callback link. Asked for in a
|
|
120
|
+
description: 'The callback link. Asked for in a popup when absent.'
|
|
25
121
|
}
|
|
26
122
|
}
|
|
27
123
|
}
|
|
28
124
|
},
|
|
29
125
|
execute: async (args) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
126
|
+
const send = (url) => deliver(url, app, trans);
|
|
127
|
+
const given = typeof args.url === 'string' ? args.url.trim() : '';
|
|
128
|
+
if (given) {
|
|
129
|
+
// No popup to write into, so the result is a toast.
|
|
130
|
+
const outcome = await send(given);
|
|
131
|
+
Notification[outcome.level](outcome.message, {
|
|
132
|
+
autoClose: outcome.level === 'success' ? 4000 : 8000
|
|
37
133
|
});
|
|
38
|
-
|
|
39
|
-
if (!url) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const answer = await requestAPI('callback', app.serviceManager.serverSettings, { method: 'POST', body: JSON.stringify({ url }) });
|
|
45
|
-
if (answer.status < 400) {
|
|
46
|
-
Notification.success(trans.__('Callback delivered - the login answered HTTP %1.', answer.status), { autoClose: 4000 });
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
Notification.warning(trans.__('The login refused the callback (HTTP %1). Start the login again and paste the new link.', answer.status), { autoClose: 8000 });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
catch (err) {
|
|
53
|
-
// The server's own codes, never the link: it carries a login code.
|
|
54
|
-
const code = err instanceof Error ? err.message : String(err);
|
|
55
|
-
const message = code === 'callback_not_loopback'
|
|
56
|
-
? trans.__('Only an http link to 127.0.0.1 or localhost can be opened.')
|
|
57
|
-
: code === 'callback_unreachable'
|
|
58
|
-
? trans.__('No login is waiting at that address. Start the login again and paste the new link.')
|
|
59
|
-
: trans.__('Could not open the callback: %1', code);
|
|
60
|
-
Notification.error(message, { autoClose: 8000 });
|
|
134
|
+
return;
|
|
61
135
|
}
|
|
136
|
+
await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
|
|
62
137
|
}
|
|
63
138
|
});
|
|
64
139
|
palette === null || palette === void 0 ? void 0 : palette.addItem({ command: CALLBACK_COMMAND, category });
|
package/lib/core/icons.js
CHANGED
|
@@ -103,15 +103,16 @@ export const filterIcon = new LabIcon({
|
|
|
103
103
|
name: `${ICON_PREFIX}:filter`,
|
|
104
104
|
svgstr: filterSvgStr
|
|
105
105
|
});
|
|
106
|
-
// Row status mark -
|
|
107
|
-
//
|
|
108
|
-
// a CSS `fill` on the circle
|
|
109
|
-
//
|
|
110
|
-
// attribute: the stylesheet is the
|
|
111
|
-
//
|
|
112
|
-
// (DEF-PANE-179).
|
|
106
|
+
// Row status mark - a light, drawn for a conversation that is live or under
|
|
107
|
+
// remote control: a 6px circle over the 8px disc that casts its halo. An SVG
|
|
108
|
+
// rather than a rounded span so each colour is a CSS `fill` on the circle
|
|
109
|
+
// itself, the way every other glyph here is recoloured through its class
|
|
110
|
+
// (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
|
|
111
|
+
// single source of those colours, and a presentation attribute here would be
|
|
112
|
+
// a second one to keep in step (DEF-PANE-179).
|
|
113
113
|
const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
114
|
-
<circle class="jp-AiAssistantsPanel-
|
|
114
|
+
<circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
|
|
115
|
+
<circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
|
|
115
116
|
</svg>`;
|
|
116
117
|
export const statusDotIcon = new LabIcon({
|
|
117
118
|
name: `${ICON_PREFIX}:status-dot`,
|
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.50",
|
|
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",
|
|
@@ -18,7 +18,7 @@ jest.mock('../core/request', () => ({
|
|
|
18
18
|
requestAPI: jest.fn()
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { Notification } from '@jupyterlab/apputils';
|
|
22
22
|
import { nullTranslator } from '@jupyterlab/translation';
|
|
23
23
|
import { CommandRegistry } from '@lumino/commands';
|
|
24
24
|
|
|
@@ -28,6 +28,10 @@ import { requestAPI } from '../core/request';
|
|
|
28
28
|
const request = requestAPI as jest.Mock;
|
|
29
29
|
const LINK = 'http://127.0.0.1:1455/auth/callback?code=one-time&state=s';
|
|
30
30
|
|
|
31
|
+
/** One macrotask: `Dialog.launch` attaches the popup after its own awaits. */
|
|
32
|
+
const settle = (): Promise<void> =>
|
|
33
|
+
new Promise(resolve => setTimeout(resolve, 0));
|
|
34
|
+
|
|
31
35
|
function setup(): {
|
|
32
36
|
commands: CommandRegistry;
|
|
33
37
|
added: { command: string; category: string }[];
|
|
@@ -45,21 +49,56 @@ function setup(): {
|
|
|
45
49
|
return { commands, added, serverSettings };
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
interface IPopup {
|
|
53
|
+
/** Settles when the popup has closed. */
|
|
54
|
+
closed: Promise<unknown>;
|
|
55
|
+
input: HTMLInputElement;
|
|
56
|
+
status: HTMLElement;
|
|
57
|
+
ok: HTMLElement;
|
|
58
|
+
close: HTMLElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function openPopup(commands: CommandRegistry): Promise<IPopup> {
|
|
62
|
+
const closed = commands.execute(CALLBACK_COMMAND);
|
|
63
|
+
await settle();
|
|
64
|
+
const node = document.querySelector('.jp-Dialog') as HTMLElement;
|
|
65
|
+
return {
|
|
66
|
+
closed,
|
|
67
|
+
input: node.querySelector(
|
|
68
|
+
'.jp-AiAssistantsCallback input'
|
|
69
|
+
) as HTMLInputElement,
|
|
70
|
+
status: node.querySelector(
|
|
71
|
+
'.jp-AiAssistantsCallback-status'
|
|
72
|
+
) as HTMLElement,
|
|
73
|
+
ok: node.querySelector('.jp-mod-accept') as HTMLElement,
|
|
74
|
+
close: node.querySelector('.jp-mod-reject') as HTMLElement
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** No status code, no server error code and never the link, which carries a
|
|
79
|
+
* login code. */
|
|
80
|
+
function expectPlainWords(text: string): void {
|
|
81
|
+
expect(text).not.toMatch(
|
|
82
|
+
/callback_|HTTP|\b[45]\d\d\b|Internal Server Error|one-time/
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
describe('the Open Callback command', () => {
|
|
49
87
|
let success: jest.SpyInstance;
|
|
50
|
-
let warning: jest.SpyInstance;
|
|
51
88
|
let error: jest.SpyInstance;
|
|
52
|
-
let dialog: jest.SpyInstance;
|
|
53
89
|
|
|
54
90
|
beforeEach(() => {
|
|
55
91
|
request.mockReset();
|
|
56
92
|
success = jest.spyOn(Notification, 'success').mockReturnValue('' as any);
|
|
57
|
-
warning = jest.spyOn(Notification, 'warning').mockReturnValue('' as any);
|
|
58
93
|
error = jest.spyOn(Notification, 'error').mockReturnValue('' as any);
|
|
59
|
-
dialog = jest.spyOn(InputDialog, 'getText');
|
|
60
94
|
});
|
|
61
95
|
|
|
62
|
-
afterEach(() => {
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
// A popup left open would hold every later one in JupyterLab's queue.
|
|
98
|
+
(
|
|
99
|
+
document.querySelector('.jp-Dialog .jp-mod-reject') as HTMLElement
|
|
100
|
+
)?.click();
|
|
101
|
+
await settle();
|
|
63
102
|
jest.restoreAllMocks();
|
|
64
103
|
});
|
|
65
104
|
|
|
@@ -83,75 +122,137 @@ describe('the Open Callback command', () => {
|
|
|
83
122
|
expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
|
|
84
123
|
});
|
|
85
124
|
|
|
86
|
-
it('
|
|
125
|
+
it('OK sends the trimmed link and writes the result under the field, window still open', async () => {
|
|
87
126
|
const { commands, serverSettings } = setup();
|
|
88
|
-
dialog.mockResolvedValue({
|
|
89
|
-
button: { accept: true },
|
|
90
|
-
value: ` ${LINK}\n`
|
|
91
|
-
} as any);
|
|
92
127
|
request.mockResolvedValue({ status: 302 });
|
|
128
|
+
const popup = await openPopup(commands);
|
|
93
129
|
|
|
94
|
-
|
|
130
|
+
popup.input.value = ` ${LINK}\n`;
|
|
131
|
+
popup.ok.click();
|
|
132
|
+
await settle();
|
|
95
133
|
|
|
96
|
-
expect(dialog).toHaveBeenCalledTimes(1);
|
|
97
134
|
expect(request).toHaveBeenCalledWith('callback', serverSettings, {
|
|
98
135
|
method: 'POST',
|
|
99
136
|
body: JSON.stringify({ url: LINK })
|
|
100
137
|
});
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
138
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
139
|
+
expect(popup.status.textContent).toContain('The login accepted the link');
|
|
140
|
+
expectPlainWords(popup.status.textContent ?? '');
|
|
141
|
+
// Read in the popup, not in a toast, and the popup is still there.
|
|
142
|
+
expect(success).not.toHaveBeenCalled();
|
|
143
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
144
|
+
|
|
145
|
+
popup.close.click();
|
|
146
|
+
await popup.closed;
|
|
147
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
103
148
|
});
|
|
104
149
|
|
|
105
|
-
it('
|
|
150
|
+
it('Enter in the field sends, as OK does', async () => {
|
|
106
151
|
const { commands } = setup();
|
|
107
152
|
request.mockResolvedValue({ status: 200 });
|
|
153
|
+
const popup = await openPopup(commands);
|
|
108
154
|
|
|
109
|
-
|
|
155
|
+
popup.input.value = LINK;
|
|
156
|
+
popup.input.dispatchEvent(
|
|
157
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
|
158
|
+
);
|
|
159
|
+
await settle();
|
|
110
160
|
|
|
111
|
-
expect(dialog).not.toHaveBeenCalled();
|
|
112
161
|
expect(request).toHaveBeenCalledTimes(1);
|
|
113
|
-
expect(
|
|
162
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
114
163
|
});
|
|
115
164
|
|
|
116
|
-
it
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
165
|
+
it('a refused link is replaced and sent again in the same window', async () => {
|
|
166
|
+
const { commands } = setup();
|
|
167
|
+
request
|
|
168
|
+
.mockRejectedValueOnce(new Error('callback_unreachable'))
|
|
169
|
+
.mockResolvedValueOnce({ status: 302 });
|
|
170
|
+
const popup = await openPopup(commands);
|
|
171
|
+
|
|
172
|
+
popup.input.value = LINK;
|
|
173
|
+
popup.ok.click();
|
|
174
|
+
await settle();
|
|
175
|
+
expect(popup.status.dataset.level).toBe('error');
|
|
176
|
+
|
|
177
|
+
popup.input.value = LINK.replace('one-time', 'second');
|
|
178
|
+
popup.ok.click();
|
|
179
|
+
await settle();
|
|
180
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
181
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('OK on an empty field sends nothing and says so', async () => {
|
|
120
185
|
const { commands } = setup();
|
|
121
|
-
|
|
186
|
+
const popup = await openPopup(commands);
|
|
122
187
|
|
|
123
|
-
|
|
188
|
+
popup.input.value = ' ';
|
|
189
|
+
popup.ok.click();
|
|
190
|
+
await settle();
|
|
124
191
|
|
|
125
192
|
expect(request).not.toHaveBeenCalled();
|
|
126
|
-
expect(
|
|
127
|
-
expect(error).not.toHaveBeenCalled();
|
|
193
|
+
expect(popup.status.textContent).toBe('Paste the link first.');
|
|
128
194
|
});
|
|
129
195
|
|
|
130
|
-
it('
|
|
196
|
+
it('Close sends nothing', async () => {
|
|
131
197
|
const { commands } = setup();
|
|
132
|
-
|
|
198
|
+
const popup = await openPopup(commands);
|
|
133
199
|
|
|
134
|
-
|
|
200
|
+
popup.input.value = LINK;
|
|
201
|
+
popup.close.click();
|
|
202
|
+
await popup.closed;
|
|
135
203
|
|
|
136
|
-
expect(
|
|
137
|
-
expect(
|
|
138
|
-
expect(String(warning.mock.calls[0][0])).toContain('400');
|
|
204
|
+
expect(request).not.toHaveBeenCalled();
|
|
205
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
139
206
|
});
|
|
140
207
|
|
|
141
208
|
it.each([
|
|
142
|
-
[
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
209
|
+
[
|
|
210
|
+
'the login answers with an error',
|
|
211
|
+
() => Promise.resolve({ status: 400 }),
|
|
212
|
+
'warning',
|
|
213
|
+
'The login did not accept this link'
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
'the link is not a loopback link',
|
|
217
|
+
() => Promise.reject(new Error('callback_not_loopback')),
|
|
218
|
+
'error',
|
|
219
|
+
'It must start with http://127.0.0.1 or http://localhost'
|
|
220
|
+
],
|
|
221
|
+
[
|
|
222
|
+
'no login is listening',
|
|
223
|
+
() => Promise.reject(new Error('callback_unreachable')),
|
|
224
|
+
'error',
|
|
225
|
+
'No login is waiting for this link'
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
'the server fails',
|
|
229
|
+
() => Promise.reject(new Error('Internal Server Error')),
|
|
230
|
+
'error',
|
|
231
|
+
'The link could not be sent to the Jupyter server'
|
|
232
|
+
]
|
|
233
|
+
])('says in plain words that %s', async (_case, answer, level, expected) => {
|
|
146
234
|
const { commands } = setup();
|
|
147
|
-
request.
|
|
235
|
+
request.mockImplementation(answer);
|
|
236
|
+
const popup = await openPopup(commands);
|
|
237
|
+
|
|
238
|
+
popup.input.value = LINK;
|
|
239
|
+
popup.ok.click();
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
expect(popup.status.dataset.level).toBe(level);
|
|
243
|
+
expect(popup.status.textContent).toContain(expected);
|
|
244
|
+
expectPlainWords(popup.status.textContent ?? '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('given the link as an argument, opens no popup and reports in a toast', async () => {
|
|
248
|
+
const { commands } = setup();
|
|
249
|
+
request.mockRejectedValue(new Error('callback_unreachable'));
|
|
148
250
|
|
|
149
251
|
await commands.execute(CALLBACK_COMMAND, { url: LINK });
|
|
150
252
|
|
|
253
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
254
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
151
255
|
expect(error).toHaveBeenCalledTimes(1);
|
|
152
|
-
|
|
153
|
-
expect(message).toContain(expected);
|
|
154
|
-
// The link carries a login code.
|
|
155
|
-
expect(message).not.toContain('one-time');
|
|
256
|
+
expectPlainWords(String(error.mock.calls[0][0]));
|
|
156
257
|
});
|
|
157
258
|
});
|
package/src/core/callback.ts
CHANGED
|
@@ -1,16 +1,145 @@
|
|
|
1
1
|
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
-
import {
|
|
3
|
-
ICommandPalette,
|
|
4
|
-
InputDialog,
|
|
5
|
-
Notification
|
|
6
|
-
} from '@jupyterlab/apputils';
|
|
2
|
+
import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
7
3
|
import { TranslationBundle } from '@jupyterlab/translation';
|
|
4
|
+
import { Widget } from '@lumino/widgets';
|
|
8
5
|
|
|
9
6
|
import { requestAPI } from './request';
|
|
10
7
|
|
|
11
8
|
/** The command that delivers a pasted login callback link. */
|
|
12
9
|
export const CALLBACK_COMMAND = 'open:callback';
|
|
13
10
|
|
|
11
|
+
/** What a delivery came to, in words for the user. Never the link, which
|
|
12
|
+
* carries a login code, and never a status code. */
|
|
13
|
+
interface IOutcome {
|
|
14
|
+
level: 'success' | 'warning' | 'error';
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function deliver(
|
|
19
|
+
url: string,
|
|
20
|
+
app: JupyterFrontEnd,
|
|
21
|
+
trans: TranslationBundle
|
|
22
|
+
): Promise<IOutcome> {
|
|
23
|
+
try {
|
|
24
|
+
const answer = await requestAPI<{ status: number }>(
|
|
25
|
+
'callback',
|
|
26
|
+
app.serviceManager.serverSettings,
|
|
27
|
+
{ method: 'POST', body: JSON.stringify({ url }) }
|
|
28
|
+
);
|
|
29
|
+
return answer.status < 400
|
|
30
|
+
? {
|
|
31
|
+
level: 'success',
|
|
32
|
+
message: trans.__(
|
|
33
|
+
'The login accepted the link. Check the terminal where the login is running.'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
: {
|
|
37
|
+
level: 'warning',
|
|
38
|
+
message: trans.__(
|
|
39
|
+
'The login did not accept this link. Start the login again and paste the new link.'
|
|
40
|
+
)
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const code = err instanceof Error ? err.message : '';
|
|
44
|
+
return {
|
|
45
|
+
level: 'error',
|
|
46
|
+
message:
|
|
47
|
+
code === 'callback_not_loopback'
|
|
48
|
+
? trans.__(
|
|
49
|
+
'This link cannot be used. It must start with http://127.0.0.1 or http://localhost.'
|
|
50
|
+
)
|
|
51
|
+
: code === 'callback_unreachable'
|
|
52
|
+
? trans.__(
|
|
53
|
+
'No login is waiting for this link. Start the login again and paste the new link.'
|
|
54
|
+
)
|
|
55
|
+
: trans.__(
|
|
56
|
+
'The link could not be sent to the Jupyter server. Try again.'
|
|
57
|
+
)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The popup's body: the link field and, below it, what the last send came
|
|
63
|
+
* to. */
|
|
64
|
+
class CallbackForm extends Widget {
|
|
65
|
+
constructor(trans: TranslationBundle) {
|
|
66
|
+
super();
|
|
67
|
+
this.addClass('jp-AiAssistantsCallback');
|
|
68
|
+
const label = document.createElement('label');
|
|
69
|
+
label.textContent = trans.__('Callback link from the browser address bar');
|
|
70
|
+
this._input = document.createElement('input');
|
|
71
|
+
this._input.type = 'text';
|
|
72
|
+
this._input.className = 'jp-mod-styled';
|
|
73
|
+
this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
|
|
74
|
+
label.appendChild(this._input);
|
|
75
|
+
this._status = document.createElement('div');
|
|
76
|
+
this._status.className = 'jp-AiAssistantsCallback-status';
|
|
77
|
+
this._status.setAttribute('role', 'status');
|
|
78
|
+
this.node.append(label, this._status);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get url(): string {
|
|
82
|
+
return this._input.value.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
report(level: IOutcome['level'] | 'pending', message: string): void {
|
|
86
|
+
this._status.dataset.level = level;
|
|
87
|
+
this._status.textContent = message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private _input: HTMLInputElement;
|
|
91
|
+
private _status: HTMLDivElement;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** OK sends the link and leaves the window open, so the result is read - and
|
|
95
|
+
* a refused link replaced - in the same place. Close and Escape end it. */
|
|
96
|
+
class CallbackDialog extends Dialog<void> {
|
|
97
|
+
constructor(
|
|
98
|
+
private _form: CallbackForm,
|
|
99
|
+
private _send: (url: string) => Promise<IOutcome>,
|
|
100
|
+
private _trans: TranslationBundle
|
|
101
|
+
) {
|
|
102
|
+
super({
|
|
103
|
+
title: _trans.__('Open Callback'),
|
|
104
|
+
body: _form,
|
|
105
|
+
buttons: [
|
|
106
|
+
Dialog.cancelButton({ label: _trans.__('Close') }),
|
|
107
|
+
Dialog.okButton({ label: _trans.__('OK') })
|
|
108
|
+
],
|
|
109
|
+
defaultButton: 1,
|
|
110
|
+
focusNodeSelector: 'input'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resolve(index?: number): void {
|
|
115
|
+
if (index === 0) {
|
|
116
|
+
super.resolve(index);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
void this._deliver();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async _deliver(): Promise<void> {
|
|
123
|
+
const url = this._form.url;
|
|
124
|
+
if (this._sending) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!url) {
|
|
128
|
+
this._form.report('error', this._trans.__('Paste the link first.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this._sending = true;
|
|
132
|
+
this._form.report('pending', this._trans.__('Sending the link...'));
|
|
133
|
+
const outcome = await this._send(url);
|
|
134
|
+
this._sending = false;
|
|
135
|
+
if (!this.isDisposed) {
|
|
136
|
+
this._form.report(outcome.level, outcome.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private _sending = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
14
143
|
/**
|
|
15
144
|
* Register the command that sends a pasted login callback link to the login
|
|
16
145
|
* waiting on the Jupyter server.
|
|
@@ -37,62 +166,23 @@ export function addCallbackCommand(
|
|
|
37
166
|
properties: {
|
|
38
167
|
url: {
|
|
39
168
|
type: 'string',
|
|
40
|
-
description: 'The callback link. Asked for in a
|
|
169
|
+
description: 'The callback link. Asked for in a popup when absent.'
|
|
41
170
|
}
|
|
42
171
|
}
|
|
43
172
|
}
|
|
44
173
|
},
|
|
45
174
|
execute: async args => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
175
|
+
const send = (url: string): Promise<IOutcome> => deliver(url, app, trans);
|
|
176
|
+
const given = typeof args.url === 'string' ? args.url.trim() : '';
|
|
177
|
+
if (given) {
|
|
178
|
+
// No popup to write into, so the result is a toast.
|
|
179
|
+
const outcome = await send(given);
|
|
180
|
+
Notification[outcome.level](outcome.message, {
|
|
181
|
+
autoClose: outcome.level === 'success' ? 4000 : 8000
|
|
52
182
|
});
|
|
53
|
-
|
|
54
|
-
if (!url) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const answer = await requestAPI<{ status: number }>(
|
|
60
|
-
'callback',
|
|
61
|
-
app.serviceManager.serverSettings,
|
|
62
|
-
{ method: 'POST', body: JSON.stringify({ url }) }
|
|
63
|
-
);
|
|
64
|
-
if (answer.status < 400) {
|
|
65
|
-
Notification.success(
|
|
66
|
-
trans.__(
|
|
67
|
-
'Callback delivered - the login answered HTTP %1.',
|
|
68
|
-
answer.status
|
|
69
|
-
),
|
|
70
|
-
{ autoClose: 4000 }
|
|
71
|
-
);
|
|
72
|
-
} else {
|
|
73
|
-
Notification.warning(
|
|
74
|
-
trans.__(
|
|
75
|
-
'The login refused the callback (HTTP %1). Start the login again and paste the new link.',
|
|
76
|
-
answer.status
|
|
77
|
-
),
|
|
78
|
-
{ autoClose: 8000 }
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
} catch (err) {
|
|
82
|
-
// The server's own codes, never the link: it carries a login code.
|
|
83
|
-
const code = err instanceof Error ? err.message : String(err);
|
|
84
|
-
const message =
|
|
85
|
-
code === 'callback_not_loopback'
|
|
86
|
-
? trans.__(
|
|
87
|
-
'Only an http link to 127.0.0.1 or localhost can be opened.'
|
|
88
|
-
)
|
|
89
|
-
: code === 'callback_unreachable'
|
|
90
|
-
? trans.__(
|
|
91
|
-
'No login is waiting at that address. Start the login again and paste the new link.'
|
|
92
|
-
)
|
|
93
|
-
: trans.__('Could not open the callback: %1', code);
|
|
94
|
-
Notification.error(message, { autoClose: 8000 });
|
|
183
|
+
return;
|
|
95
184
|
}
|
|
185
|
+
await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
|
|
96
186
|
}
|
|
97
187
|
});
|
|
98
188
|
palette?.addItem({ command: CALLBACK_COMMAND, category });
|
package/src/core/icons.ts
CHANGED
|
@@ -126,15 +126,16 @@ export const filterIcon = new LabIcon({
|
|
|
126
126
|
svgstr: filterSvgStr
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
// Row status mark -
|
|
130
|
-
//
|
|
131
|
-
// a CSS `fill` on the circle
|
|
132
|
-
//
|
|
133
|
-
// attribute: the stylesheet is the
|
|
134
|
-
//
|
|
135
|
-
// (DEF-PANE-179).
|
|
129
|
+
// Row status mark - a light, drawn for a conversation that is live or under
|
|
130
|
+
// remote control: a 6px circle over the 8px disc that casts its halo. An SVG
|
|
131
|
+
// rather than a rounded span so each colour is a CSS `fill` on the circle
|
|
132
|
+
// itself, the way every other glyph here is recoloured through its class
|
|
133
|
+
// (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
|
|
134
|
+
// single source of those colours, and a presentation attribute here would be
|
|
135
|
+
// a second one to keep in step (DEF-PANE-179).
|
|
136
136
|
const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
137
|
-
<circle class="jp-AiAssistantsPanel-
|
|
137
|
+
<circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
|
|
138
|
+
<circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
|
|
138
139
|
</svg>`;
|
|
139
140
|
|
|
140
141
|
export const statusDotIcon = new LabIcon({
|
package/style/base.css
CHANGED
|
@@ -394,6 +394,15 @@
|
|
|
394
394
|
fill: var(--jp-success-color1);
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/* The 8px disc behind the 6px light. It is what casts the halo, so the halo
|
|
398
|
+
keeps the size it had when the light itself was 8px; fully opaque for that
|
|
399
|
+
reason, since a shadow is cast by alpha. The 1px of it that shows around the
|
|
400
|
+
light is painted as the halo looks at that point - the glow colour, dimmed -
|
|
401
|
+
so it reads as the halo's inner edge and not as a second dot. */
|
|
402
|
+
.jp-AiAssistantsPanel-dotHalo {
|
|
403
|
+
fill: color-mix(in srgb, var(--jp-success-color1) 65%, black);
|
|
404
|
+
}
|
|
405
|
+
|
|
397
406
|
/* Light themes take a different dot, because both halves of the one above stop
|
|
398
407
|
working there. JupyterLab stamps the theme's own `isLight` onto the body, so
|
|
399
408
|
the switch is CSS and no TypeScript sees it.
|
|
@@ -426,6 +435,12 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
|
|
|
426
435
|
fill: var(--md-green-500);
|
|
427
436
|
}
|
|
428
437
|
|
|
438
|
+
/* The mint ring's own colour: three passes saturate the ring at the disc's
|
|
439
|
+
edge, so undimmed mint is what the halo looks like there. */
|
|
440
|
+
body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
441
|
+
fill: var(--md-green-200);
|
|
442
|
+
}
|
|
443
|
+
|
|
429
444
|
.jp-AiAssistantsPanel-dotPlaceholder {
|
|
430
445
|
background-color: transparent;
|
|
431
446
|
}
|
|
@@ -1001,3 +1016,40 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
|
|
|
1001
1016
|
.jp-AiAssistantsPanel-row.jp-mod-stale .jp-AiAssistantsPanel-name {
|
|
1002
1017
|
opacity: 0.65;
|
|
1003
1018
|
}
|
|
1019
|
+
|
|
1020
|
+
/* The Open Callback popup. A callback link runs to about 250 characters, so
|
|
1021
|
+
the field takes the width a normal window can give it. */
|
|
1022
|
+
.jp-AiAssistantsCallback {
|
|
1023
|
+
width: min(640px, 80vw);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.jp-AiAssistantsCallback label {
|
|
1027
|
+
display: flex;
|
|
1028
|
+
flex-direction: column;
|
|
1029
|
+
gap: 4px;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.jp-AiAssistantsCallback input {
|
|
1033
|
+
width: 100%;
|
|
1034
|
+
box-sizing: border-box;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* What the last send came to. The height is reserved, so the buttons do not
|
|
1038
|
+
move when the text arrives. */
|
|
1039
|
+
.jp-AiAssistantsCallback-status {
|
|
1040
|
+
min-height: 1.5em;
|
|
1041
|
+
margin-top: 8px;
|
|
1042
|
+
color: var(--jp-ui-font-color2);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.jp-AiAssistantsCallback-status[data-level='success'] {
|
|
1046
|
+
color: var(--jp-success-color1);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.jp-AiAssistantsCallback-status[data-level='warning'] {
|
|
1050
|
+
color: var(--jp-warn-color1);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.jp-AiAssistantsCallback-status[data-level='error'] {
|
|
1054
|
+
color: var(--jp-error-color1);
|
|
1055
|
+
}
|