@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
@@ -21,6 +21,9 @@ import React from 'react';
21
21
  import { marked } from 'marked';
22
22
 
23
23
  import { DatasourceItem } from './DatasourceItem';
24
+ import { IOceanumAuth } from './auth/tokens';
25
+ import { StoredNotebooks } from './StoredNotebooks';
26
+ import { ITab, Tabs } from './Tabs';
24
27
  import {
25
28
  SIGN_IN_COMMAND,
26
29
  aiBackendUrl,
@@ -476,7 +479,7 @@ interface IAiSettings {
476
479
  * Not `window.datameshToken`, which keeps a token after it is cleared.
477
480
  */
478
481
  datameshToken: () => string;
479
- /** The `aiBackendUrl` setting. */
482
+ /** The deployment's Oceanum AI address. */
480
483
  aiBackendUrl: () => string;
481
484
  /** Emitted when the settings change. */
482
485
  settingsChanged: ISignal<unknown, void>;
@@ -493,7 +496,7 @@ interface IAiAccess {
493
496
  pasted: string;
494
497
  /** Whether the host can sign the user in to Oceanum.io on this site. */
495
498
  canSignIn: boolean;
496
- /** The backend address, from the `aiBackendUrl` setting. */
499
+ /** The backend address: the deployment's Oceanum AI. */
497
500
  backend: string;
498
501
  }
499
502
 
@@ -583,8 +586,15 @@ function TokenConfigMessage({
583
586
 
584
587
  return (
585
588
  <div className="oceanum-token-config">
586
- Set your <a onClick={openSettings}>Datamesh token</a> to enable Oceanum.io
587
- services{' '}
589
+ Set your{' '}
590
+ <a
591
+ onClick={() =>
592
+ commands.execute('settingeditor:open', { query: 'Oceanum' })
593
+ }
594
+ >
595
+ Datamesh token
596
+ </a>{' '}
597
+ to enable Oceanum.io services{' '}
588
598
  <a
589
599
  href="https://home.oceanum.io/account"
590
600
  target="_blank"
@@ -935,18 +945,74 @@ export interface IDatameshWidgetProps {
935
945
  name: string;
936
946
  icon: LabIcon;
937
947
  openDatameshUI: any;
938
- /** The `datameshUiUrl` setting. */
948
+ /** The deployment's Datamesh UI address. */
939
949
  datameshUiUrl: () => string;
940
950
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
941
951
  datameshUiFrame: () => Window | null;
942
952
  /** The `datameshToken` setting. */
943
953
  datameshToken: () => string;
944
- /** The `aiBackendUrl` setting. */
954
+ /** The deployment's Oceanum AI address. */
945
955
  aiBackendUrl: () => string;
946
956
  /** Emitted when the settings change. */
947
957
  settingsChanged: ISignal<unknown, void>;
948
958
  commands: CommandRegistry;
949
959
  getCurrentWidget: () => Widget;
960
+ /** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
961
+ auth?: IOceanumAuth | null;
962
+ /** Open a stored notebook by spec store id. */
963
+ openStoredNotebook?: (id: string) => void;
964
+ }
965
+
966
+ /**
967
+ * The panel's tabs.
968
+ *
969
+ * A component rather than a list built in `render`, because whether Oceanum AI is
970
+ * reachable is not static: `useAiAccess` re-reads it when the settings change and when
971
+ * the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
972
+ * credential to work with — otherwise it would open on the same nothing the panel
973
+ * rendered before there were tabs, except now with a label promising otherwise.
974
+ */
975
+ function PanelTabs({
976
+ auth,
977
+ commands,
978
+ settings,
979
+ renderDatamesh,
980
+ selected,
981
+ onSelect
982
+ }: {
983
+ auth: IOceanumAuth | null;
984
+ commands: CommandRegistry;
985
+ settings: IAiSettings;
986
+ renderDatamesh: () => React.ReactElement;
987
+ selected: string;
988
+ onSelect: (id: string) => void;
989
+ }): React.ReactElement {
990
+ const access = useAiAccess(commands, settings);
991
+ const tabs: ITab[] = [
992
+ {
993
+ id: 'notebooks',
994
+ label: 'Notebooks',
995
+ render: () =>
996
+ // An auth with no environment is a provider that has nothing to sign in to (the
997
+ // server has sign-in turned off). Asking the user to sign in would be a dead end.
998
+ auth?.environment ? (
999
+ <StoredNotebooks auth={auth} />
1000
+ ) : (
1001
+ <div className="oceanum-text-empty">
1002
+ Oceanum.io sign-in is not configured for this host.
1003
+ </div>
1004
+ )
1005
+ },
1006
+ { id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
1007
+ ];
1008
+ if (access.source) {
1009
+ tabs.push({
1010
+ id: 'ai',
1011
+ label: 'Oceanum AI',
1012
+ render: () => <AIChatPanel commands={commands} settings={settings} />
1013
+ });
1014
+ }
1015
+ return <Tabs tabs={tabs} selected={selected} onSelect={onSelect} />;
950
1016
  }
951
1017
 
952
1018
  /**
@@ -1014,18 +1080,10 @@ export class DatameshConnectWidget extends ReactWidget {
1014
1080
  );
1015
1081
  }
1016
1082
 
1017
- render(): React.ReactElement {
1083
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
1084
+ renderDatamesh(): React.ReactElement {
1018
1085
  return (
1019
- <div className="datamesh-connect">
1020
- <header className="oceanum-sidebar-header">
1021
- <this.props.icon.react
1022
- tag="span"
1023
- width="auto"
1024
- height="20px"
1025
- verticalAlign="middle"
1026
- />
1027
- <span className="oceanum-sidebar-title">Oceanum.io</span>
1028
- </header>
1086
+ <>
1029
1087
  <div className="datamesh-workspace-header">
1030
1088
  <span>Datamesh Workspace</span>
1031
1089
  <div
@@ -1047,9 +1105,56 @@ export class DatameshConnectWidget extends ReactWidget {
1047
1105
  settings={this.props}
1048
1106
  />
1049
1107
  </div>
1050
- <div className="datamesh-connect-divider" />
1051
- <AIChatPanel commands={this.props.commands} settings={this.props} />
1108
+ </>
1109
+ );
1110
+ }
1111
+
1112
+ render(): React.ReactElement {
1113
+ return (
1114
+ <div className="datamesh-connect">
1115
+ <header className="oceanum-sidebar-header">
1116
+ <this.props.icon.react
1117
+ tag="span"
1118
+ width="auto"
1119
+ height="20px"
1120
+ verticalAlign="middle"
1121
+ />
1122
+ <span className="oceanum-sidebar-title">Oceanum.io</span>
1123
+ </header>
1124
+ <UseSignal signal={this.tabChanged} initialArgs={this.selectedTab}>
1125
+ {(): React.ReactElement => (
1126
+ <PanelTabs
1127
+ auth={this.props.auth ?? null}
1128
+ commands={this.props.commands}
1129
+ settings={this.props}
1130
+ renderDatamesh={() => this.renderDatamesh()}
1131
+ selected={this.selectedTab}
1132
+ onSelect={id => this.selectTab(id)}
1133
+ />
1134
+ )}
1135
+ </UseSignal>
1052
1136
  </div>
1053
1137
  );
1054
1138
  }
1139
+
1140
+ /**
1141
+ * The visible tab. The widget only holds it and announces changes through
1142
+ * `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
1143
+ * restores a widget's place in the shell rather than fields on it.
1144
+ */
1145
+ get selectedTab(): string {
1146
+ return this._selectedTab;
1147
+ }
1148
+
1149
+ selectTab(id: string): void {
1150
+ if (id !== this._selectedTab) {
1151
+ this._selectedTab = id;
1152
+ this.tabChanged.emit(id);
1153
+ }
1154
+ }
1155
+
1156
+ readonly tabChanged = new Signal<this, string>(this);
1157
+ // Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
1158
+ // work, so it opens there rather than on Datamesh.
1159
+ private _selectedTab = 'notebooks';
1055
1160
  }
@@ -0,0 +1,166 @@
1
+ import * as React from 'react';
2
+
3
+ import { IOceanumAuth } from './auth/tokens';
4
+ import { ISpecSummary, listNotebooks, partitionNotebooks } from './specStore';
5
+
6
+ export interface IStoredNotebooksProps {
7
+ auth: IOceanumAuth;
8
+ /**
9
+ * Open a stored notebook, when this host can. Undefined where nothing has registered
10
+ * a way to open one — today that is native JupyterLab, which has no spec store drive
11
+ * until OCE-182. The list then reads rather than offering an action that does nothing.
12
+ */
13
+ onOpen?: (item: ISpecSummary) => void;
14
+ }
15
+
16
+ type Load =
17
+ | { state: 'loading' }
18
+ | { state: 'error'; message: string }
19
+ | { state: 'ready'; items: ISpecSummary[] };
20
+
21
+ function Section({
22
+ title,
23
+ items,
24
+ empty,
25
+ onOpen
26
+ }: {
27
+ title: string;
28
+ items: readonly ISpecSummary[];
29
+ empty: string;
30
+ onOpen?: (item: ISpecSummary) => void;
31
+ }): React.ReactElement {
32
+ return (
33
+ <div className="oceanum-notebooks-section">
34
+ <div className="oceanum-notebooks-heading">
35
+ {title}
36
+ <span className="oceanum-notebooks-count">{items.length}</span>
37
+ </div>
38
+ {items.length === 0 ? (
39
+ <div className="oceanum-text-empty">{empty}</div>
40
+ ) : (
41
+ <ul className="oceanum-notebooks-list">
42
+ {items.map(item => (
43
+ <li key={item.id}>
44
+ {/* Names come from other users: React escapes these, never set as HTML. */}
45
+ {onOpen ? (
46
+ <button
47
+ type="button"
48
+ className="oceanum-notebooks-item"
49
+ onClick={() => onOpen(item)}
50
+ title={item.description ?? undefined}
51
+ >
52
+ <span className="oceanum-notebooks-name">
53
+ {item.name || 'Untitled'}
54
+ </span>
55
+ </button>
56
+ ) : (
57
+ <div
58
+ className="oceanum-notebooks-item oceanum-notebooks-item-static"
59
+ title={item.description ?? undefined}
60
+ >
61
+ <span className="oceanum-notebooks-name">
62
+ {item.name || 'Untitled'}
63
+ </span>
64
+ </div>
65
+ )}
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ /**
75
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
76
+ */
77
+ export function StoredNotebooks({
78
+ auth,
79
+ onOpen
80
+ }: IStoredNotebooksProps): React.ReactElement {
81
+ const [load, setLoad] = React.useState<Load>({ state: 'loading' });
82
+ const [user, setUser] = React.useState(auth.user);
83
+
84
+ // Reload whenever the signed-in user changes, so signing in or out does not leave a
85
+ // stale list from the previous session on screen.
86
+ React.useEffect(() => {
87
+ const onUser = (): void => setUser(auth.user);
88
+ auth.userChanged.connect(onUser);
89
+ return () => {
90
+ auth.userChanged.disconnect(onUser);
91
+ };
92
+ }, [auth]);
93
+
94
+ React.useEffect(() => {
95
+ let cancelled = false;
96
+ const specs = auth.urls?.specs ?? null;
97
+ if (!user || !specs) {
98
+ setLoad({ state: 'ready', items: [] });
99
+ return;
100
+ }
101
+ setLoad({ state: 'loading' });
102
+ void (async () => {
103
+ try {
104
+ const token = await auth.getAccessToken();
105
+ if (cancelled) {
106
+ return;
107
+ }
108
+ if (!token) {
109
+ setLoad({ state: 'ready', items: [] });
110
+ return;
111
+ }
112
+ const items = await listNotebooks(specs, token);
113
+ if (!cancelled) {
114
+ setLoad({ state: 'ready', items });
115
+ }
116
+ } catch (error) {
117
+ if (!cancelled) {
118
+ setLoad({
119
+ state: 'error',
120
+ message:
121
+ error instanceof Error
122
+ ? error.message
123
+ : 'Could not reach Oceanum.io.'
124
+ });
125
+ }
126
+ }
127
+ })();
128
+ return () => {
129
+ cancelled = true;
130
+ };
131
+ }, [auth, user]);
132
+
133
+ if (!user) {
134
+ return (
135
+ <div className="oceanum-text-empty">
136
+ Sign in to Oceanum.io to see your notebooks.
137
+ </div>
138
+ );
139
+ }
140
+ if (load.state === 'loading') {
141
+ return <div className="oceanum-text-empty">Loading…</div>;
142
+ }
143
+ if (load.state === 'error') {
144
+ // Never render the sections alongside an error: their empty messages would claim
145
+ // the account has no notebooks when the load simply failed.
146
+ return <div className="oceanum-text-error">{load.message}</div>;
147
+ }
148
+
149
+ const { mine, shared } = partitionNotebooks(load.items, user.email);
150
+ return (
151
+ <div className="oceanum-notebooks">
152
+ <Section
153
+ title="My notebooks"
154
+ items={mine}
155
+ empty="You have not saved any notebooks yet."
156
+ onOpen={onOpen}
157
+ />
158
+ <Section
159
+ title="Shared with me"
160
+ items={shared}
161
+ empty="No notebooks have been shared with you."
162
+ onOpen={onOpen}
163
+ />
164
+ </div>
165
+ );
166
+ }
package/src/Tabs.tsx ADDED
@@ -0,0 +1,102 @@
1
+ import * as React from 'react';
2
+
3
+ export interface ITab {
4
+ /** Stable key, also used as the restored selection value. */
5
+ id: string;
6
+ label: string;
7
+ render: () => React.ReactElement;
8
+ }
9
+
10
+ export interface ITabsProps {
11
+ tabs: readonly ITab[];
12
+ /** Selected tab id. Falls back to the first tab when it names no tab. */
13
+ selected: string;
14
+ onSelect: (id: string) => void;
15
+ }
16
+
17
+ /**
18
+ * A tab strip over a single visible pane.
19
+ *
20
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
21
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
22
+ * to reach the ends.
23
+ *
24
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
25
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
26
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
27
+ * the user is in the middle of.
28
+ */
29
+ export function Tabs({
30
+ tabs,
31
+ selected,
32
+ onSelect
33
+ }: ITabsProps): React.ReactElement {
34
+ const index = Math.max(
35
+ 0,
36
+ tabs.findIndex(tab => tab.id === selected)
37
+ );
38
+ const active = tabs[index];
39
+
40
+ const onKeyDown = (event: React.KeyboardEvent): void => {
41
+ const last = tabs.length - 1;
42
+ let next: number | null = null;
43
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
44
+ next = index === last ? 0 : index + 1;
45
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
46
+ next = index === 0 ? last : index - 1;
47
+ } else if (event.key === 'Home') {
48
+ next = 0;
49
+ } else if (event.key === 'End') {
50
+ next = last;
51
+ }
52
+ if (next === null) {
53
+ return;
54
+ }
55
+ // Otherwise the arrow keys also scroll the panel.
56
+ event.preventDefault();
57
+ onSelect(tabs[next].id);
58
+ };
59
+
60
+ return (
61
+ <div className="oceanum-tabs">
62
+ <div
63
+ className="oceanum-tabs-strip"
64
+ role="tablist"
65
+ aria-label="Oceanum.io"
66
+ >
67
+ {tabs.map((tab, i) => (
68
+ <button
69
+ key={tab.id}
70
+ id={`oceanum-tab-${tab.id}`}
71
+ className={
72
+ i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab'
73
+ }
74
+ role="tab"
75
+ type="button"
76
+ aria-selected={i === index}
77
+ aria-controls={`oceanum-tabpanel-${tab.id}`}
78
+ // Only the selected tab is in the tab order, so Tab moves past the strip
79
+ // rather than through every tab in it.
80
+ tabIndex={i === index ? 0 : -1}
81
+ onClick={() => onSelect(tab.id)}
82
+ onKeyDown={onKeyDown}
83
+ >
84
+ {tab.label}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ {tabs.map(tab => (
89
+ <div
90
+ key={tab.id}
91
+ className="oceanum-tabpanel"
92
+ id={`oceanum-tabpanel-${tab.id}`}
93
+ role="tabpanel"
94
+ aria-labelledby={`oceanum-tab-${tab.id}`}
95
+ hidden={tab.id !== active.id}
96
+ >
97
+ {tab.render()}
98
+ </div>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
@@ -199,12 +199,16 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
199
199
  oceanum.setSignedIn(true);
200
200
  await until(() => expect(chat(node)).not.toBeNull());
201
201
  expect(notice(node)).toBeNull();
202
- expect(requests).toEqual([
203
- {
204
- url: 'https://ai.example.test/api/capabilities',
205
- headers: { Authorization: 'Bearer a-jwt' }
206
- }
207
- ]);
202
+ // Also awaited: the chat renders before the capabilities call resolves, so asserting
203
+ // this straight after the chat appears is a race that a slow run loses.
204
+ await until(() =>
205
+ expect(requests).toEqual([
206
+ {
207
+ url: 'https://ai.example.test/api/capabilities',
208
+ headers: { Authorization: 'Bearer a-jwt' }
209
+ }
210
+ ])
211
+ );
208
212
  // One token request for the capabilities, however many components watch.
209
213
  expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
210
214
 
@@ -237,7 +241,9 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
237
241
  // Signing in is a change, not a poll: it may run the token command once.
238
242
  oceanum.setSignedIn(true);
239
243
  await until(() => expect(chat(node)).not.toBeNull());
240
- expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
244
+ // Awaited: the chat renders before the token command resolves, so asserting straight
245
+ // after it appears is a race that a slow run loses.
246
+ await until(() => expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]));
241
247
 
242
248
  await settle(2000);
243
249
  expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
@@ -248,7 +254,8 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
248
254
  const node = mount(oceanum.commands);
249
255
 
250
256
  await until(() => expect(chat(node)).not.toBeNull());
251
- expect(requests).toHaveLength(1);
257
+ // Awaited: the capabilities request resolves separately from the chat rendering.
258
+ await until(() => expect(requests).toHaveLength(1));
252
259
 
253
260
  // The host refreshed the token and told the registry, as it does.
254
261
  oceanum.setToken('refreshed-jwt');
@@ -264,12 +271,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
264
271
  const node = mount(oceanum.commands, { pasted: 'pasted-token' });
265
272
 
266
273
  await until(() => expect(chat(node)).not.toBeNull());
267
- expect(requests).toEqual([
268
- {
269
- url: 'https://ai.oceanum.io/api/capabilities',
270
- headers: { 'X-Datamesh-Token': 'pasted-token' }
271
- }
272
- ]);
274
+ // Awaited: the capabilities request resolves separately from the chat rendering.
275
+ await until(() =>
276
+ expect(requests).toEqual([
277
+ {
278
+ url: 'https://ai.oceanum.io/api/capabilities',
279
+ headers: { 'X-Datamesh-Token': 'pasted-token' }
280
+ }
281
+ ])
282
+ );
273
283
  expect(oceanum.executed).toEqual([]);
274
284
  });
275
285
 
@@ -283,12 +293,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
283
293
  const node = mount(oceanum.commands, { pasted: '' });
284
294
 
285
295
  await until(() => expect(chat(node)).not.toBeNull());
286
- expect(requests).toEqual([
287
- {
288
- url: 'https://ai.oceanum.io/api/capabilities',
289
- headers: { Authorization: 'Bearer a-jwt' }
290
- }
291
- ]);
296
+ // Awaited: the capabilities request resolves separately from the chat rendering.
297
+ await until(() =>
298
+ expect(requests).toEqual([
299
+ {
300
+ url: 'https://ai.oceanum.io/api/capabilities',
301
+ headers: { Authorization: 'Bearer a-jwt' }
302
+ }
303
+ ])
304
+ );
292
305
  });
293
306
 
294
307
  it('keeps the chat hidden when the capabilities say there is no code', async () => {
@@ -0,0 +1,101 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { CommandRegistry } from '@lumino/commands';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { Widget } from '@lumino/widgets';
5
+ import { LabIcon } from '@jupyterlab/ui-components';
6
+
7
+ import { DatameshConnectWidget } from '../DatameshWidget';
8
+
9
+ const icon = new LabIcon({
10
+ name: 'oceanum:test-icon',
11
+ svgstr: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
12
+ });
13
+
14
+ function panel(): DatameshConnectWidget {
15
+ const commands = new CommandRegistry();
16
+ const app = {
17
+ shell: { currentWidget: null },
18
+ commands
19
+ } as unknown as JupyterFrontEnd;
20
+ return new DatameshConnectWidget({
21
+ app,
22
+ name: 'Datamesh Connect',
23
+ icon,
24
+ openDatameshUI: (): void => undefined,
25
+ datameshUiUrl: () => 'https://ui.datamesh.example.com',
26
+ datameshUiFrame: (): Window | null => null,
27
+ datameshToken: () => '',
28
+ aiBackendUrl: () => 'https://ai.example.com',
29
+ settingsChanged: new Signal<unknown, void>({}),
30
+ commands,
31
+ getCurrentWidget: () => new Widget(),
32
+ auth: null
33
+ } as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
34
+ }
35
+
36
+ async function mount(): Promise<DatameshConnectWidget> {
37
+ const widget = panel();
38
+ Widget.attach(widget, document.body);
39
+ await new Promise(resolve => setTimeout(resolve, 100));
40
+ return widget;
41
+ }
42
+
43
+ /**
44
+ * The panel used to render the Datamesh workspace and the AI chat one above the other.
45
+ * They are now tabs. `DatameshWidget.spec.tsx` covers the AI chat's behaviour but never
46
+ * touches the Datamesh surface, so nothing else here would notice if rearranging the
47
+ * two lost one of them.
48
+ */
49
+ describe('the Oceanum panel', () => {
50
+ it('offers no Oceanum AI tab when the chat has no credential', async () => {
51
+ // AIChatPanel renders nothing without one, so a labelled tab would open on the
52
+ // same emptiness the panel showed before there were tabs, but now advertised.
53
+ const widget = await mount();
54
+ const labels = Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
55
+ node => node.textContent
56
+ );
57
+
58
+ expect(labels).toEqual(['Notebooks', 'Datamesh']);
59
+ expect(widget.node.querySelector('#oceanum-tabpanel-ai')).toBeNull();
60
+
61
+ widget.dispose();
62
+ });
63
+
64
+ it('still renders the Datamesh workspace section', async () => {
65
+ const widget = await mount();
66
+
67
+ expect(
68
+ widget.node.querySelector('.datamesh-workspace-header')
69
+ ).not.toBeNull();
70
+ expect(
71
+ widget.node.querySelector('.datamesh-connect-workspace')
72
+ ).not.toBeNull();
73
+ expect(widget.node.textContent).toContain('Datamesh Workspace');
74
+
75
+ widget.dispose();
76
+ });
77
+
78
+ it('opens on Notebooks, and announces a change so it can be persisted', async () => {
79
+ const widget = await mount();
80
+ const seen: string[] = [];
81
+ widget.tabChanged.connect((_, id) => seen.push(id));
82
+
83
+ expect(widget.selectedTab).toBe('notebooks');
84
+ widget.selectTab('datamesh');
85
+ expect(widget.selectedTab).toBe('datamesh');
86
+ expect(seen).toEqual(['datamesh']);
87
+
88
+ // Selecting the same tab again is not a change.
89
+ widget.selectTab('datamesh');
90
+ expect(seen).toEqual(['datamesh']);
91
+
92
+ widget.dispose();
93
+ });
94
+
95
+ it('explains the Notebooks tab rather than failing when sign-in is unavailable', async () => {
96
+ // auth is null on a host with no Oceanum environment.
97
+ const widget = await mount();
98
+ expect(widget.node.textContent).toContain('sign-in is not configured');
99
+ widget.dispose();
100
+ });
101
+ });