jupyterlab_ai_code_assistants_extension 1.2.41 → 1.2.47
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 +2 -1
- package/lib/core/callback.d.ts +15 -0
- package/lib/core/callback.js +65 -0
- package/lib/index.js +39 -3
- package/package.json +1 -1
- package/src/__tests__/callback.spec.ts +157 -0
- package/src/__tests__/index.spec.ts +132 -13
- package/src/core/callback.ts +99 -0
- package/src/index.ts +41 -3
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
27
27
|
|
|
28
28
|
- **One install, every assistant** - Claude Code, Codex, Kimi, Gemini and DeepSeek from a single package, each with its own right-side panel wearing its official mark
|
|
29
29
|
- **Provider registry** - assistant-specific behaviour lives in one module per assistant behind capability flags; no core file names an assistant, and adding one touches no core file
|
|
30
|
-
- **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload
|
|
30
|
+
- **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload, and a change reaches the other JupyterLab windows of the same browser
|
|
31
31
|
- **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
|
|
32
32
|
- **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated. Exception: a Codex or Kimi conversation started with `+`, and a Codex fork, run without an id until the CLI writes one, so a click on that row meanwhile opens a second terminal
|
|
33
33
|
- **Launcher tiles** - every enabled assistant has a tile in an "AI Assistants" section of the JupyterLab Launcher (after Other, under the extension's own robot-head icon); a click opens the assistant in the file browser's current folder, resuming the folder's conversation when one exists and starting a new one otherwise, through the companion `jupyterlab_basic_terminal_extension` (installed automatically). Disable an assistant and its tile goes; disable them all and the section goes with them
|
|
@@ -47,6 +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
51
|
|
|
51
52
|
## Requirements
|
|
52
53
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
3
|
+
import { TranslationBundle } from '@jupyterlab/translation';
|
|
4
|
+
/** The command that delivers a pasted login callback link. */
|
|
5
|
+
export declare const CALLBACK_COMMAND = "open:callback";
|
|
6
|
+
/**
|
|
7
|
+
* Register the command that sends a pasted login callback link to the login
|
|
8
|
+
* waiting on the Jupyter server.
|
|
9
|
+
*
|
|
10
|
+
* A CLI login waits for its OAuth redirect on the server's own loopback, but
|
|
11
|
+
* the browser that follows the redirect runs on the user's machine, where that
|
|
12
|
+
* address is another host - so the page fails to load, with the link left in
|
|
13
|
+
* the address bar. Pasted here, the server makes the request instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare function addCallbackCommand(app: JupyterFrontEnd, palette: ICommandPalette | null, category: string, trans: TranslationBundle): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { InputDialog, Notification } from '@jupyterlab/apputils';
|
|
2
|
+
import { requestAPI } from './request';
|
|
3
|
+
/** The command that delivers a pasted login callback link. */
|
|
4
|
+
export const CALLBACK_COMMAND = 'open:callback';
|
|
5
|
+
/**
|
|
6
|
+
* Register the command that sends a pasted login callback link to the login
|
|
7
|
+
* waiting on the Jupyter server.
|
|
8
|
+
*
|
|
9
|
+
* A CLI login waits for its OAuth redirect on the server's own loopback, but
|
|
10
|
+
* the browser that follows the redirect runs on the user's machine, where that
|
|
11
|
+
* address is another host - so the page fails to load, with the link left in
|
|
12
|
+
* the address bar. Pasted here, the server makes the request instead.
|
|
13
|
+
*/
|
|
14
|
+
export function addCallbackCommand(app, palette, category, trans) {
|
|
15
|
+
app.commands.addCommand(CALLBACK_COMMAND, {
|
|
16
|
+
label: trans.__('Open Callback'),
|
|
17
|
+
caption: trans.__('Send a pasted login callback link to the login waiting on the Jupyter server'),
|
|
18
|
+
describedBy: {
|
|
19
|
+
args: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
url: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'The callback link. Asked for in a dialog when absent.'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
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=...'
|
|
37
|
+
});
|
|
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 });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
palette === null || palette === void 0 ? void 0 : palette.addItem({ command: CALLBACK_COMMAND, category });
|
|
65
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
|
|
2
|
-
import { Notification } from '@jupyterlab/apputils';
|
|
2
|
+
import { ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
3
3
|
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
4
4
|
import { ILauncher } from '@jupyterlab/launcher';
|
|
5
5
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
6
6
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
7
7
|
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
8
8
|
import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
|
|
9
|
+
import { addCallbackCommand } from './core/callback';
|
|
9
10
|
import { launcherTileIcon, providerIcon } from './core/icons';
|
|
10
11
|
import { DEFAULT_RECENT_LIMIT } from './core/limits';
|
|
11
12
|
import { AssistantSessionsPanel, DEFAULT_PRESENTATION_MODE, commandId, panelWidgetId } from './core/panel';
|
|
@@ -40,9 +41,10 @@ const plugin = {
|
|
|
40
41
|
IDefaultFileBrowser,
|
|
41
42
|
IColourfulTabs,
|
|
42
43
|
ILauncher,
|
|
43
|
-
ITranslator
|
|
44
|
+
ITranslator,
|
|
45
|
+
ICommandPalette
|
|
44
46
|
],
|
|
45
|
-
activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator) => {
|
|
47
|
+
activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator, palette) => {
|
|
46
48
|
console.log('JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!');
|
|
47
49
|
// Every user-visible string in the panels goes through this bundle.
|
|
48
50
|
// `ITranslator` is optional, and the null translator returns each string
|
|
@@ -51,6 +53,7 @@ const plugin = {
|
|
|
51
53
|
// A duplicate id throws here, at registration, rather than surfacing later
|
|
52
54
|
// as two panels quietly fighting over one widget id.
|
|
53
55
|
const registry = new ProviderRegistry(PROVIDERS);
|
|
56
|
+
addCallbackCommand(app, palette !== null && palette !== void 0 ? palette : null, LAUNCHER_CATEGORY, trans);
|
|
54
57
|
const serverSettings = app.serviceManager.serverSettings;
|
|
55
58
|
const live = new Map();
|
|
56
59
|
const warnedUnknown = new Set();
|
|
@@ -402,6 +405,39 @@ const plugin = {
|
|
|
402
405
|
reprobe();
|
|
403
406
|
}
|
|
404
407
|
});
|
|
408
|
+
// Each JupyterLab window keeps its own copy of the settings and never
|
|
409
|
+
// reads them again, and every save writes that whole copy. So a window
|
|
410
|
+
// loaded before another window changed a setting put the old value back
|
|
411
|
+
// on its next save of any setting (DEF-PANE-282). A change in this window
|
|
412
|
+
// tells the other windows of this browser, and a window also checks when
|
|
413
|
+
// it gains focus, for a save from another browser or device. Either way
|
|
414
|
+
// it reloads only when the server's copy differs from its own: a reload
|
|
415
|
+
// replaces unsaved text in an open JSON settings editor, and the check
|
|
416
|
+
// also ends the echo, because the window that saved already holds the
|
|
417
|
+
// server's copy. Still open: another browser or device whose window saves
|
|
418
|
+
// without gaining focus first, or whose focusing click is itself the
|
|
419
|
+
// change - that needs a version check in JupyterLab's settings API
|
|
420
|
+
// (DEF-PANE-283).
|
|
421
|
+
if (settingRegistry && settings) {
|
|
422
|
+
const current = settings;
|
|
423
|
+
const channel = new BroadcastChannel(PLUGIN_ID);
|
|
424
|
+
const reload = async () => {
|
|
425
|
+
try {
|
|
426
|
+
const fetched = await settingRegistry.connector.fetch(PLUGIN_ID);
|
|
427
|
+
if (fetched && fetched.raw !== current.raw) {
|
|
428
|
+
await settingRegistry.reload(PLUGIN_ID);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
console.warn(`${LOG_PREFIX} could not reload settings.`, err);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
window.addEventListener('focus', () => void reload());
|
|
436
|
+
channel.onmessage = () => void reload();
|
|
437
|
+
current.changed.connect(() => {
|
|
438
|
+
channel.postMessage('changed');
|
|
439
|
+
});
|
|
440
|
+
}
|
|
405
441
|
console.log(`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`);
|
|
406
442
|
}
|
|
407
443
|
};
|
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.47",
|
|
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",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Open Callback command: a pasted login callback link, handed to the
|
|
3
|
+
* server. The server's side - loopback only, one request, nothing logged - is
|
|
4
|
+
* `tests/test_callback.py`; this is the popup, the request and what the user
|
|
5
|
+
* is told.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ESM-only packages that JupyterLab's apputils pulls in and jest cannot parse.
|
|
9
|
+
jest.mock('@jupyter/react-components', () => ({}));
|
|
10
|
+
jest.mock('@jupyter/web-components', () => ({
|
|
11
|
+
addJupyterLabThemeChangeListener: () => undefined,
|
|
12
|
+
applyJupyterTheme: () => undefined,
|
|
13
|
+
jpButton: () => undefined,
|
|
14
|
+
jpToolbar: () => undefined,
|
|
15
|
+
provideJupyterDesignSystem: () => ({ register: () => undefined })
|
|
16
|
+
}));
|
|
17
|
+
jest.mock('../core/request', () => ({
|
|
18
|
+
requestAPI: jest.fn()
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { InputDialog, Notification } from '@jupyterlab/apputils';
|
|
22
|
+
import { nullTranslator } from '@jupyterlab/translation';
|
|
23
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
24
|
+
|
|
25
|
+
import { CALLBACK_COMMAND, addCallbackCommand } from '../core/callback';
|
|
26
|
+
import { requestAPI } from '../core/request';
|
|
27
|
+
|
|
28
|
+
const request = requestAPI as jest.Mock;
|
|
29
|
+
const LINK = 'http://127.0.0.1:1455/auth/callback?code=one-time&state=s';
|
|
30
|
+
|
|
31
|
+
function setup(): {
|
|
32
|
+
commands: CommandRegistry;
|
|
33
|
+
added: { command: string; category: string }[];
|
|
34
|
+
serverSettings: object;
|
|
35
|
+
} {
|
|
36
|
+
const commands = new CommandRegistry();
|
|
37
|
+
const serverSettings = {};
|
|
38
|
+
const added: { command: string; category: string }[] = [];
|
|
39
|
+
addCallbackCommand(
|
|
40
|
+
{ commands, serviceManager: { serverSettings } } as any,
|
|
41
|
+
{ addItem: (item: any) => added.push(item) } as any,
|
|
42
|
+
'AI Assistants',
|
|
43
|
+
nullTranslator.load('test')
|
|
44
|
+
);
|
|
45
|
+
return { commands, added, serverSettings };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('the Open Callback command', () => {
|
|
49
|
+
let success: jest.SpyInstance;
|
|
50
|
+
let warning: jest.SpyInstance;
|
|
51
|
+
let error: jest.SpyInstance;
|
|
52
|
+
let dialog: jest.SpyInstance;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
request.mockReset();
|
|
56
|
+
success = jest.spyOn(Notification, 'success').mockReturnValue('' as any);
|
|
57
|
+
warning = jest.spyOn(Notification, 'warning').mockReturnValue('' as any);
|
|
58
|
+
error = jest.spyOn(Notification, 'error').mockReturnValue('' as any);
|
|
59
|
+
dialog = jest.spyOn(InputDialog, 'getText');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
jest.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is on the command palette under the assistants category', () => {
|
|
67
|
+
const { commands, added } = setup();
|
|
68
|
+
expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
|
|
69
|
+
expect(commands.label(CALLBACK_COMMAND)).toBe('Open Callback');
|
|
70
|
+
expect(added).toEqual([
|
|
71
|
+
{ command: CALLBACK_COMMAND, category: 'AI Assistants' }
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('registers without a palette', () => {
|
|
76
|
+
const commands = new CommandRegistry();
|
|
77
|
+
addCallbackCommand(
|
|
78
|
+
{ commands, serviceManager: { serverSettings: {} } } as any,
|
|
79
|
+
null,
|
|
80
|
+
'AI Assistants',
|
|
81
|
+
nullTranslator.load('test')
|
|
82
|
+
);
|
|
83
|
+
expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('asks for the link in a popup and posts it, trimmed, to the server', async () => {
|
|
87
|
+
const { commands, serverSettings } = setup();
|
|
88
|
+
dialog.mockResolvedValue({
|
|
89
|
+
button: { accept: true },
|
|
90
|
+
value: ` ${LINK}\n`
|
|
91
|
+
} as any);
|
|
92
|
+
request.mockResolvedValue({ status: 302 });
|
|
93
|
+
|
|
94
|
+
await commands.execute(CALLBACK_COMMAND);
|
|
95
|
+
|
|
96
|
+
expect(dialog).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(request).toHaveBeenCalledWith('callback', serverSettings, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
body: JSON.stringify({ url: LINK })
|
|
100
|
+
});
|
|
101
|
+
expect(success).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(String(success.mock.calls[0][0])).toContain('302');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('takes the link as an argument without a popup', async () => {
|
|
106
|
+
const { commands } = setup();
|
|
107
|
+
request.mockResolvedValue({ status: 200 });
|
|
108
|
+
|
|
109
|
+
await commands.execute(CALLBACK_COMMAND, { url: LINK });
|
|
110
|
+
|
|
111
|
+
expect(dialog).not.toHaveBeenCalled();
|
|
112
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(success).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
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) => {
|
|
120
|
+
const { commands } = setup();
|
|
121
|
+
dialog.mockResolvedValue(answer as any);
|
|
122
|
+
|
|
123
|
+
await commands.execute(CALLBACK_COMMAND);
|
|
124
|
+
|
|
125
|
+
expect(request).not.toHaveBeenCalled();
|
|
126
|
+
expect(success).not.toHaveBeenCalled();
|
|
127
|
+
expect(error).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('says so when the login refused the callback', async () => {
|
|
131
|
+
const { commands } = setup();
|
|
132
|
+
request.mockResolvedValue({ status: 400 });
|
|
133
|
+
|
|
134
|
+
await commands.execute(CALLBACK_COMMAND, { url: LINK });
|
|
135
|
+
|
|
136
|
+
expect(success).not.toHaveBeenCalled();
|
|
137
|
+
expect(warning).toHaveBeenCalledTimes(1);
|
|
138
|
+
expect(String(warning.mock.calls[0][0])).toContain('400');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
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) => {
|
|
146
|
+
const { commands } = setup();
|
|
147
|
+
request.mockRejectedValue(new Error(code));
|
|
148
|
+
|
|
149
|
+
await commands.execute(CALLBACK_COMMAND, { url: LINK });
|
|
150
|
+
|
|
151
|
+
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');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -61,6 +61,34 @@ async function flush(turns = 8): Promise<void> {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** jsdom has no BroadcastChannel. This one joins every window of the test
|
|
65
|
+
* process and delivers as a microtask, so `flush` settles it and, as in a
|
|
66
|
+
* browser, a sender never hears its own message. Two windows reloading each
|
|
67
|
+
* other would post forever and starve every timer, jest's own included, so
|
|
68
|
+
* past a cap no legitimate run reaches it throws instead of hanging. */
|
|
69
|
+
class TestBroadcastChannel {
|
|
70
|
+
private static _open: TestBroadcastChannel[] = [];
|
|
71
|
+
private static _posted = 0;
|
|
72
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(readonly name: string) {
|
|
75
|
+
TestBroadcastChannel._open.push(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
postMessage(data: unknown): void {
|
|
79
|
+
TestBroadcastChannel._posted += 1;
|
|
80
|
+
if (TestBroadcastChannel._posted > 1000) {
|
|
81
|
+
throw new Error('windows are reloading each other without end');
|
|
82
|
+
}
|
|
83
|
+
for (const other of TestBroadcastChannel._open) {
|
|
84
|
+
if (other !== this && other.name === this.name) {
|
|
85
|
+
queueMicrotask(() => other.onmessage?.({ data }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
(globalThis as any).BroadcastChannel = TestBroadcastChannel;
|
|
91
|
+
|
|
64
92
|
describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () => {
|
|
65
93
|
it('a panel docked before the first roster gains the root and the trash flag', async () => {
|
|
66
94
|
let roster: unknown = null;
|
|
@@ -82,12 +110,16 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
|
|
|
82
110
|
hasPlugin: () => false
|
|
83
111
|
} as any;
|
|
84
112
|
const settings = {
|
|
113
|
+
raw: '{}',
|
|
85
114
|
composite: {},
|
|
86
115
|
get: () => ({ composite: undefined, user: undefined }),
|
|
87
116
|
set: async () => undefined,
|
|
88
117
|
changed: new Signal<unknown, void>({})
|
|
89
118
|
};
|
|
90
|
-
const settingRegistry = {
|
|
119
|
+
const settingRegistry = {
|
|
120
|
+
connector: { fetch: async () => ({ raw: settings.raw }) },
|
|
121
|
+
load: async () => settings
|
|
122
|
+
} as any;
|
|
91
123
|
const fileBrowser = { model: { path: 'data/raw' } } as any;
|
|
92
124
|
|
|
93
125
|
await (plugin.activate as any)(
|
|
@@ -302,6 +334,10 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
302
334
|
});
|
|
303
335
|
|
|
304
336
|
describe('ACC-PROV - the activation contract', () => {
|
|
337
|
+
/** The settings file on the server, shared by every window activated here. */
|
|
338
|
+
let serverRaw = '{}';
|
|
339
|
+
let saves = 0;
|
|
340
|
+
|
|
305
341
|
/** A roster in which every assistant is present, or every one but `absent`. */
|
|
306
342
|
const roster = (absent?: string): unknown => ({
|
|
307
343
|
root_dir: '/srv/lab',
|
|
@@ -319,6 +355,11 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
319
355
|
restored: [AssistantSessionsPanel, string][];
|
|
320
356
|
/** How many times the status route has been asked. */
|
|
321
357
|
probes: () => number;
|
|
358
|
+
/** Plugin ids passed to `ISettingRegistry.reload`, in order. */
|
|
359
|
+
reloaded: string[];
|
|
360
|
+
/** Save from this window: the server and this window hold the new copy,
|
|
361
|
+
* and `changed` fires. */
|
|
362
|
+
save: () => void;
|
|
322
363
|
}
|
|
323
364
|
|
|
324
365
|
interface IOptions {
|
|
@@ -377,17 +418,29 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
377
418
|
|
|
378
419
|
const composite = options.composite;
|
|
379
420
|
const userSet = new Set(options.userSet ?? Object.keys(composite ?? {}));
|
|
380
|
-
const
|
|
421
|
+
const reloaded: string[] = [];
|
|
422
|
+
const changed = new Signal<unknown, void>({});
|
|
423
|
+
const loaded = composite && {
|
|
424
|
+
raw: serverRaw,
|
|
425
|
+
composite,
|
|
426
|
+
get: (key: string) => ({
|
|
427
|
+
composite: composite[key],
|
|
428
|
+
user: userSet.has(key) ? composite[key] : undefined
|
|
429
|
+
}),
|
|
430
|
+
set: async () => undefined,
|
|
431
|
+
changed
|
|
432
|
+
};
|
|
433
|
+
const settingRegistry = loaded
|
|
381
434
|
? ({
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
435
|
+
connector: { fetch: async () => ({ raw: serverRaw }) },
|
|
436
|
+
// JupyterLab's reload takes the server's copy and fires `changed`
|
|
437
|
+
// before it resolves.
|
|
438
|
+
reload: async (id: string) => {
|
|
439
|
+
reloaded.push(id);
|
|
440
|
+
loaded.raw = serverRaw;
|
|
441
|
+
changed.emit(undefined);
|
|
442
|
+
},
|
|
443
|
+
load: async () => loaded
|
|
391
444
|
} as any)
|
|
392
445
|
: null;
|
|
393
446
|
|
|
@@ -401,7 +454,21 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
401
454
|
null
|
|
402
455
|
);
|
|
403
456
|
await flush();
|
|
404
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
app,
|
|
459
|
+
docked,
|
|
460
|
+
restored,
|
|
461
|
+
probes: () => call,
|
|
462
|
+
reloaded,
|
|
463
|
+
save: () => {
|
|
464
|
+
saves += 1;
|
|
465
|
+
serverRaw = `{"saves": ${saves}}`;
|
|
466
|
+
if (loaded) {
|
|
467
|
+
loaded.raw = serverRaw;
|
|
468
|
+
}
|
|
469
|
+
changed.emit(undefined);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
405
472
|
}
|
|
406
473
|
|
|
407
474
|
/** Every assistant, turned off. */
|
|
@@ -413,6 +480,12 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
413
480
|
])
|
|
414
481
|
);
|
|
415
482
|
|
|
483
|
+
it('registers the Open Callback command at activation', async () => {
|
|
484
|
+
const live = await activate();
|
|
485
|
+
expect(live.app.commands.hasCommand('open:callback')).toBe(true);
|
|
486
|
+
live.docked.forEach(widget => widget.dispose());
|
|
487
|
+
});
|
|
488
|
+
|
|
416
489
|
it('ACC-PROV-10, ACC-PROV-11 - each panel carries its own widget id and registers it for restore', async () => {
|
|
417
490
|
const live = await activate();
|
|
418
491
|
// How this fails: pass a constant to `restorer.add` and two panels claim
|
|
@@ -534,6 +607,50 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
534
607
|
live.docked.forEach(widget => widget.dispose());
|
|
535
608
|
});
|
|
536
609
|
|
|
610
|
+
it('DEF-PANE-282 - a focus with no newer save leaves the settings alone', async () => {
|
|
611
|
+
const live = await activate({ composite: {} });
|
|
612
|
+
|
|
613
|
+
// A reload fires `changed`, and an open JSON settings editor then drops
|
|
614
|
+
// its unsaved text - so a focus that brings nothing newer must not reload.
|
|
615
|
+
window.dispatchEvent(new Event('focus'));
|
|
616
|
+
await flush(32);
|
|
617
|
+
expect(live.reloaded).toEqual([]);
|
|
618
|
+
|
|
619
|
+
live.docked.forEach(widget => widget.dispose());
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('DEF-PANE-282 - a focus after another device saved reloads the settings', async () => {
|
|
623
|
+
const live = await activate({ composite: {} });
|
|
624
|
+
|
|
625
|
+
// Saved elsewhere since this window loaded; this window's next save
|
|
626
|
+
// would write the old copy back.
|
|
627
|
+
serverRaw = '{"savedElsewhere": true}';
|
|
628
|
+
window.dispatchEvent(new Event('focus'));
|
|
629
|
+
await flush(32);
|
|
630
|
+
expect(live.reloaded).toEqual([
|
|
631
|
+
'jupyterlab_ai_code_assistants_extension:plugin'
|
|
632
|
+
]);
|
|
633
|
+
|
|
634
|
+
live.docked.forEach(widget => widget.dispose());
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('DEF-PANE-282 - a change in one window makes the other windows of the browser reload', async () => {
|
|
638
|
+
const one = await activate({ composite: {} });
|
|
639
|
+
const other = await activate({ composite: {} });
|
|
640
|
+
|
|
641
|
+
one.save();
|
|
642
|
+
await flush(32);
|
|
643
|
+
expect(other.reloaded).toEqual([
|
|
644
|
+
'jupyterlab_ai_code_assistants_extension:plugin'
|
|
645
|
+
]);
|
|
646
|
+
// The other window's reload fires `changed` and posts in turn; the window
|
|
647
|
+
// that saved already holds the server's copy, so the echo stops there.
|
|
648
|
+
expect(one.reloaded).toEqual([]);
|
|
649
|
+
|
|
650
|
+
one.docked.forEach(widget => widget.dispose());
|
|
651
|
+
other.docked.forEach(widget => widget.dispose());
|
|
652
|
+
});
|
|
653
|
+
|
|
537
654
|
it('ACC-PROV-16 - a binary installed after start gains its panel on the next probe', async () => {
|
|
538
655
|
const late = PROVIDERS[2].descriptor.id;
|
|
539
656
|
let present = false;
|
|
@@ -734,9 +851,11 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
734
851
|
user: key in composite ? composite[key] : undefined
|
|
735
852
|
}),
|
|
736
853
|
set: async () => undefined,
|
|
737
|
-
changed
|
|
854
|
+
changed,
|
|
855
|
+
raw: '{}'
|
|
738
856
|
};
|
|
739
857
|
const settingRegistry = {
|
|
858
|
+
connector: { fetch: async () => ({ raw: settings.raw }) },
|
|
740
859
|
load: async () => {
|
|
741
860
|
if (options.loadThrows) {
|
|
742
861
|
throw new Error('settings registry unavailable');
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import {
|
|
3
|
+
ICommandPalette,
|
|
4
|
+
InputDialog,
|
|
5
|
+
Notification
|
|
6
|
+
} from '@jupyterlab/apputils';
|
|
7
|
+
import { TranslationBundle } from '@jupyterlab/translation';
|
|
8
|
+
|
|
9
|
+
import { requestAPI } from './request';
|
|
10
|
+
|
|
11
|
+
/** The command that delivers a pasted login callback link. */
|
|
12
|
+
export const CALLBACK_COMMAND = 'open:callback';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register the command that sends a pasted login callback link to the login
|
|
16
|
+
* waiting on the Jupyter server.
|
|
17
|
+
*
|
|
18
|
+
* A CLI login waits for its OAuth redirect on the server's own loopback, but
|
|
19
|
+
* the browser that follows the redirect runs on the user's machine, where that
|
|
20
|
+
* address is another host - so the page fails to load, with the link left in
|
|
21
|
+
* the address bar. Pasted here, the server makes the request instead.
|
|
22
|
+
*/
|
|
23
|
+
export function addCallbackCommand(
|
|
24
|
+
app: JupyterFrontEnd,
|
|
25
|
+
palette: ICommandPalette | null,
|
|
26
|
+
category: string,
|
|
27
|
+
trans: TranslationBundle
|
|
28
|
+
): void {
|
|
29
|
+
app.commands.addCommand(CALLBACK_COMMAND, {
|
|
30
|
+
label: trans.__('Open Callback'),
|
|
31
|
+
caption: trans.__(
|
|
32
|
+
'Send a pasted login callback link to the login waiting on the Jupyter server'
|
|
33
|
+
),
|
|
34
|
+
describedBy: {
|
|
35
|
+
args: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
url: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'The callback link. Asked for in a dialog when absent.'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
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=...'
|
|
52
|
+
});
|
|
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 });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
palette?.addItem({ command: CALLBACK_COMMAND, category });
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
JupyterFrontEnd,
|
|
5
5
|
JupyterFrontEndPlugin
|
|
6
6
|
} from '@jupyterlab/application';
|
|
7
|
-
import { Notification } from '@jupyterlab/apputils';
|
|
7
|
+
import { ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
8
8
|
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
9
9
|
import { ILauncher } from '@jupyterlab/launcher';
|
|
10
10
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
@@ -13,6 +13,7 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
|
13
13
|
import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
|
|
14
14
|
import { IDisposable } from '@lumino/disposable';
|
|
15
15
|
|
|
16
|
+
import { addCallbackCommand } from './core/callback';
|
|
16
17
|
import { launcherTileIcon, providerIcon } from './core/icons';
|
|
17
18
|
import { DEFAULT_RECENT_LIMIT } from './core/limits';
|
|
18
19
|
import {
|
|
@@ -75,7 +76,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
75
76
|
IDefaultFileBrowser,
|
|
76
77
|
IColourfulTabs,
|
|
77
78
|
ILauncher,
|
|
78
|
-
ITranslator
|
|
79
|
+
ITranslator,
|
|
80
|
+
ICommandPalette
|
|
79
81
|
],
|
|
80
82
|
activate: async (
|
|
81
83
|
app: JupyterFrontEnd,
|
|
@@ -86,7 +88,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
86
88
|
fileBrowser: IDefaultFileBrowser | null,
|
|
87
89
|
colourfulTabs: IColourfulTabs | null,
|
|
88
90
|
launcher: ILauncher | null,
|
|
89
|
-
translator: ITranslator | null
|
|
91
|
+
translator: ITranslator | null,
|
|
92
|
+
palette: ICommandPalette | null
|
|
90
93
|
) => {
|
|
91
94
|
console.log(
|
|
92
95
|
'JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!'
|
|
@@ -103,6 +106,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
103
106
|
// as two panels quietly fighting over one widget id.
|
|
104
107
|
const registry = new ProviderRegistry(PROVIDERS);
|
|
105
108
|
|
|
109
|
+
addCallbackCommand(app, palette ?? null, LAUNCHER_CATEGORY, trans);
|
|
110
|
+
|
|
106
111
|
const serverSettings = app.serviceManager.serverSettings;
|
|
107
112
|
const live = new Map<string, ILivePanel>();
|
|
108
113
|
const warnedUnknown = new Set<string>();
|
|
@@ -502,6 +507,39 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
502
507
|
}
|
|
503
508
|
});
|
|
504
509
|
|
|
510
|
+
// Each JupyterLab window keeps its own copy of the settings and never
|
|
511
|
+
// reads them again, and every save writes that whole copy. So a window
|
|
512
|
+
// loaded before another window changed a setting put the old value back
|
|
513
|
+
// on its next save of any setting (DEF-PANE-282). A change in this window
|
|
514
|
+
// tells the other windows of this browser, and a window also checks when
|
|
515
|
+
// it gains focus, for a save from another browser or device. Either way
|
|
516
|
+
// it reloads only when the server's copy differs from its own: a reload
|
|
517
|
+
// replaces unsaved text in an open JSON settings editor, and the check
|
|
518
|
+
// also ends the echo, because the window that saved already holds the
|
|
519
|
+
// server's copy. Still open: another browser or device whose window saves
|
|
520
|
+
// without gaining focus first, or whose focusing click is itself the
|
|
521
|
+
// change - that needs a version check in JupyterLab's settings API
|
|
522
|
+
// (DEF-PANE-283).
|
|
523
|
+
if (settingRegistry && settings) {
|
|
524
|
+
const current = settings;
|
|
525
|
+
const channel = new BroadcastChannel(PLUGIN_ID);
|
|
526
|
+
const reload = async (): Promise<void> => {
|
|
527
|
+
try {
|
|
528
|
+
const fetched = await settingRegistry.connector.fetch(PLUGIN_ID);
|
|
529
|
+
if (fetched && fetched.raw !== current.raw) {
|
|
530
|
+
await settingRegistry.reload(PLUGIN_ID);
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.warn(`${LOG_PREFIX} could not reload settings.`, err);
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
window.addEventListener('focus', () => void reload());
|
|
537
|
+
channel.onmessage = () => void reload();
|
|
538
|
+
current.changed.connect(() => {
|
|
539
|
+
channel.postMessage('changed');
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
505
543
|
console.log(
|
|
506
544
|
`${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`
|
|
507
545
|
);
|