@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
@@ -14,14 +14,26 @@ import { LabIcon, addIcon } from '@jupyterlab/ui-components';
14
14
  import { CommandRegistry } from '@lumino/commands';
15
15
  import { MimeData, ReadonlyJSONArray } from '@lumino/coreutils';
16
16
  import { Drag } from '@lumino/dragdrop';
17
- import { Signal } from '@lumino/signaling';
17
+ import { ISignal, Signal } from '@lumino/signaling';
18
18
  import { Widget } from '@lumino/widgets';
19
19
 
20
20
  import React from 'react';
21
21
  import { marked } from 'marked';
22
22
 
23
23
  import { DatasourceItem } from './DatasourceItem';
24
- import { OCEANUM_AI_BACKEND_URL } from './constants';
24
+ import { IOceanumAuth } from './auth/tokens';
25
+ import { StoredNotebooks } from './StoredNotebooks';
26
+ import { ITab, Tabs } from './Tabs';
27
+ import {
28
+ SIGN_IN_COMMAND,
29
+ aiBackendUrl,
30
+ aiCredentialSource,
31
+ canSignIn,
32
+ pastedToken,
33
+ resolveAiCredential,
34
+ signInToken,
35
+ type AiCredentialSource
36
+ } from './aiBackend';
25
37
  import { isDatameshUiMessage } from './datameshUiUrl';
26
38
  import { describeProgress, onProgress, type Progress } from './progress';
27
39
 
@@ -460,27 +472,118 @@ class DatameshWorkspaceDisplay extends React.Component<IDatameshWorkspaceProps>
460
472
  }
461
473
  }
462
474
 
475
+ /** The settings the sidebar reaches Oceanum AI with. */
476
+ interface IAiSettings {
477
+ /**
478
+ * The `datameshToken` setting: the same value a chat request is signed with.
479
+ * Not `window.datameshToken`, which keeps a token after it is cleared.
480
+ */
481
+ datameshToken: () => string;
482
+ /** The deployment's Oceanum AI address. */
483
+ aiBackendUrl: () => string;
484
+ /** Emitted when the settings change. */
485
+ settingsChanged: ISignal<unknown, void>;
486
+ }
487
+
463
488
  /**
464
- * Shows a message prompting user to configure their Datamesh token.
465
- * Only renders when no token is set.
489
+ * How the sidebar can reach Oceanum AI right now. Holds no sign-in token:
490
+ * finding one out means running the host's command.
491
+ */
492
+ interface IAiAccess {
493
+ /** Which credential a request would carry; null when there is none. */
494
+ source: AiCredentialSource | null;
495
+ /** The pasted Datamesh token, trimmed; '' when there is none. */
496
+ pasted: string;
497
+ /** Whether the host can sign the user in to Oceanum.io on this site. */
498
+ canSignIn: boolean;
499
+ /** The backend address: the deployment's Oceanum AI. */
500
+ backend: string;
501
+ }
502
+
503
+ function readAiAccess(
504
+ commands: CommandRegistry,
505
+ settings: IAiSettings
506
+ ): IAiAccess {
507
+ const datameshToken = settings.datameshToken();
508
+ return {
509
+ source: aiCredentialSource(datameshToken, commands),
510
+ pasted: pastedToken(datameshToken),
511
+ canSignIn: canSignIn(commands),
512
+ backend: aiBackendUrl(settings.aiBackendUrl())
513
+ };
514
+ }
515
+
516
+ /**
517
+ * How the sidebar can reach Oceanum AI, read again whenever the settings or the
518
+ * commands change: the host calls `notifyCommandChanged` when the user signs
519
+ * in or out.
520
+ *
521
+ * Only the registry's queries are used, never `execute`, and nothing runs on a
522
+ * timer: every executed command closes JupyterLab's command palette.
523
+ */
524
+ function useAiAccess(
525
+ commands: CommandRegistry,
526
+ settings: IAiSettings
527
+ ): IAiAccess {
528
+ const [access, setAccess] = React.useState(() =>
529
+ readAiAccess(commands, settings)
530
+ );
531
+
532
+ React.useEffect(() => {
533
+ const update = () => {
534
+ const next = readAiAccess(commands, settings);
535
+ // Same content keeps the same object, so nothing downstream re-runs.
536
+ setAccess(prev =>
537
+ JSON.stringify(prev) === JSON.stringify(next) ? prev : next
538
+ );
539
+ };
540
+ // Anything that changed between the first render and now.
541
+ update();
542
+ commands.commandChanged.connect(update);
543
+ settings.settingsChanged.connect(update);
544
+ return () => {
545
+ commands.commandChanged.disconnect(update);
546
+ settings.settingsChanged.disconnect(update);
547
+ };
548
+ }, [commands, settings]);
549
+
550
+ return access;
551
+ }
552
+
553
+ /**
554
+ * Shows a message prompting the user to sign in to Oceanum.io, or to configure
555
+ * their Datamesh token where the site has no sign-in. Only renders when there
556
+ * is no credential.
466
557
  */
