@oceanum/oceanumlab 4.6.2 → 4.7.1

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.
Files changed (62) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +24 -2
  5. package/lib/DatameshWidget.js +171 -45
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +66 -0
  11. package/lib/aiBackend.js +101 -0
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +17 -1
  23. package/lib/chatRouter.js +34 -9
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +8 -16
  28. package/lib/httpUrl.d.ts +9 -0
  29. package/lib/httpUrl.js +20 -0
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +67 -15
  32. package/lib/specStore.d.ts +37 -0
  33. package/lib/specStore.js +57 -0
  34. package/package.json +9 -1
  35. package/schema/datamesh-connect.json +3 -6
  36. package/src/DatameshUI.tsx +1 -10
  37. package/src/DatameshWidget.tsx +294 -59
  38. package/src/StoredNotebooks.tsx +166 -0
  39. package/src/Tabs.tsx +102 -0
  40. package/src/__tests__/DatameshWidget.spec.tsx +393 -0
  41. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  42. package/src/__tests__/Tabs.spec.tsx +168 -0
  43. package/src/__tests__/aiBackend.spec.ts +271 -0
  44. package/src/__tests__/chatRouter.spec.ts +253 -17
  45. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  46. package/src/__tests__/pluginArity.spec.ts +24 -0
  47. package/src/__tests__/serverAuth.spec.ts +277 -0
  48. package/src/__tests__/settingsSchema.spec.ts +73 -0
  49. package/src/__tests__/specStore.spec.ts +126 -0
  50. package/src/aiBackend.ts +150 -0
  51. package/src/auth/accountWidget.ts +113 -0
  52. package/src/auth/claims.ts +30 -0
  53. package/src/auth/plugin.ts +155 -0
  54. package/src/auth/serverAuth.ts +341 -0
  55. package/src/auth/tokens.ts +104 -0
  56. package/src/chatRouter.ts +41 -9
  57. package/src/constants.ts +1 -1
  58. package/src/datameshUiUrl.ts +8 -15
  59. package/src/httpUrl.ts +23 -0
  60. package/src/index.ts +99 -33
  61. package/src/specStore.ts +82 -0
  62. package/style/index.css +158 -0
package/README.md CHANGED
@@ -19,6 +19,62 @@ To install the extension, execute:
19
19
  pip install oceanumlab
