@oceanum/oceanumlab 4.7.0 → 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 (59) 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 +18 -2
  5. package/lib/DatameshWidget.js +70 -11
  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 +1 -1
  11. package/lib/aiBackend.js +1 -1
  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 +13 -1
  23. package/lib/chatRouter.js +9 -2
  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 +6 -6
  28. package/lib/index.d.ts +8 -1
  29. package/lib/index.js +56 -17
  30. package/lib/specStore.d.ts +37 -0
  31. package/lib/specStore.js +57 -0
  32. package/package.json +9 -1
  33. package/schema/datamesh-connect.json +3 -12
  34. package/src/DatameshUI.tsx +1 -10
  35. package/src/DatameshWidget.tsx +124 -19
  36. package/src/StoredNotebooks.tsx +166 -0
  37. package/src/Tabs.tsx +102 -0
  38. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  39. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  40. package/src/__tests__/Tabs.spec.tsx +168 -0
  41. package/src/__tests__/aiBackend.spec.ts +5 -6
  42. package/src/__tests__/chatRouter.spec.ts +9 -15
  43. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  44. package/src/__tests__/pluginArity.spec.ts +24 -0
  45. package/src/__tests__/serverAuth.spec.ts +277 -0
  46. package/src/__tests__/settingsSchema.spec.ts +73 -0
  47. package/src/__tests__/specStore.spec.ts +126 -0
  48. package/src/aiBackend.ts +1 -1
  49. package/src/auth/accountWidget.ts +113 -0
  50. package/src/auth/claims.ts +30 -0
  51. package/src/auth/plugin.ts +155 -0
  52. package/src/auth/serverAuth.ts +341 -0
  53. package/src/auth/tokens.ts +104 -0
  54. package/src/chatRouter.ts +8 -4
  55. package/src/constants.ts +1 -1
  56. package/src/datameshUiUrl.ts +6 -6
  57. package/src/index.ts +85 -36
  58. package/src/specStore.ts +82 -0
  59. 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;
@@ -5,6 +5,7 @@ import { CommandRegistry } from '@lumino/commands';
5
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,18 +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;
47
48
  /** The `datameshToken` setting. */
48
49
  datameshToken: () => string;
49
- /** The `aiBackendUrl` setting. */
50
+ /** The deployment's Oceanum AI address. */
50
51
  aiBackendUrl: () => string;
51
52
  /** Emitted when the settings change. */
52
53
  settingsChanged: ISignal<unknown, void>;
53
54
  commands: CommandRegistry;
54
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;
55
60
  }
56
61
  /**
57
62
  * A widget for Datamesh Connections.
@@ -65,5 +70,16 @@ export declare class DatameshConnectWidget extends ReactWidget {
65
70
  constructor(props: IDatameshWidgetProps);
66
71
  receiveIFrameMessage(event: MessageEvent): void;
67
72
  renderDisplay(datameshWorkspace: IWorkspaceSpec): React.ReactElement;
73
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
74
+ renderDatamesh(): React.ReactElement;
68
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;
69
85
  }
@@ -9,6 +9,8 @@ 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 { StoredNotebooks } from './StoredNotebooks';
13
+ import { Tabs } from './Tabs';
12
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';
@@ -342,9 +344,11 @@ function TokenConfigMessage({ commands, settings }) {
342
344
  "."));
343
345
  }
344
346
  return (React.createElement("div", { className: "oceanum-token-config" },
345
- "Set your ",
346
- React.createElement("a", { onClick: openSettings }, "Datamesh token"),
347
- " to enable Oceanum.io services",
347
+ "Set your",
348
+ ' ',
349
+ React.createElement("a", { onClick: () => commands.execute('settingeditor:open', { query: 'Oceanum' }) }, "Datamesh token"),
350
+ ' ',
351
+ "to enable Oceanum.io services",
348
352
  ' ',
349
353
  React.createElement("a", { href: "https://home.oceanum.io/account", target: "_blank", rel: "noopener noreferrer" }, "Get token here")));
350
354
  }
@@ -596,6 +600,37 @@ function AIChatPanel({ commands, settings }) {
596
600
  ? commands.execute('oceanum-ai:stop')
597
601
  : handleSubmit()), disabled: !loading && !input.trim(), title: loading ? 'Stop the current response' : undefined }, loading ? 'Stop' : 'Send'))));
598
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
+ }
599
634
  /**
600
635
  * A widget for Datamesh Connections.
601
636
  */
@@ -603,6 +638,10 @@ export class DatameshConnectWidget extends ReactWidget {
603
638
  constructor(props) {
604
639
  super();
605
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';
606
645
  this.props = props;
607
646
  this.renderSignal = new Signal(this);
608
647
  this.renderDisplay = this.renderDisplay.bind(this);
@@ -628,18 +667,38 @@ export class DatameshConnectWidget extends ReactWidget {
628
667
  React.createElement("a", { onClick: this.props.openDatameshUI }, "Open"),
629
668
  " the Oceanum Datamesh UI to add datasources."))));
630
669
  }
631
- render() {
632
- return (React.createElement("div", { className: "datamesh-connect" },
633
- React.createElement("header", { className: "oceanum-sidebar-header" },
634
- React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
635
- 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,
636
673
  React.createElement("div", { className: "datamesh-workspace-header" },
637
674
  React.createElement("span", null, "Datamesh Workspace"),
638
675
  React.createElement("div", { className: "open-datamesh-ui", onClick: this.props.openDatameshUI, title: "Open Datamesh UI" }, React.createElement(addIcon.react, { height: "20px", verticalAlign: "middle" }))),
639
676
  React.createElement("div", { className: "datamesh-connect-workspace" },
640
677
  React.createElement(UseSignal, { signal: this.renderSignal, initialArgs: null }, (_, datameshWorkspace) => this.renderDisplay(datameshWorkspace)),
641
- React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props })),
642
- React.createElement("div", { className: "datamesh-connect-divider" }),
643
- React.createElement(AIChatPanel, { commands: this.props.commands, settings: this.props })));
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
+ }
644
703
  }
