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 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
 
@@ -1,7 +1,103 @@
1
- import { InputDialog, Notification } from '@jupyterlab/apputils';
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 dialog when absent.'
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
- var _a;
31
- let url = typeof args.url === 'string' ? args.url.trim() : '';
32
- if (!url) {
33
- const asked = await InputDialog.getText({
34
- title: trans.__('Open Callback'),
35
- label: trans.__('Callback link from the browser address bar'),
36
- placeholder: 'http://127.0.0.1:1455/auth/callback?code=...'
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
- url = asked.button.accept ? ((_a = asked.value) !== null && _a !== void 0 ? _a : '').trim() : '';
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 - one filled circle, drawn for a conversation that is live
107
- // or under remote control. An SVG rather than a rounded span so its colour is
108
- // a CSS `fill` on the circle itself, the way every other glyph here is
109
- // recoloured through its class (DEF-PANE-174). The circle carries no `fill`
110
- // attribute: the stylesheet is the single source of that colour, and a
111
- // presentation attribute here would be a second one to keep in step
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-dotGlyph" cx="8" cy="8" r="8"/>
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.47",
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 { InputDialog, Notification } from '@jupyterlab/apputils';
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('asks for the link in a popup and posts it, trimmed, to the server', async () => {
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
- await commands.execute(CALLBACK_COMMAND);
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(success).toHaveBeenCalledTimes(1);
102
- expect(String(success.mock.calls[0][0])).toContain('302');
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('takes the link as an argument without a popup', async () => {
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
- await commands.execute(CALLBACK_COMMAND, { url: LINK });
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(success).toHaveBeenCalledTimes(1);
162
+ expect(popup.status.dataset.level).toBe('success');
114
163
  });
115
164
 
116
- it.each([
117
- ['cancelled', { button: { accept: false }, value: LINK }],
118
- ['left empty', { button: { accept: true }, value: ' ' }]
119
- ])('sends nothing when the popup is %s', async (_name, answer) => {
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
- dialog.mockResolvedValue(answer as any);
186
+ const popup = await openPopup(commands);
122
187
 
123
- await commands.execute(CALLBACK_COMMAND);
188
+ popup.input.value = ' ';
189
+ popup.ok.click();
190
+ await settle();
124
191
 
125
192
  expect(request).not.toHaveBeenCalled();
126
- expect(success).not.toHaveBeenCalled();
127
- expect(error).not.toHaveBeenCalled();
193
+ expect(popup.status.textContent).toBe('Paste the link first.');
128
194
  });
129
195
 
130
- it('says so when the login refused the callback', async () => {
196
+ it('Close sends nothing', async () => {
131
197
  const { commands } = setup();
132
- request.mockResolvedValue({ status: 400 });
198
+ const popup = await openPopup(commands);
133
199
 
134
- await commands.execute(CALLBACK_COMMAND, { url: LINK });
200
+ popup.input.value = LINK;
201
+ popup.close.click();
202
+ await popup.closed;
135
203
 
136
- expect(success).not.toHaveBeenCalled();
137
- expect(warning).toHaveBeenCalledTimes(1);
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
- ['callback_not_loopback', '127.0.0.1'],
143
- ['callback_unreachable', 'No login is waiting'],
144
- ['Internal Server Error', 'Internal Server Error']
145
- ])('reports %s without repeating the link', async (code, expected) => {
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.mockRejectedValue(new Error(code));
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
- const message = String(error.mock.calls[0][0]);
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
  });
@@ -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 dialog when absent.'
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
- let url = typeof args.url === 'string' ? args.url.trim() : '';
47
- if (!url) {
48
- const asked = await InputDialog.getText({
49
- title: trans.__('Open Callback'),
50
- label: trans.__('Callback link from the browser address bar'),
51
- placeholder: 'http://127.0.0.1:1455/auth/callback?code=...'
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
- url = asked.button.accept ? (asked.value ?? '').trim() : '';
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 - one filled circle, drawn for a conversation that is live
130
- // or under remote control. An SVG rather than a rounded span so its colour is
131
- // a CSS `fill` on the circle itself, the way every other glyph here is
132
- // recoloured through its class (DEF-PANE-174). The circle carries no `fill`
133
- // attribute: the stylesheet is the single source of that colour, and a
134
- // presentation attribute here would be a second one to keep in step
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-dotGlyph" cx="8" cy="8" r="8"/>
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
+ }