20
20
  ```
21
21
 
22
+ ## Oceanum.io sign-in
23
+
24
+ A **Sign in to Oceanum.io** control sits in the top bar. Signing in gives the Notebooks tab
25
+ your stored notebooks and lets Oceanum AI act as you, with no Datamesh token to paste.
26
+
27
+ It uses the OAuth **device grant**, run by oceanumlab's Jupyter server extension: click
28
+ **Sign in**, open the link it shows, check the code matches, and sign in on Oceanum.io as
29
+ usual. The dialog closes by itself when you are done. It works with no configuration, and
30
+ from any address — `localhost` on any port, a JupyterHub, a remote server.
31
+
32
+ That last point is why it works this way. A browser sign-in (a popup, a redirect, the
33
+ Oceanum widget) has to tell Auth0 which page to return to, Auth0 only accepts addresses
34
+ registered in advance, and it cannot wildcard a port or an arbitrary host. A JupyterLab's
35
+ address is not knowable ahead of time, so on anything but Oceanum's own sites a browser
36
+ sign-in fails with "Callback URL mismatch". The device grant has no callback.
37
+
38
+ The refresh token stays in the Jupyter server's memory and never reaches the page or the
39
+ disk, so restarting the server signs you out.
40
+
41
+ ### Configuration
42
+
43
+ Sign-in is server configuration, not a frontend setting: which Auth0 tenant a notebook signs
44
+ in to, and which services its tokens are sent to, is the deployment's decision, and a
45
+ user-editable setting could be pointed at someone else's. In `jupyter_server_config.py` —
46
+ `~/.jupyter/` for one user, or `etc/jupyter/` under the environment prefix for everyone
47
+ using it; `jupyter --paths` lists both:
48
+
49
+ ```python
50
+ c.OceanumLab.sign_in = "off" # "device" is the default
51
+ ```
52
+
53
+ To sign in to a deployment other than Oceanum production, replace the environment whole:
54
+
55
+ ```python
56
+ c.OceanumLab.device_environment = {
57
+ "auth0Domain": "auth.example.org",
58
+ "clientId": "<an Auth0 Native application with the Device Code grant>",
59
+ "audience": "",
60
+ "oceanumDomain": "example.org",
61
+ "urls": {
62
+ "datamesh": "https://datamesh.example.org",
63
+ "specs": "https://specs.example.org",
64
+ "manage": "https://manage.example.org",
65
+ },
66
+ }
67
+ ```
68
+
69
+ **This file is Python, not JSON** — `True`, not `true`. A syntax error there is quiet and
70
+ expensive: traitlets logs it once at startup and then skips the **whole** file, so the
71
+ setting silently does not apply. Check the server's startup log for
72
+ `oceanumlab: Oceanum.io sign-in by device code`.
73
+
74
+ notebook.oceanum.io is a JupyterLite site with no Jupyter server, so none of this applies
75
+ there. It signs in with the Oceanum widget, from its own extension, and lists
76
+ `@oceanum/oceanumlab:device-auth` under `disabledExtensions`.
77
+
22
78
  ## Uninstall
23
79
 
24
80
  To remove the extension, execute:
@@ -31,27 +87,59 @@ pip uninstall oceanumlab
31
87
 
32
88
  ### Development install
33
89
 
34
- Note: You will need NodeJS 22.12 or later (or 20.19+) to build the extension package.
90
+ The extension is built with [jupyter-builder](https://github.com/jupyterlab/jupyter-builder),
91
+ which bundles it with rspack. You will need:
92
+
93
+ - Python 3.10 or later
94
+ - Node.js 22.12 or later (or 20.19+), which rspack requires
35
95
 
36
- The `jlpm` command is JupyterLab's pinned version of
37
- [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
38
- `yarn` or `npm` in lieu of `jlpm` below.
96
+ The `jlpm` command is a pinned version of [yarn](https://yarnpkg.com/) that is
97
+ installed with jupyter-builder. You may use `yarn` or `npm` in lieu of `jlpm` below.
98
+
99
+ Create an environment, either with conda, which also installs Node.js and jupyter-builder:
39
100
 
40
101
  ```bash
41
102
  # Clone the repo to your local environment
42
103
  # Change directory to the oceanumlab directory
43
- # Install package in development mode, with jupyter-builder for building the extension
44
- pip install -e ".[dev]"
104
+ conda env create -f environment.yml
105
+ conda activate oceanumlab
106
+ ```
107
+
108
+ or with a virtual environment, using your own Node.js:
109
+
110
+ ```bash
111
+ python -m venv .venv
112
+ source .venv/bin/activate
113
+ ```
114
+
115
+ Then install the extension in development mode:
116
+
117
+ ```bash
118
+ # Install the package in development mode. The dev extra installs jupyter-builder
119
+ # and the test extra installs the server test dependencies. This also enables the
120
+ # server extension.
121
+ pip install -e ".[dev,test]"
45
122
  # Link your development version of the extension with JupyterLab
46
123
  jupyter-builder develop . --overwrite
47
- # Rebuild extension Typescript source after making changes
124
+ # Rebuild the extension after making changes
125
+ # Unlike the steps above, which you only do once, do this every time you make a change
48
126
  jlpm build
49
127
  ```
50
128
 
129
+ Every `pip install -e` puts a copy of the built extension back in place of the link,
130
+ so run `jupyter-builder develop . --overwrite` again after reinstalling.
131
+
132
+ The main build commands are:
133
+
134
+ - `jlpm build`: development build, with source maps
135
+ - `jlpm build:prod`: production build, as used for releases
136
+ - `jlpm clean:all`: remove the built TypeScript, the built extension and the lint caches
137
+
51
138
  You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
52
139
 
53
140
  ```bash