645
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
+ }
package/lib/Tabs.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import * as React from 'react';
2
+ export interface ITab {
3
+ /** Stable key, also used as the restored selection value. */
4
+ id: string;
5
+ label: string;
6
+ render: () => React.ReactElement;
7
+ }
8
+ export interface ITabsProps {
9
+ tabs: readonly ITab[];
10
+ /** Selected tab id. Falls back to the first tab when it names no tab. */
11
+ selected: string;
12
+ onSelect: (id: string) => void;
13
+ }
14
+ /**
15
+ * A tab strip over a single visible pane.
16
+ *
17
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
18
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
19
+ * to reach the ends.
20
+ *
21
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
22
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
23
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
24
+ * the user is in the middle of.
25
+ */
26
+ export declare function Tabs({ tabs, selected, onSelect }: ITabsProps): React.ReactElement;
package/lib/Tabs.js ADDED
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * A tab strip over a single visible pane.
4
+ *
5
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
6
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
7
+ * to reach the ends.
8
+ *
9
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
10
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
11
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
12
+ * the user is in the middle of.
13
+ */
14
+ export function Tabs({ tabs, selected, onSelect }) {
15
+ const index = Math.max(0, tabs.findIndex(tab => tab.id === selected));
16
+ const active = tabs[index];
17
+ const onKeyDown = (event) => {
18
+ const last = tabs.length - 1;
19
+ let next = null;
20
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
21
+ next = index === last ? 0 : index + 1;
22
+ }
23
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
24
+ next = index === 0 ? last : index - 1;
25
+ }
26
+ else if (event.key === 'Home') {
27
+ next = 0;
28
+ }
29
+ else if (event.key === 'End') {
30
+ next = last;
31
+ }
32
+ if (next === null) {
33
+ return;
34
+ }
35
+ // Otherwise the arrow keys also scroll the panel.
36
+ event.preventDefault();
37
+ onSelect(tabs[next].id);
38
+ };
39
+ return (React.createElement("div", { className: "oceanum-tabs" },
40
+ React.createElement("div", { className: "oceanum-tabs-strip", role: "tablist", "aria-label": "Oceanum.io" }, tabs.map((tab, i) => (React.createElement("button", { key: tab.id, id: `oceanum-tab-${tab.id}`, className: i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab', role: "tab", type: "button", "aria-selected": i === index, "aria-controls": `oceanum-tabpanel-${tab.id}`,
41
+ // Only the selected tab is in the tab order, so Tab moves past the strip
42
+ // rather than through every tab in it.
43
+ tabIndex: i === index ? 0 : -1, onClick: () => onSelect(tab.id), onKeyDown: onKeyDown }, tab.label)))),
44
+ tabs.map(tab => (React.createElement("div", { key: tab.id, className: "oceanum-tabpanel", id: `oceanum-tabpanel-${tab.id}`, role: "tabpanel", "aria-labelledby": `oceanum-tab-${tab.id}`, hidden: tab.id !== active.id }, tab.render())))));
45
+ }
@@ -35,7 +35,7 @@ export interface IAiCredential {
35
35
  headers: Record<string, string>;
36
36
  }
37
37
  /**
38
- * The backend address for an `aiBackendUrl` setting value, without a trailing
38
+ * The backend address for a configured value, without a trailing
39
39
  * slash so a path can be appended. An invalid value falls back to the default.
40
40
  */
41
41
  export declare function aiBackendUrl(value: string | null | undefined): string;
package/lib/aiBackend.js CHANGED
@@ -14,7 +14,7 @@ export const SIGN_IN_COMMAND = 'oceanum-auth:sign-in';
14
14
  /** The host's command that signs out. Visible only while signed in. */
15
15
  export const SIGN_OUT_COMMAND = 'oceanum-auth:sign-out';
16
16
  /**
17
- * The backend address for an `aiBackendUrl` setting value, without a trailing
17
+ * The backend address for a configured value, without a trailing
18
18
  * slash so a path can be appended. An invalid value falls back to the default.
19
19
  */
20
20
  export function aiBackendUrl(value) {
@@ -0,0 +1,35 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { IPendingSignIn, ServerAuth } from './serverAuth';
4
+ export declare const CommandIDs: {
5
+ readonly signIn: "oceanum-auth:sign-in";
6
+ readonly signOut: "oceanum-auth:sign-out";
7
+ readonly account: "oceanum-auth:account";
8
+ /**
9
+ * For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
10
+ * the current access token, or `null` when signed out. Never rejects.
11
+ */
12
+ readonly accessToken: "oceanum-auth:access-token";
13
+ };
14
+ /**
15
+ * The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
16
+ * menu when signed in.
17
+ */
18
+ export declare class AccountWidget extends Widget {
19
+ constructor(auth: ServerAuth, commands: CommandRegistry);
20
+ dispose(): void;
21
+ private _onClick;
22
+ private _render;
23
+ private _auth;
24
+ private _commands;
25
+ private _menu;
26
+ }
27
+ /**
28
+ * What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
29
+ * it. A real link rather than `window.open`, which a popup blocker stops once there has
30
+ * been an `await` between the click and the call -- and there always has, because the code
31
+ * comes from the server.
32
+ */
33
+ export declare class SignInBody extends Widget {
34
+ constructor(pending: IPendingSignIn);
35
+ }