467
558
  function TokenConfigMessage({
468
- commands
559
+ commands,
560
+ settings
469
561
  }: {
470
562
  commands: CommandRegistry;
563
+ settings: IAiSettings;
471
564
  }): React.ReactElement | null {
472
- const [hasToken, setHasToken] = React.useState(!!window.datameshToken);
473
-
474
- React.useEffect(() => {
475
- const checkToken = () => setHasToken(!!window.datameshToken);
476
- const interval = setInterval(checkToken, 1000);
477
- return () => clearInterval(interval);
478
- }, []);
565
+ const access = useAiAccess(commands, settings);
479
566
 
480
- if (hasToken) {
567
+ if (access.source) {
481
568
  return null;
482
569
  }
483
570
 
571
+ const openSettings = () =>
572
+ commands.execute('settingeditor:open', { query: 'Oceanum' });
573
+
574
+ if (access.canSignIn) {
575
+ const signIn = () =>
576
+ commands.execute(SIGN_IN_COMMAND).catch(() => {
577
+ console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
578
+ });
579
+ return (
580
+ <div className="oceanum-token-config">
581
+ <a onClick={() => void signIn()}>Sign in to Oceanum.io</a> to use
582
+ Oceanum AI, or set a <a onClick={openSettings}>Datamesh token</a>.
583
+ </div>
584
+ );
585
+ }
586
+
484
587
  return (
485
588
  <div className="oceanum-token-config">
486
589
  Set your{' '}
@@ -491,7 +594,7 @@ function TokenConfigMessage({
491
594
  >
492
595
  Datamesh token
493
596
  </a>{' '}
494
- to enable Oceanum.oi services{' '}
597
+ to enable Oceanum.io services{' '}
495
598
  <a
496
599
  href="https://home.oceanum.io/account"
497
600
  target="_blank"
@@ -510,10 +613,12 @@ interface IChatMessage {
510
613
 
511
614
  interface IAIChatPanelProps {
512
615
  commands: CommandRegistry;
616
+ settings: IAiSettings;
513
617
  }
514
618
 
515
619
  function AIChatPanel({
516
- commands
620
+ commands,
621
+ settings
517
622
  }: IAIChatPanelProps): React.ReactElement | null {
518
623
  const [messages, setMessages] = React.useState<IChatMessage[]>([]);
519
624
  const [input, setInput] = React.useState('');
@@ -532,55 +637,81 @@ function AIChatPanel({
532
637
  const [historyIndex, setHistoryIndex] = React.useState(-1);
533
638
  const [tempInput, setTempInput] = React.useState('');
534
639
 
535
- // Track the actual token value to detect changes
536
- const [token, setToken] = React.useState(window.datameshToken || '');
537
- const hasToken = !!token;
640
+ // Which credential and address the chat has, as the settings and the host
641
+ // report them.
642
+ const access = useAiAccess(commands, settings);
643
+ // What the capabilities depend on: which credential, the pasted token and
644
+ // the address. Not the sign-in token itself: it refreshes, and finding it out
645
+ // runs the host's command.
646
+ const capabilitiesKey = access.source
647
+ ? JSON.stringify([access.source, access.pasted, access.backend])
648
+ : '';
538
649
 
539
650
  // Check if code capability is enabled (null = loading/unknown)
540
651
  const [codeEnabled, setCodeEnabled] = React.useState<boolean | null>(null);
541
652
 
542
- // Re-check token periodically (settings may change)
543
- React.useEffect(() => {
544
- const checkToken = () => {
545
- const currentToken = window.datameshToken || '';
546
- if (currentToken !== token) {
547
- setToken(currentToken);
548
- }
549
- };
550
- const interval = setInterval(checkToken, 1000);
551
- return () => clearInterval(interval);
552
- }, [token]);
653
+ // The sidebar's one pending access-token request, shared rather than
654
+ // repeated if the capabilities are asked for again before it answers.
655
+ const tokenRequest = React.useRef<Promise<unknown> | null>(null);
553
656
 
554
- // Fetch capabilities when token changes
657
+ // Fetch capabilities when the credential or the address changes
555
658
  React.useEffect(() => {
556
- if (!token) {
659
+ if (!access.source) {
557
660
  setCodeEnabled(null);
558
661
  return;
559
662
  }
560
663
 
664
+ // An answer for a credential that has since changed is dropped.
665
+ let current = true;
666
+ const signInTokenOnce = (): Promise<unknown> => {
667
+ if (!tokenRequest.current) {
668
+ tokenRequest.current = signInToken(commands)().finally(() => {
669
+ tokenRequest.current = null;
670
+ });
671
+ }
672
+ return tokenRequest.current;
673
+ };
561
674
  const fetchCapabilities = async () => {
675
+ const credential = await resolveAiCredential(
676
+ access.pasted,
677
+ signInTokenOnce
678
+ );
679
+ if (!current) {
680
+ return;
681
+ }
682
+ if (!credential) {
683
+ // The host says signed in but has no token to give: leave it to a
684
+ // request to report, rather than hide the chat.
685
+ setCodeEnabled(null);
686
+ return;
687
+ }
562
688
  try {
563
- const response = await fetch(
564
- `${OCEANUM_AI_BACKEND_URL}/api/capabilities`,
565
- {
566
- headers: {
567
- 'X-Datamesh-Token': token
568
- }
569
- }
570
- );
689
+ const response = await fetch(`${access.backend}/api/capabilities`, {
690
+ headers: credential.headers
691
+ });
692
+ if (!current) {
693
+ return;
694
+ }
571
695
  if (response.ok) {
572
696
  const data = await response.json();
573
- setCodeEnabled(data.code === true);
697
+ if (current) {
698
+ setCodeEnabled(data.code === true);
699
+ }
574
700
  } else {
575
701
  setCodeEnabled(false);
576
702
  }
577
703
  } catch {
578
- setCodeEnabled(false);
704
+ if (current) {
705
+ setCodeEnabled(false);
706
+ }
579
707
  }
580
708
  };
581
709
 
582
710
  void fetchCapabilities();
583
- }, [token]);
711
+ return () => {
712
+ current = false;
713
+ };
714
+ }, [capabilitiesKey]);
584
715
 
585
716
  // Auto-scroll to bottom when messages change
586
717
  React.useEffect(() => {
@@ -722,8 +853,8 @@ function AIChatPanel({
722
853
  }
723
854
  };
724
855
 
725
- // Hide AI panel if no token or code capability is not enabled
726
- if (!hasToken || codeEnabled === false) {
856
+ // Hide AI panel if no credential or code capability is not enabled
857
+ if (!access.source || codeEnabled === false) {
727
858
  return null;
728
859
  }
729
860
 
@@ -814,12 +945,74 @@ export interface IDatameshWidgetProps {
814
945
  name: string;
815
946
  icon: LabIcon;
816
947
  openDatameshUI: any;
817
- /** The `datameshUiUrl` setting. */
948
+ /** The deployment's Datamesh UI address. */
818
949
  datameshUiUrl: () => string;
819
950
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
820
951
  datameshUiFrame: () => Window | null;
952
+ /** The `datameshToken` setting. */
953
+ datameshToken: () => string;
954
+ /** The deployment's Oceanum AI address. */
955
+ aiBackendUrl: () => string;
956
+ /** Emitted when the settings change. */
957
+ settingsChanged: ISignal<unknown, void>;
821
958
  commands: CommandRegistry;
822
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} />;
823
1016
  }
824
1017
 
825
1018
  /**
@@ -887,18 +1080,10 @@ export class DatameshConnectWidget extends ReactWidget {
887
1080
  );
888
1081
  }
889
1082
 
890
- render(): React.ReactElement {
1083
+ /** The Datamesh workspace tab: what this panel showed above the divider. */
1084
+ renderDatamesh(): React.ReactElement {
891
1085
  return (
892
- <div className="datamesh-connect">
893
- <header className="oceanum-sidebar-header">
894
- <this.props.icon.react
895
- tag="span"
896
- width="auto"
897
- height="20px"
898
- verticalAlign="middle"
899
- />
900
- <span className="oceanum-sidebar-title">Oceanum.io</span>
901
- </header>
1086
+ <>
902
1087
  <div className="datamesh-workspace-header">
903
1088
  <span>Datamesh Workspace</span>
904
1089
  <div
@@ -915,11 +1100,61 @@ export class DatameshConnectWidget extends ReactWidget {
915
1100
  this.renderDisplay(datameshWorkspace)
916
1101
  }
917
1102
  </UseSignal>
918
- <TokenConfigMessage commands={this.props.commands} />
1103
+ <TokenConfigMessage
1104
+ commands={this.props.commands}
1105
+ settings={this.props}
1106
+ />
919
1107
  </div>
920
- <div className="datamesh-connect-divider" />
921
- <AIChatPanel commands={this.props.commands} />
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>
922
1136
  </div>
923
1137
  );
924
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';
925
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
+ }