54
141
  # Watch the source directory in one terminal, automatically rebuilding when needed
142
+ # (runs tsc and jupyter-builder in watch mode)
55
143
  jlpm watch
56
144
  # Run JupyterLab in another terminal
57
145
  jupyter lab
@@ -71,9 +159,9 @@ jupyter lab build --minimize=False
71
159
  pip uninstall oceanumlab
72
160
  ```
73
161
 
74
- In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
162
+ In development mode, you will also need to remove the symlink created by the `jupyter-builder develop`
75
163
  command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
76
- folder is located. Then you can remove the symlink named `oceanumlab` within that folder.
164
+ folder is located. Then you can remove the symlink named `@oceanum/oceanumlab` within that folder.
77
165
 
78
166
  ### Testing the extension
79
167
 
@@ -81,10 +169,13 @@ folder is located. Then you can remove the symlink named `oceanumlab` within tha
81
169
 
82
170
  This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
83
171
 
84
- Install test dependencies (needed only once):
172
+ The test dependencies are installed by `pip install -e ".[dev,test]"` above. If you
173
+ installed without the test extra, install them (needed only once) and restore the
174
+ front-end extension link:
85
175
 
86
176
  ```sh
87
- pip install -e ".[test]"
177
+ pip install -e ".[dev,test]"
178
+ jupyter-builder develop . --overwrite
88
179
  ```
89
180
 
90
181
  To execute them, run:
@@ -2,13 +2,8 @@ import React from 'react';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
3
  export declare class DatameshUI extends ReactWidget {
4
4
  private _url;
5
- /** `url` is the `datameshUiUrl` setting. */
5
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
6
6
  constructor(_url: string);
7
- /**
8
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
9
- * reloads only if its address actually changes.
10
- */
11
- set url(url: string);
12
7
  /** The iframe's window, for telling its messages apart; `null` if none. */
13
8
  get frame(): Window | null;
14
9
  render(): React.ReactElement;
package/lib/DatameshUI.js CHANGED
@@ -2,19 +2,11 @@ import React from 'react';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
3
  import { datameshUiSrc } from './datameshUiUrl';
4
4
  export class DatameshUI extends ReactWidget {
5
- /** `url` is the `datameshUiUrl` setting. */
5
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
6
6
  constructor(_url) {
7
7
  super();
8
8
  this._url = _url;
9
9
  }
10
- /**
11
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
12
- * reloads only if its address actually changes.
13
- */
14
- set url(url) {
15
- this._url = url;
16
- this.update();
17
- }
18
10
  /** The iframe's window, for telling its messages apart; `null` if none. */
19
11
  get frame() {
20
12
  var _a, _b;
@@ -2,9 +2,10 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
3
  import { LabIcon } from '@jupyterlab/ui-components';
4
4
  import { CommandRegistry } from '@lumino/commands';
5
- import { Signal } from '@lumino/signaling';
5
+ import { ISignal, Signal } from '@lumino/signaling';
6
6
  import { Widget } from '@lumino/widgets';
7
7
  import React from 'react';
8
+ import { IOceanumAuth } from './auth/tokens';
8
9
  export interface IDatasource {
9
10
  id: string;
10
11
  label: string;
@@ -40,12 +41,22 @@ export interface IDatameshWidgetProps {
40
41
  name: string;
41
42
  icon: LabIcon;
42
43
  openDatameshUI: any;
43
- /** The `datameshUiUrl` setting. */
44
+ /** The deployment's Datamesh UI address. */
44
45
  datameshUiUrl: () => string;
45
46
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
46
47
  datameshUiFrame: () => Window | null;
48
+ /** The `datameshToken` setting. */
49
+ datameshToken: () => string;
50
+ /** The deployment's Oceanum AI address. */
51
+ aiBackendUrl: () => string;
52
+ /** Emitted when the settings change. */
53
+ settingsChanged: ISignal<unknown, void>;
47
54
  commands: CommandRegistry;
48
55
  getCurrentWidget: () => Widget;
56
+ /** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
57
+ auth?: IOceanumAuth | null;
58
+ /** Open a stored notebook by spec store id. */
59
+ openStoredNotebook?: (id: string) => void;
49
60
  }
50
61
  /**
51
62
  * A widget for Datamesh Connections.
@@ -59,5 +70,16 @@ export declare class DatameshConnectWidget extends ReactWidget {
59
70
  constructor(props: IDatameshWidgetProps);
60
71
  receiveIFrameMessage(event: MessageEvent): void;
61
72
  renderDisplay(datameshWorkspace: IWorkspaceSpec): React.ReactElement;
73
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
74
+ renderDatamesh(): React.ReactElement;
62
75
  render(): React.ReactElement;
76
+ /**
77
+ * The visible tab. The widget only holds it and announces changes through
78
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
79
+ * restores a widget's place in the shell rather than fields on it.
80
+ */
81
+ get selectedTab(): string;
82
+ selectTab(id: string): void;
83
+ readonly tabChanged: Signal<this, string>;
84
+ private _selectedTab;
63
85
  }
@@ -9,7 +9,9 @@ import { Signal } from '@lumino/signaling';
9
9
  import React from 'react';
10
10
  import { marked } from 'marked';
11
11
  import { DatasourceItem } from './DatasourceItem';
12
- import { OCEANUM_AI_BACKEND_URL } from './constants';
12
+ import { StoredNotebooks } from './StoredNotebooks';
13
+ import { Tabs } from './Tabs';
14
+ import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
13
15
  import { isDatameshUiMessage } from './datameshUiUrl';
14
16
  import { describeProgress, onProgress } from './progress';
15
17
  // Configure marked for safe rendering
@@ -284,30 +286,73 @@ class DatameshWorkspaceDisplay extends React.Component {
284
286
  });
285
287
  }
286
288
  }
289
+ function readAiAccess(commands, settings) {
290
+ const datameshToken = settings.datameshToken();
291
+ return {
292
+ source: aiCredentialSource(datameshToken, commands),
293
+ pasted: pastedToken(datameshToken),
294
+ canSignIn: canSignIn(commands),
295
+ backend: aiBackendUrl(settings.aiBackendUrl())
296
+ };
297
+ }
287
298
  /**
288
- * Shows a message prompting user to configure their Datamesh token.
289
- * Only renders when no token is set.
299
+ * How the sidebar can reach Oceanum AI, read again whenever the settings or the
300
+ * commands change: the host calls `notifyCommandChanged` when the user signs
301
+ * in or out.
302
+ *
303
+ * Only the registry's queries are used, never `execute`, and nothing runs on a
304
+ * timer: every executed command closes JupyterLab's command palette.
290
305
  */
291
- function TokenConfigMessage({ commands }) {
292
- const [hasToken, setHasToken] = React.useState(!!window.datameshToken);
306
+ function useAiAccess(commands, settings) {
307
+ const [access, setAccess] = React.useState(() => readAiAccess(commands, settings));
293
308
  React.useEffect(() => {
294
- const checkToken = () => setHasToken(!!window.datameshToken);
295
- const interval = setInterval(checkToken, 1000);
296
- return () => clearInterval(interval);
297
- }, []);
298
- if (hasToken) {
309
+ const update = () => {
310
+ const next = readAiAccess(commands, settings);
311
+ // Same content keeps the same object, so nothing downstream re-runs.
312
+ setAccess(prev => JSON.stringify(prev) === JSON.stringify(next) ? prev : next);
313
+ };
314
+ // Anything that changed between the first render and now.
315
+ update();
316
+ commands.commandChanged.connect(update);
317
+ settings.settingsChanged.connect(update);
318
+ return () => {
319
+ commands.commandChanged.disconnect(update);
320
+ settings.settingsChanged.disconnect(update);
321
+ };
322
+ }, [commands, settings]);
323
+ return access;
324
+ }
325
+ /**
326
+ * Shows a message prompting the user to sign in to Oceanum.io, or to configure
327
+ * their Datamesh token where the site has no sign-in. Only renders when there
328
+ * is no credential.
329
+ */
330
+ function TokenConfigMessage({ commands, settings }) {
331
+ const access = useAiAccess(commands, settings);
332
+ if (access.source) {
299
333
  return null;
300
334
  }
335
+ const openSettings = () => commands.execute('settingeditor:open', { query: 'Oceanum' });
336
+ if (access.canSignIn) {
337
+ const signIn = () => commands.execute(SIGN_IN_COMMAND).catch(() => {
338
+ console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
339
+ });
340
+ return (React.createElement("div", { className: "oceanum-token-config" },
341
+ React.createElement("a", { onClick: () => void signIn() }, "Sign in to Oceanum.io"),
342
+ " to use Oceanum AI, or set a ",
343
+ React.createElement("a", { onClick: openSettings }, "Datamesh token"),
344
+ "."));
345
+ }
301
346
  return (React.createElement("div", { className: "oceanum-token-config" },
302
347
  "Set your",
303
348
  ' ',
304
349
  React.createElement("a", { onClick: () => commands.execute('settingeditor:open', { query: 'Oceanum' }) }, "Datamesh token"),
305
350
  ' ',
306
- "to enable Oceanum.oi services",
351
+ "to enable Oceanum.io services",
307
352
  ' ',
308
353
  React.createElement("a", { href: "https://home.oceanum.io/account", target: "_blank", rel: "noopener noreferrer" }, "Get token here")));
309
354
  }
310
- function AIChatPanel({ commands }) {
355
+ function AIChatPanel({ commands, settings }) {
311
356
  const [messages, setMessages] = React.useState([]);
312
357
  const [input, setInput] = React.useState('');
313
358
  const [loading, setLoading] = React.useState(false);
@@ -322,49 +367,75 @@ function AIChatPanel({ commands }) {
322
367
  const [history, setHistory] = React.useState([]);
323
368
  const [historyIndex, setHistoryIndex] = React.useState(-1);
324
369
  const [tempInput, setTempInput] = React.useState('');
325
- // Track the actual token value to detect changes
326
- const [token, setToken] = React.useState(window.datameshToken || '');
327
- const hasToken = !!token;
370
+ // Which credential and address the chat has, as the settings and the host
371
+ // report them.
372
+ const access = useAiAccess(commands, settings);
373
+ // What the capabilities depend on: which credential, the pasted token and
374
+ // the address. Not the sign-in token itself: it refreshes, and finding it out
375
+ // runs the host's command.
376
+ const capabilitiesKey = access.source
377
+ ? JSON.stringify([access.source, access.pasted, access.backend])
378
+ : '';
328
379
  // Check if code capability is enabled (null = loading/unknown)
329
380
  const [codeEnabled, setCodeEnabled] = React.useState(null);
330
- // Re-check token periodically (settings may change)
331
- React.useEffect(() => {
332
- const checkToken = () => {
333
- const currentToken = window.datameshToken || '';
334
- if (currentToken !== token) {
335
- setToken(currentToken);
336
- }
337
- };
338
- const interval = setInterval(checkToken, 1000);
339
- return () => clearInterval(interval);
340
- }, [token]);
341
- // Fetch capabilities when token changes
381
+ // The sidebar's one pending access-token request, shared rather than
382
+ // repeated if the capabilities are asked for again before it answers.
383
+ const tokenRequest = React.useRef(null);
384
+ // Fetch capabilities when the credential or the address changes
342
385
  React.useEffect(() => {
343
- if (!token) {
386
+ if (!access.source) {
344
387
  setCodeEnabled(null);
345
388
  return;
346
389
  }
390
+ // An answer for a credential that has since changed is dropped.
391
+ let current = true;
392
+ const signInTokenOnce = () => {
393
+ if (!tokenRequest.current) {
394
+ tokenRequest.current = signInToken(commands)().finally(() => {
395
+ tokenRequest.current = null;
396
+ });
397
+ }
398
+ return tokenRequest.current;
399
+ };
347
400
  const fetchCapabilities = async () => {
401
+ const credential = await resolveAiCredential(access.pasted, signInTokenOnce);
402
+ if (!current) {
403
+ return;
404
+ }
405
+ if (!credential) {
406
+ // The host says signed in but has no token to give: leave it to a
407
+ // request to report, rather than hide the chat.
408
+ setCodeEnabled(null);
409
+ return;
410
+ }
348
411
  try {
349
- const response = await fetch(`${OCEANUM_AI_BACKEND_URL}/api/capabilities`, {
350
- headers: {
351
- 'X-Datamesh-Token': token
352
- }
412
+ const response = await fetch(`${access.backend}/api/capabilities`, {
413
+ headers: credential.headers
353
414
  });
415
+ if (!current) {
416
+ return;
417
+ }
354
418
  if (response.ok) {
355
419
  const data = await response.json();
356
- setCodeEnabled(data.code === true);
420
+ if (current) {
421
+ setCodeEnabled(data.code === true);
422
+ }
357
423
  }
358
424
  else {
359
425
  setCodeEnabled(false);
360
426
  }
361
427
  }
362
428
  catch (_a) {
363
- setCodeEnabled(false);
429
+ if (current) {
430
+ setCodeEnabled(false);
431
+ }
364
432
  }
365
433
  };
366
434
  void fetchCapabilities();
367
- }, [token]);
435
+ return () => {
436
+ current = false;
437
+ };
438
+ }, [capabilitiesKey]);
368
439
  // Auto-scroll to bottom when messages change
369
440
  React.useEffect(() => {
370
441
  var _a;
@@ -501,8 +572,8 @@ function AIChatPanel({ commands }) {
501
572
  return;
502
573
  }
503
574
  };
504
- // Hide AI panel if no token or code capability is not enabled
505
- if (!hasToken || codeEnabled === false) {
575
+ // Hide AI panel if no credential or code capability is not enabled
576
+ if (!access.source || codeEnabled === false) {
506
577
  return null;
507
578
  }
508
579
  return (React.createElement("div", { className: "oceanum-ai-chat" },
@@ -529,6 +600,37 @@ function AIChatPanel({ commands }) {
529
600
  ? commands.execute('oceanum-ai:stop')
530
601
  : handleSubmit()), disabled: !loading && !input.trim(), title: loading ? 'Stop the current response' : undefined }, loading ? 'Stop' : 'Send'))));
531
602
  }
603
+ /**
604
+ * The panel's tabs.
605
+ *
606
+ * A component rather than a list built in `render`, because whether Oceanum AI is
607
+ * reachable is not static: `useAiAccess` re-reads it when the settings change and when
608
+ * the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
609
+ * credential to work with — otherwise it would open on the same nothing the panel
610
+ * rendered before there were tabs, except now with a label promising otherwise.
611
+ */
612
+ function PanelTabs({ auth, commands, settings, renderDatamesh, selected, onSelect }) {
613
+ const access = useAiAccess(commands, settings);
614
+ const tabs = [
615
+ {
616
+ id: 'notebooks',
617
+ label: 'Notebooks',
618
+ render: () =>
619
+ // An auth with no environment is a provider that has nothing to sign in to (the
620
+ // server has sign-in turned off). Asking the user to sign in would be a dead end.
621
+ (auth === null || auth === void 0 ? void 0 : auth.environment) ? (React.createElement(StoredNotebooks, { auth: auth })) : (React.createElement("div", { className: "oceanum-text-empty" }, "Oceanum.io sign-in is not configured for this host."))
622
+ },
623
+ { id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
624
+ ];
625
+ if (access.source) {
626
+ tabs.push({
627
+ id: 'ai',
628
+ label: 'Oceanum AI',
629
+ render: () => React.createElement(AIChatPanel, { commands: commands, settings: settings })
630
+ });
631
+ }
632
+ return React.createElement(Tabs, { tabs: tabs, selected: selected, onSelect: onSelect });
633
+ }
532
634
  /**
533
635
  * A widget for Datamesh Connections.
534
636
  */
@@ -536,6 +638,10 @@ export class DatameshConnectWidget extends ReactWidget {
536
638
  constructor(props) {
537
639
  super();
538
640
  this.datameshWorkspaceSpec = null;
641
+ this.tabChanged = new Signal(this);
642
+ // Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
643
+ // work, so it opens there rather than on Datamesh.
644
+ this._selectedTab = 'notebooks';
539
645
  this.props = props;
540
646
  this.renderSignal = new Signal(this);
541
647
  this.renderDisplay = this.renderDisplay.bind(this);
@@ -561,18 +667,38 @@ export class DatameshConnectWidget extends ReactWidget {
561
667
  React.createElement("a", { onClick: this.props.openDatameshUI }, "Open"),
562
668
  " the Oceanum Datamesh UI to add datasources."))));
563
669
  }
564
- render() {
565
- return (React.createElement("div", { className: "datamesh-connect" },
566
- React.createElement("header", { className: "oceanum-sidebar-header" },
567
- React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
568
- React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
670
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
671
+ renderDatamesh() {
672
+ return (React.createElement(React.Fragment, null,
569
673
  React.createElement("div", { className: "datamesh-workspace-header" },
570
674
  React.createElement("span", null, "Datamesh Workspace"),
571
675
  React.createElement("div", { className: "open-datamesh-ui", onClick: this.props.openDatameshUI, title: "Open Datamesh UI" }, React.createElement(addIcon.react, { height: "20px", verticalAlign: "middle" }))),
572
676
  React.createElement("div", { className: "datamesh-connect-workspace" },
573
677
  React.createElement(UseSignal, { signal: this.renderSignal, initialArgs: null }, (_, datameshWorkspace) => this.renderDisplay(datameshWorkspace)),
574
- React.createElement(TokenConfigMessage, { commands: this.props.commands })),
575
- React.createElement("div", { className: "datamesh-connect-divider" }),
576
- React.createElement(AIChatPanel, { commands: this.props.commands })));
678
+ React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props }))));
679
+ }
680
+ render() {
681
+ return (React.createElement("div", { className: "datamesh-connect" },
682
+ React.createElement("header", { className: "oceanum-sidebar-header" },
683
+ React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
684
+ React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
685
+ React.createElement(UseSignal, { signal: this.tabChanged, initialArgs: this.selectedTab }, () => {
686
+ var _a;
687
+ return (React.createElement(PanelTabs, { auth: (_a = this.props.auth) !== null && _a !== void 0 ? _a : null, commands: this.props.commands, settings: this.props, renderDatamesh: () => this.renderDatamesh(), selected: this.selectedTab, onSelect: id => this.selectTab(id) }));
688
+ })));
689
+ }
690
+ /**
691
+ * The visible tab. The widget only holds it and announces changes through
692
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
693
+ * restores a widget's place in the shell rather than fields on it.
694
+ */
695
+ get selectedTab() {
696
+ return this._selectedTab;
697
+ }
698
+ selectTab(id) {
699
+ if (id !== this._selectedTab) {
700
+ this._selectedTab = id;
701
+ this.tabChanged.emit(id);
702
+ }
577
703
  }
578
704
  }
@@ -0,0 +1,16 @@
1
+ import * as React from 'react';
2
+ import { IOceanumAuth } from './auth/tokens';
3
+ import { ISpecSummary } from './specStore';
4
+ export interface IStoredNotebooksProps {
5
+ auth: IOceanumAuth;
6
+ /**
7
+ * Open a stored notebook, when this host can. Undefined where nothing has registered
8
+ * a way to open one — today that is native JupyterLab, which has no spec store drive
9
+ * until OCE-182. The list then reads rather than offering an action that does nothing.
10
+ */
11
+ onOpen?: (item: ISpecSummary) => void;
12
+ }
13
+ /**
14
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
15
+ */
16
+ export declare function StoredNotebooks({ auth, onOpen }: IStoredNotebooksProps): React.ReactElement;
@@ -0,0 +1,84 @@
1
+ import * as React from 'react';
2
+ import { listNotebooks, partitionNotebooks } from './specStore';
3
+ function Section({ title, items, empty, onOpen }) {
4
+ return (React.createElement("div", { className: "oceanum-notebooks-section" },
5
+ React.createElement("div", { className: "oceanum-notebooks-heading" },
6
+ title,
7
+ React.createElement("span", { className: "oceanum-notebooks-count" }, items.length)),
8
+ items.length === 0 ? (React.createElement("div", { className: "oceanum-text-empty" }, empty)) : (React.createElement("ul", { className: "oceanum-notebooks-list" }, items.map(item => {
9
+ var _a, _b;
10
+ return (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-item", onClick: () => onOpen(item), title: (_a = item.description) !== null && _a !== void 0 ? _a : undefined },
11
+ React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))) : (React.createElement("div", { className: "oceanum-notebooks-item oceanum-notebooks-item-static", title: (_b = item.description) !== null && _b !== void 0 ? _b : undefined },
12
+ React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled')))));
13
+ })))));
14
+ }
15
+ /**
16
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
17
+ */
18
+ export function StoredNotebooks({ auth, onOpen }) {
19
+ const [load, setLoad] = React.useState({ state: 'loading' });
20
+ const [user, setUser] = React.useState(auth.user);
21
+ // Reload whenever the signed-in user changes, so signing in or out does not leave a
22
+ // stale list from the previous session on screen.
23
+ React.useEffect(() => {
24
+ const onUser = () => setUser(auth.user);
25
+ auth.userChanged.connect(onUser);
26
+ return () => {
27
+ auth.userChanged.disconnect(onUser);
28
+ };
29
+ }, [auth]);
30
+ React.useEffect(() => {
31
+ var _a, _b;
32
+ let cancelled = false;
33
+ const specs = (_b = (_a = auth.urls) === null || _a === void 0 ? void 0 : _a.specs) !== null && _b !== void 0 ? _b : null;
34
+ if (!user || !specs) {
35
+ setLoad({ state: 'ready', items: [] });
36
+ return;
37
+ }
38
+ setLoad({ state: 'loading' });
39
+ void (async () => {
40
+ try {
41
+ const token = await auth.getAccessToken();
42
+ if (cancelled) {
43
+ return;
44
+ }
45
+ if (!token) {
46
+ setLoad({ state: 'ready', items: [] });
47
+ return;
48
+ }
49
+ const items = await listNotebooks(specs, token);
50
+ if (!cancelled) {
51
+ setLoad({ state: 'ready', items });
52
+ }
53
+ }
54
+ catch (error) {
55
+ if (!cancelled) {
56
+ setLoad({
57
+ state: 'error',
58
+ message: error instanceof Error
59
+ ? error.message
60
+ : 'Could not reach Oceanum.io.'
61
+ });
62
+ }
63
+ }
64
+ })();
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [auth, user]);
69
+ if (!user) {
70
+ return (React.createElement("div", { className: "oceanum-text-empty" }, "Sign in to Oceanum.io to see your notebooks."));
71
+ }
72
+ if (load.state === 'loading') {
73
+ return React.createElement("div", { className: "oceanum-text-empty" }, "Loading\u2026");
74
+ }
75
+ if (load.state === 'error') {
76
+ // Never render the sections alongside an error: their empty messages would claim
77
+ // the account has no notebooks when the load simply failed.
78
+ return React.createElement("div", { className: "oceanum-text-error" }, load.message);
79
+ }
80
+ const { mine, shared } = partitionNotebooks(load.items, user.email);
81
+ return (React.createElement("div", { className: "oceanum-notebooks" },
82
+ React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
83
+ React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen })));
84
+ }