@datalayer/core 1.2.28 → 1.2.29

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 (38) hide show
  1. package/lib/api/mcp/observability.d.ts +1 -1
  2. package/lib/api/mcp/observability.js +2 -0
  3. package/lib/api/mcp/operations.d.ts +28 -10
  4. package/lib/api/mcp/tasks.d.ts +9 -1
  5. package/lib/api/otel/firewall.d.ts +74 -0
  6. package/lib/api/otel/firewall.js +42 -0
  7. package/lib/api/otel/index.d.ts +1 -0
  8. package/lib/api/otel/index.js +1 -0
  9. package/lib/cli-login.js +2 -2
  10. package/lib/components/anonymous/AnonymousKeyExpired.js +15 -3
  11. package/lib/components/principal/PrincipalSwitcherMenu.js +4 -4
  12. package/lib/components/sharing/ShareAccessComponent.js +33 -8
  13. package/lib/components/toast/ToastPanel.js +18 -7
  14. package/lib/components/toast/toastQueue.d.ts +2 -0
  15. package/lib/hooks/index.d.ts +1 -0
  16. package/lib/hooks/index.js +1 -0
  17. package/lib/hooks/useCache.d.ts +4 -2
  18. package/lib/hooks/useCache.js +13 -4
  19. package/lib/hooks/useKeptScroll.d.ts +6 -0
  20. package/lib/hooks/useKeptScroll.js +54 -0
  21. package/lib/hooks/useMcp.d.ts +2 -2
  22. package/lib/hooks/useMcp.js +5 -2
  23. package/lib/hooks/useNavigate.d.ts +16 -2
  24. package/lib/hooks/useNavigate.js +65 -5
  25. package/lib/models/Usage.d.ts +13 -0
  26. package/lib/models/Usage.js +5 -0
  27. package/lib/otel/client/OtelClient.d.ts +56 -0
  28. package/lib/otel/client/OtelClient.js +25 -0
  29. package/lib/otel/client/index.d.ts +1 -1
  30. package/lib/otel/index.d.ts +1 -1
  31. package/lib/otel/views/OtelLive.js +1 -1
  32. package/lib/state/substates/LayoutState.js +24 -3
  33. package/lib/state/substates/NavigationState.d.ts +11 -3
  34. package/lib/state/substates/NavigationState.js +48 -5
  35. package/lib/views/mcp/McpObservability.d.ts +22 -3
  36. package/lib/views/mcp/McpObservability.js +65 -17
  37. package/lib/views/mcp/RunDetail.js +2 -2
  38. package/package.json +1 -1
@@ -39,7 +39,7 @@ export declare const MCP_DURABLE_SERVICE_NAME = "datalayer-durable";
39
39
  */
40
40
  export declare const serviceNameFor: (metric: McpMetricName) => string;
41
41
  /** The metric catalog `telemetry.py` owns; no ad-hoc counters. */
42
- export declare const MCP_METRIC_CATALOG: readonly ["mcp.calls", "mcp.call.duration", "mcp.refusals", "mcp.limit.unenforced", "mcp.forwards", "mcp.workers", "mcp.worker_start_seconds", "mcp.bindings", "mcp.sandbox.lost", "mcp.tasks", "mcp.task.duration", "durable.step.duration", "durable.queue.wait", "durable.recoveries", "sandbox.launch_seconds", "mcp.audit.writes", "mcp.audit.write_failures", "mcp.dependency.duration", "mcp.dependency.timeouts", "mcp.dependency.ready", "mcp.readiness.failures", "mcp.audit.deleted", "mcp.jobs", "mcp.job.duration"];
42
+ export declare const MCP_METRIC_CATALOG: readonly ["mcp.calls", "mcp.call.duration", "mcp.refusals", "mcp.limit.unenforced", "mcp.forwards", "mcp.workers", "mcp.process.fds", "mcp.worker_start_seconds", "mcp.bindings", "mcp.sandbox.lost", "mcp.tasks", "mcp.task.duration", "durable.step.duration", "durable.queue.wait", "durable.recoveries", "sandbox.launch_seconds", "mcp.audit.writes", "mcp.audit.write_failures", "mcp.dependency.duration", "mcp.dependency.timeouts", "mcp.dependency.ready", "mcp.readiness.failures", "mcp.audit.deleted", "mcp.jobs", "mcp.job.duration"];
43
43
  export type McpMetricName = (typeof MCP_METRIC_CATALOG)[number];
44
44
  /** The four SLIs, and the catalog metric each is read from. */
45
45
  export declare const MCP_SLI_METRICS: {
@@ -79,6 +79,8 @@ export const MCP_METRIC_CATALOG = [
79
79
  'mcp.limit.unenforced',
80
80
  'mcp.forwards',
81
81
  'mcp.workers',
82
+ // A gateway replica: file descriptors open, and the limit (6 November 2026).
83
+ 'mcp.process.fds',
82
84
  'mcp.worker_start_seconds',
83
85
  'mcp.bindings',
84
86
  'mcp.sandbox.lost',
@@ -27,11 +27,17 @@ export interface McpWorkerList {
27
27
  /** The replica that answered. */
28
28
  replica?: string;
29
29
  }
30
- export interface McpWorkflowQueue {
31
- name: string;
32
- backlog: number;
33
- /** Seconds the oldest waiting workflow has waited. */
34
- oldestWaitSeconds?: number | null;
30
+ /** One of the newest runs the durable engine reports. */
31
+ export interface McpWorkflowRun {
32
+ uid: string;
33
+ workflow: string;
34
+ queue: string;
35
+ /** `running` (DBOS's pending and enqueued alike), `blocked`, `completed`, `failed` or `cancelled`. */
36
+ status: string;
37
+ taskUid?: string;
38
+ /** Unix seconds. */
39
+ startedAt?: number;
40
+ updatedAt?: number;
35
41
  }
36
42
  /** One periodic job on the replica that answered. */
37
43
  export interface McpJob {
@@ -57,14 +63,26 @@ export interface McpJobSchedule {
57
63
  holder: string;
58
64
  jobs: McpJob[];
59
65
  }
60
- /** Engine health, queue backlog and worker versions. */
66
+ /**
67
+ * What the durable engine says about itself (`/operations/workflows`, the
68
+ * engine's `operations()` as the gateway forwards it): whether it is taking
69
+ * work, the queues it polls, the queues it was told to run that nobody serves,
70
+ * and its newest runs.
71
+ */
61
72
  export interface McpWorkflowsHealth {
62
73
  engine: 'temporal' | 'dbos' | string;
63
- healthy: boolean;
74
+ /** Whether it takes work: launched, and its run listing readable. */
75
+ durable: boolean;
64
76
  detail?: string | null;
65
- queues: McpWorkflowQueue[];
66
- workerVersions: Record<string, number>;
67
- checkedAt: string;
77
+ /** The queues declared on the engine, by name. */
78
+ queues: string[];
79
+ /** Queues this deployment was told to run that the catalogue does not have: work sent there is never done. */
80
+ queuesNobodyServes: string[];
81
+ workflows: string[];
82
+ /** Its newest runs (twenty), newest first. */
83
+ runs: McpWorkflowRun[];
84
+ databaseConfigured?: boolean;
85
+ applicationVersion?: string;
68
86
  }
69
87
  export declare const getGatewayVersion: (baseUrl?: string) => Promise<McpGatewayVersion>;
70
88
  export declare const getGatewayHealth: (baseUrl?: string) => Promise<McpGatewayHealth>;
@@ -16,11 +16,19 @@ export interface McpTaskListFilters {
16
16
  export declare const listTasks: (token: string, filters?: McpTaskListFilters, baseUrl?: string) => Promise<McpTaskList>;
17
17
  export declare const getTask: (token: string, taskUid: string, baseUrl?: string) => Promise<McpTask>;
18
18
  /** Stop a task that is still going. Idempotent: a finished task is answered as it is. */
19
- export declare const cancelTask: (token: string, taskUid: string, baseUrl?: string) => Promise<McpTask>;
19
+ export declare const cancelTask: (token: string, taskUid: string, baseUrl?: string) => Promise<McpTaskCancelled>;
20
20
  /** What the gateway says of an answer: that it was passed on, not the task. */
21
21
  export type McpTaskAnswered = {
22
22
  uid: string;
23
23
  answered: boolean;
24
+ /** Set when the answer closed a run its engine no longer carried. */
25
+ status?: string;
26
+ };
27
+ /** What the gateway says of a cancel: whether it stopped, and the status. */
28
+ export type McpTaskCancelled = {
29
+ uid: string;
30
+ cancelled: boolean;
31
+ status?: string;
24
32
  };
25
33
  /**
26
34
  * Answer a task that is `input_required`.
@@ -0,0 +1,74 @@
1
+ /** One connection the firewall decided. */
2
+ export interface FirewallConnection {
3
+ /** Unix nanoseconds, which a JavaScript number holds only approximately. */
4
+ time_unix_nano: number;
5
+ decision: 'allow' | 'refuse';
6
+ /** The rule that decided, e.g. `spec:*`, `base:refused`, `spec:*_non-public`. */
7
+ rule: string;
8
+ /** `address:port`. */
9
+ destination: string;
10
+ /** The host the address was resolved from, when the firewall knows it. */
11
+ host: string;
12
+ port: number | null;
13
+ /** `tcp`, `udp` or `icmp`. */
14
+ protocol: string;
15
+ source: string;
16
+ /** Connections folded into this row, besides itself. */
17
+ repeated: number;
18
+ }
19
+ /** One apply of the runtime's rule. */
20
+ export interface FirewallApplied {
21
+ time_unix_nano: number;
22
+ /** `start`, `configure`, `refresh` or `apply`. */
23
+ reason: string;
24
+ revision: string;
25
+ /** The egress entries applied: hosts, CIDRs, `*` for any public address. */
26
+ egress: string[];
27
+ ingress: string[];
28
+ /** The base layer's allowances, as the firewall wrote them. */
29
+ base?: unknown;
30
+ /** How many rules were compiled. */
31
+ rules: number | null;
32
+ /** Hosts that resolved to nothing. */
33
+ unresolved: string[];
34
+ }
35
+ /** A runtime's firewall over a window. */
36
+ export interface RuntimeFirewall {
37
+ success: boolean;
38
+ runtime_uid: string;
39
+ window: {
40
+ since_unix_nano: number;
41
+ days: number;
42
+ };
43
+ /** Why the firewall did not take the runtime's rule, as its record says; empty when it holds. */
44
+ refused: string;
45
+ /** Latest first. None when the runtime has no firewall, or it has not reported yet. */
46
+ applied: FirewallApplied[];
47
+ /** The latest connections, latest first. */
48
+ connections: FirewallConnection[];
49
+ /** Over the whole window, each row counting itself and the connections folded into it. */
50
+ counts: {
51
+ allowed: number;
52
+ refused: number;
53
+ rows: number;
54
+ };
55
+ /** The window held more connection rows than were counted. */
56
+ truncated: boolean;
57
+ }
58
+ export interface RuntimeFirewallOptions {
59
+ /** How many days back, 1 to 30; 7 by default. */
60
+ days?: number;
61
+ /** How many connections to return, latest first; 200 by default. */
62
+ limit?: number;
63
+ }
64
+ /**
65
+ * A runtime's firewall: the rule applied, its latest connections and their counts.
66
+ *
67
+ * Answers 404 when the runtime is not the caller's (or no longer runs).
68
+ *
69
+ * @param token - Authentication token.
70
+ * @param runtimeUid - The runtime's uid.
71
+ * @param options - The window and how many connections.
72
+ * @param baseUrl - The OTEL service.
73
+ */
74
+ export declare const getRuntimeFirewall: (token: string, runtimeUid: string, options?: RuntimeFirewallOptions, baseUrl?: string) => Promise<RuntimeFirewall>;
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Copyright (c) 2023-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A runtime's network firewall, read back from Datalayer's observability.
7
+ *
8
+ * The runtime pod's `shield-firewall` sidecar (agent-shield's firewall)
9
+ * exports each rule it applies and each connection it allows or refuses as
10
+ * a span. The OTEL service reads them for the runtime's owner only: it asks
11
+ * the runtimes service for the runtime with the caller's own token first.
12
+ *
13
+ * @module api/otel/firewall
14
+ */
15
+ import { requestDatalayerAPI } from '../DatalayerApi.js';
16
+ import { API_BASE_PATHS, DEFAULT_SERVICE_URLS } from '../constants.js';
17
+ import { validateToken } from '../utils/validation.js';
18
+ /**
19
+ * A runtime's firewall: the rule applied, its latest connections and their counts.
20
+ *
21
+ * Answers 404 when the runtime is not the caller's (or no longer runs).
22
+ *
23
+ * @param token - Authentication token.
24
+ * @param runtimeUid - The runtime's uid.
25
+ * @param options - The window and how many connections.
26
+ * @param baseUrl - The OTEL service.
27
+ */
28
+ export const getRuntimeFirewall = async (token, runtimeUid, options = {}, baseUrl = DEFAULT_SERVICE_URLS.OTEL) => {
29
+ validateToken(token);
30
+ const params = new URLSearchParams();
31
+ if (options.days)
32
+ params.set('days', String(options.days));
33
+ if (options.limit)
34
+ params.set('limit', String(options.limit));
35
+ const queryString = params.toString();
36
+ const url = `${baseUrl}${API_BASE_PATHS.OTEL}/firewall/runtimes/${encodeURIComponent(runtimeUid)}${queryString ? `?${queryString}` : ''}`;
37
+ return requestDatalayerAPI({
38
+ url,
39
+ method: 'GET',
40
+ token,
41
+ });
42
+ };
@@ -11,3 +11,4 @@ export * as logs from './logs';
11
11
  export * as services from './services';
12
12
  export * as query from './query';
13
13
  export * as dashboards from './dashboards';
14
+ export * as firewall from './firewall';
@@ -15,3 +15,4 @@ export * as logs from './logs.js';
15
15
  export * as services from './services.js';
16
16
  export * as query from './query.js';
17
17
  export * as dashboards from './dashboards.js';
18
+ export * as firewall from './firewall.js';
package/lib/cli-login.js CHANGED
@@ -9,7 +9,7 @@ import { Button, Spinner, Text } from '@primer/react';
9
9
  import { ShieldLockIcon } from '@primer/octicons-react';
10
10
  import { Box, DatalayerThemeProvider, themeConfigs, createThemeStore, } from '@datalayer/primer-addons';
11
11
  import { SvgLinesLogo, SvgUsecasesHero } from '@datalayer/design';
12
- import { AppearanceControlsWithStore } from '@datalayer/primer-addons/lib/components/appearance';
12
+ import { AppearanceControls } from '@datalayer/primer-addons/lib/components/appearance';
13
13
  import { ConfettiSuccess } from './components/confetti/index.js';
14
14
  import { formatFriendlyHandle } from './utils/Handles.js';
15
15
  import { SignInSimple } from './views/iam/SignInSimple.js';
@@ -90,7 +90,7 @@ function SignInCLIApp() {
90
90
  setState('signin');
91
91
  });
92
92
  }, [finalizeCliAuthentication]);
93
- const renderLayout = (content) => (_jsx(DatalayerThemeProvider, { colorMode: colorMode, theme: cfg.primerTheme, themeStyles: cfg.themeStyles, children: _jsxs(Box, { minHeight: "100vh", bg: "canvas.default", color: "fg.default", position: "relative", children: [_jsx(Box, { position: "fixed", top: 0, right: 0, zIndex: 20, p: 3, display: "flex", justifyContent: "flex-end", children: _jsx(AppearanceControlsWithStore, { useStore: useCliThemeStore }) }), content] }) }));
93
+ const renderLayout = (content) => (_jsx(DatalayerThemeProvider, { colorMode: colorMode, theme: cfg.primerTheme, themeStyles: cfg.themeStyles, children: _jsxs(Box, { minHeight: "100vh", bg: "canvas.default", color: "fg.default", position: "relative", children: [_jsx(Box, { position: "fixed", top: 0, right: 0, zIndex: 20, p: 3, display: "flex", justifyContent: "flex-end", children: _jsx(AppearanceControls, { layout: "inline", useStore: useCliThemeStore }) }), content] }) }));
94
94
  if (state === 'checking') {
95
95
  return renderLayout(_jsx(Box, { display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh", children: _jsx(Spinner, {}) }));
96
96
  }
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback } from 'react';
3
- import { Text } from '@primer/react';
3
+ import { Button, Text } from '@primer/react';
4
4
  import { Box } from '@datalayer/primer-addons';
5
- import { KeyIcon } from '@primer/octicons-react';
5
+ import { KeyIcon, SyncIcon } from '@primer/octicons-react';
6
6
  import { SignInSimple } from '../../views/iam/SignInSimple.js';
7
7
  import { useCoreStore, iamStore } from '../../state/substates/index.js';
8
8
  export function AnonymousKeyExpired({ agentName, sandboxStillRuns = false, temporary = true, onSignedIn, onSignUp, }) {
@@ -35,7 +35,19 @@ export function AnonymousKeyExpired({ agentName, sandboxStillRuns = false, tempo
35
35
  ? `${agentName ?? 'The agent'} has used the time this key allows. Sign in to point your own agent at it, use your own data, and drop the time limit.`
36
36
  : `${agentName ?? 'The agent'} stopped because your session ran out.`, sandboxStillRuns
37
37
  ? ' Your notebook is still running in this page.'
38
- : ''] }), _jsx(SignInSimple
38
+ : ''] }), temporary && (
39
+ /*
40
+ Or another trial key (the user, 6 November 2026): a new page gets
41
+ a new one, so the demo can go on without an account. The button
42
+ reloads the browser rather than the chat, since the key is minted
43
+ as the page opens; what was said is gone with it, and the line
44
+ says so.
45
+ */
46
+ _jsxs(Box, { display: "flex", flexDirection: "column", alignItems: "center", gap: 2, mb: 3, "data-anonymous-key-refresh": "", children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted', textAlign: 'center' }, children: "Or keep trying without an account: refreshing the page gives this demo a new key, and starts the conversation over." }), _jsx(Button, { leadingVisual: SyncIcon, onClick: () => {
47
+ if (typeof window !== 'undefined') {
48
+ window.location.reload();
49
+ }
50
+ }, children: "Refresh the page for a new key" })] })), _jsx(SignInSimple
39
51
  // The panel above has already said what happened and why; the form's
40
52
  // own hero would say it a second time, in a bigger font.
41
53
  , {
@@ -204,7 +204,7 @@ export function PrincipalSwitcherMenu({ maxLabelChars = 48, fullWidth = true, sh
204
204
  outline: '2px solid',
205
205
  outlineColor: 'accent.emphasis',
206
206
  outlineOffset: '2px',
207
- }, children: [_jsx(Box, { display: "inline-flex", alignItems: "center", color: "fg.muted", flexShrink: 0, children: selectedPrincipalKind === 'organization' ? (_jsx(PeopleIcon, { size: 16 })) : selectedPrincipalKind === 'team' ? (_jsx(OrganizationIcon, { size: 16 })) : (_jsx(PersonIcon, { size: 16 })) }), _jsx(Box, { minWidth: 0, flex: 1, children: _jsx(Text, { sx: {
207
+ }, children: [_jsx(Box, { display: "inline-flex", alignItems: "center", color: "fg.muted", flexShrink: 0, children: selectedPrincipalKind === 'organization' ? (_jsx(OrganizationIcon, { size: 16 })) : selectedPrincipalKind === 'team' ? (_jsx(PeopleIcon, { size: 16 })) : (_jsx(PersonIcon, { size: 16 })) }), _jsx(Box, { minWidth: 0, flex: 1, children: _jsx(Text, { sx: {
208
208
  display: 'block',
209
209
  color: 'accent.fg',
210
210
  fontWeight: 'semibold',
@@ -216,7 +216,7 @@ export function PrincipalSwitcherMenu({ maxLabelChars = 48, fullWidth = true, sh
216
216
  }, children: selectedPrincipalLabelClosed }) }), isPlatformAdmin && isCurrentUserPrincipal ? (_jsx(Box, { flexShrink: 0, display: "inline-flex", alignItems: "center", children: _jsx(AdminLabel, {}) })) : null, _jsx(Box, { display: "inline-flex", alignItems: "center", color: "fg.muted", flexShrink: 0, ml: isPlatformAdmin && isCurrentUserPrincipal ? 1 : 0, children: _jsx(TriangleDownIcon, { size: 12 }) })] })) : (_jsx(Button, { variant: "invisible", size: "small", "aria-label": "Switch principal", trailingVisual: TriangleDownIcon, sx: {
217
217
  maxWidth: fullWidth ? '100%' : ['200px', '260px', '360px'],
218
218
  width: fullWidth ? '100%' : 'auto',
219
- }, children: _jsxs(Box, { display: "inline-flex", alignItems: "center", gap: 1, minWidth: 0, children: [_jsx(Box, { display: "inline-flex", alignItems: "center", color: "fg.muted", flexShrink: 0, children: selectedPrincipalKind === 'organization' ? (_jsx(PeopleIcon, { size: 16 })) : selectedPrincipalKind === 'team' ? (_jsx(OrganizationIcon, { size: 16 })) : (_jsx(PersonIcon, { size: 16 })) }), _jsx(Box, { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: _jsx(Text, { sx: {
219
+ }, children: _jsxs(Box, { display: "inline-flex", alignItems: "center", gap: 1, minWidth: 0, children: [_jsx(Box, { display: "inline-flex", alignItems: "center", color: "fg.muted", flexShrink: 0, children: selectedPrincipalKind === 'organization' ? (_jsx(OrganizationIcon, { size: 16 })) : selectedPrincipalKind === 'team' ? (_jsx(PeopleIcon, { size: 16 })) : (_jsx(PersonIcon, { size: 16 })) }), _jsx(Box, { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: _jsx(Text, { sx: {
220
220
  color: 'accent.fg',
221
221
  fontWeight: 'semibold',
222
222
  }, children: selectedPrincipalLabelClosed }) }), isPlatformAdmin && isCurrentUserPrincipal ? (_jsx(AdminLabel, {})) : null] }) })) }), _jsx(ActionMenu.Overlay, { width: "large", children: _jsxs(ActionList, { selectionVariant: "single", children: [_jsxs(ActionList.Group, { children: [_jsx(ActionList.GroupHeading, { children: "Personal" }), _jsxs(ActionList.Item, { disabled: isCurrentUserPrincipal, selected: isCurrentUserPrincipal, sx: isCurrentUserPrincipal ? selectedItemSx : undefined, onSelect: () => {
@@ -242,7 +242,7 @@ export function PrincipalSwitcherMenu({ maxLabelChars = 48, fullWidth = true, sh
242
242
  if (organizationUid && organization.handle) {
243
243
  selectOrganization(organizationUid, organization.handle);
244
244
  }
245
- }, children: [_jsx(ActionList.LeadingVisual, { children: _jsx(PeopleIcon, {}) }), _jsxs(Box, { as: "span", title: displayHandleText(organization.handle), display: "block", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: [String(organization?.display_name ||
245
+ }, children: [_jsx(ActionList.LeadingVisual, { children: _jsx(OrganizationIcon, {}) }), _jsxs(Box, { as: "span", title: displayHandleText(organization.handle), display: "block", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: [String(organization?.display_name ||
246
246
  organization?.displayName ||
247
247
  organization?.name ||
248
248
  organization?.handle ||
@@ -258,7 +258,7 @@ export function PrincipalSwitcherMenu({ maxLabelChars = 48, fullWidth = true, sh
258
258
  return;
259
259
  }
260
260
  selectTeam(team, orgHandle);
261
- }, children: [_jsx(ActionList.LeadingVisual, { children: _jsx(OrganizationIcon, {}) }), _jsxs(Box, { as: "span", title: `${displayHandleText(orgHandle)}/${displayHandleText(team.handle, { withAt: false })}`, display: "block", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: [String(team.displayName ||
261
+ }, children: [_jsx(ActionList.LeadingVisual, { children: _jsx(PeopleIcon, {}) }), _jsxs(Box, { as: "span", title: `${displayHandleText(orgHandle)}/${displayHandleText(team.handle, { withAt: false })}`, display: "block", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: [String(team.displayName ||
262
262
  team.name ||
263
263
  team.handle ||
264
264
  'Team').trim(), _jsxs(Box, { as: "span", color: "fg.muted", children: [' ', "\u00B7 ", _jsx(DisplayHandle, { handle: orgHandle }), "/", _jsx(DisplayHandle, { handle: team.handle, withAt: false })] })] })] }, team.uid));
@@ -405,8 +405,18 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
405
405
  const [isSearchOverlayOpen, setIsSearchOverlayOpen] = useState(false);
406
406
  const [sharingAccessMessage, setSharingAccessMessage] = useState(null);
407
407
  const [isSharingAccessConfirmed, setIsSharingAccessConfirmed] = useState(false);
408
+ // Why the sharing could not be read, said where the sharing would be: a
409
+ // toast is gone in a few seconds and leaves the form disabled, unexplained.
410
+ const [sharingLoadError, setSharingLoadError] = useState(null);
408
411
  // ----- Refs -----
409
- const hasLoadedForOpenRef = useRef(false);
412
+ // What the sharing was last asked from (the transport, or the URL): a load
413
+ // is started once per source, and started again when the source changes or
414
+ // when the load in flight was dropped before it answered.
415
+ const loadedSourceRef = useRef(null);
416
+ const levelsRef = useRef(levels);
417
+ levelsRef.current = levels;
418
+ const resourceLabelRef = useRef(resourceLabel);
419
+ resourceLabelRef.current = resourceLabel;
410
420
  const hasHydratedSharingRef = useRef(false);
411
421
  const lastSavedSharingRef = useRef(null);
412
422
  const autoSaveTimerRef = useRef(null);
@@ -471,7 +481,8 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
471
481
  }, [isOpen, defaultAccessLevel]);
472
482
  useEffect(() => {
473
483
  if (!isOpen) {
474
- hasLoadedForOpenRef.current = false;
484
+ loadedSourceRef.current = null;
485
+ setSharingLoadError(null);
475
486
  hasHydratedSharingRef.current = false;
476
487
  lastSavedSharingRef.current = null;
477
488
  if (autoSaveTimerRef.current) {
@@ -496,15 +507,20 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
496
507
  setIsSharingAccessConfirmed(false);
497
508
  return;
498
509
  }
499
- if (hasLoadedForOpenRef.current) {
510
+ const source = transport ?? requestUrl ?? null;
511
+ if (loadedSourceRef.current === source) {
500
512
  return;
501
513
  }
502
- hasLoadedForOpenRef.current = true;
514
+ loadedSourceRef.current = source;
503
515
  let cancelled = false;
516
+ let settled = false;
517
+ const levels = levelsRef.current;
518
+ const resourceLabel = resourceLabelRef.current;
504
519
  const run = async () => {
505
520
  setIsLoading(true);
506
521
  setIsSharingAccessConfirmed(false);
507
522
  setSharingAccessMessage(null);
523
+ setSharingLoadError(null);
508
524
  try {
509
525
  const response = transport
510
526
  ? null
@@ -572,9 +588,11 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
572
588
  const message = error instanceof Error
573
589
  ? error.message
574
590
  : `Unable to load ${resourceLabel.toLowerCase()} sharing.`;
591
+ setSharingLoadError(message);
575
592
  enqueueToastRef.current(message, { variant: 'error' });
576
593
  }
577
594
  finally {
595
+ settled = true;
578
596
  if (!cancelled) {
579
597
  setIsLoading(false);
580
598
  }
@@ -583,16 +601,23 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
583
601
  void run();
584
602
  return () => {
585
603
  cancelled = true;
604
+ // A load dropped before it answered never clears "Loading…" by itself
605
+ // and its answer is thrown away: the next run has to ask again. Before,
606
+ // the once-per-open guard stayed set, so any re-render that re-ran this
607
+ // effect mid-flight (the space's name arriving, say) left the form
608
+ // disabled on "Loading current sharing settings…" for good.
609
+ if (!settled && loadedSourceRef.current === source) {
610
+ loadedSourceRef.current = null;
611
+ }
586
612
  };
613
+ // Only what decides the request: the label and the levels are read
614
+ // through refs, so a parent's re-render does not drop a load in flight.
587
615
  }, [
588
616
  isOpen,
589
617
  canRequest,
590
618
  requestUrl,
591
619
  transport,
592
620
  token,
593
- resourceLabel,
594
- resourceName,
595
- levels,
596
621
  mergePrincipalCacheEntry,
597
622
  ]);
598
623
  // ----- Fetch shareable principals on open -----
@@ -1215,7 +1240,7 @@ export function ShareAccessComponent({ isOpen, requestUrl, transport, resourceLa
1215
1240
  letterSpacing: 0.4,
1216
1241
  }, children: label }), _jsx(Box, { display: "grid", gap: 1, children: items.map(renderShareablePrincipalRow) })] }));
1217
1242
  };
1218
- const content = (_jsxs(Box, { p: 3, display: "flex", flexDirection: "column", gap: 3, children: [sharingAccessMessage && (_jsxs(Box, { px: 3, py: 3, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "attention.muted", bg: "attention.subtle", display: "grid", gap: 1, children: [_jsx(Text, { sx: { fontWeight: 600 }, children: "Sharing access is restricted" }), _jsx(Text, { sx: { color: 'fg.muted' }, children: sharingAccessMessage })] })), _jsxs(Box, { opacity: isReadOnly ? 0.6 : 1, pointerEvents: isReadOnly ? 'none' : 'auto', display: "flex", flexDirection: "column", gap: 3, "aria-disabled": isReadOnly, children: [_jsxs(Box, { px: 3, py: 2, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "border.default", bg: "canvas.subtle", display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, children: [_jsx(Box, { display: "grid", gap: 1, children: _jsxs(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: ["Share ", resourceName || `this ${resourceLabel.toLowerCase()}`, ". Pick a principal below \u2014 they will be granted the selected access level."] }) }), _jsxs(ActionMenu, { children: [_jsxs(Box, { display: "inline-flex", alignItems: "center", gap: 1, children: [_jsx(ActionMenu.Anchor, { children: _jsxs(Button, { variant: "default", size: "small", leadingVisual: KeyIcon, disabled: isSaving || isReadOnly, children: ["Access:", ' ', levelLabels[selectedAccessLevel] || selectedAccessLevel] }) }), _jsx(Button, { variant: "invisible", size: "small", leadingVisual: isExpanded ? ChevronUpIcon : ChevronDownIcon, onClick: () => setIsExpanded(previous => !previous), "aria-label": isExpanded
1243
+ const content = (_jsxs(Box, { p: 3, display: "flex", flexDirection: "column", gap: 3, children: [sharingAccessMessage && (_jsxs(Box, { px: 3, py: 3, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "attention.muted", bg: "attention.subtle", display: "grid", gap: 1, children: [_jsx(Text, { sx: { fontWeight: 600 }, children: "Sharing access is restricted" }), _jsx(Text, { sx: { color: 'fg.muted' }, children: sharingAccessMessage })] })), sharingLoadError && (_jsxs(Box, { role: "alert", px: 3, py: 3, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "danger.muted", bg: "danger.subtle", display: "grid", gap: 1, children: [_jsx(Text, { sx: { fontWeight: 600 }, children: "The sharing settings could not be loaded" }), _jsx(Text, { sx: { color: 'fg.muted' }, children: sharingLoadError })] })), _jsxs(Box, { opacity: isReadOnly ? 0.6 : 1, pointerEvents: isReadOnly ? 'none' : 'auto', display: "flex", flexDirection: "column", gap: 3, "aria-disabled": isReadOnly, children: [_jsxs(Box, { px: 3, py: 2, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "border.default", bg: "canvas.subtle", display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, children: [_jsx(Box, { display: "grid", gap: 1, children: _jsxs(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: ["Share ", resourceName || `this ${resourceLabel.toLowerCase()}`, ". Pick a principal below \u2014 they will be granted the selected access level."] }) }), _jsxs(ActionMenu, { children: [_jsxs(Box, { display: "inline-flex", alignItems: "center", gap: 1, children: [_jsx(ActionMenu.Anchor, { children: _jsxs(Button, { variant: "default", size: "small", leadingVisual: KeyIcon, disabled: isSaving || isReadOnly, children: ["Access:", ' ', levelLabels[selectedAccessLevel] || selectedAccessLevel] }) }), _jsx(Button, { variant: "invisible", size: "small", leadingVisual: isExpanded ? ChevronUpIcon : ChevronDownIcon, onClick: () => setIsExpanded(previous => !previous), "aria-label": isExpanded
1219
1244
  ? 'Shrink sharing details'
1220
1245
  : 'Expand sharing details', children: isExpanded ? 'Shrink details' : 'Expand details' })] }), _jsx(ActionMenu.Overlay, { width: "small", children: _jsx(ActionList, { selectionVariant: "single", children: levels.map(({ level, label }) => (_jsx(ActionList.Item, { selected: selectedAccessLevel === level, onSelect: () => setSelectedAccessLevel(level), children: label }, level))) }) })] })] }), !isExpanded && (_jsxs(Box, { px: 3, py: 2, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "border.default", bg: "canvas.default", display: "grid", gap: 1, children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Sharing details are collapsed." }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [aclEntries.length, " principal", aclEntries.length === 1 ? '' : 's', ' ', "currently granted access."] })] })), isExpanded && (_jsxs(_Fragment, { children: [_jsxs(Box, { px: 3, py: 2, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "border.default", bg: "canvas.default", display: "grid", gap: 1, children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Owner" }), ownerPrincipals.length > 0 ? (_jsx(Box, { display: "grid", children: ownerPrincipals.map((ownerPrincipal, index) => (_jsx(Box, { py: 1, borderTopWidth: index === 0 ? 0 : 1, borderTopStyle: "solid", borderColor: "border.subtle", children: _jsx(OwnerPrincipalRow, { ownerPrincipal: ownerPrincipal, cache: principalCache, showAvatarSkeleton: ownerPrincipal.kind === 'personal' &&
1221
1246
  Boolean(hydratingUserUids[ownerPrincipal.uid]), isPlatformAdmin: isPlatformAdmin }) }, principalKey(ownerPrincipal.kind, ownerPrincipal.uid)))) })) : (_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Owner information is not available." }))] }), _jsxs(Box, { px: 3, py: 2, borderRadius: 2, borderWidth: 1, borderStyle: "solid", borderColor: "border.default", bg: "canvas.default", display: "grid", gap: 2, children: [_jsxs(Box, { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, children: [_jsx(Text, { sx: { fontSize: 1, fontWeight: 600 }, children: "Share with\u2026" }), isLoadingShareable && (_jsxs(Box, { display: "inline-flex", alignItems: "center", gap: 1, children: [_jsx(Spinner, { size: "small" }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Loading\u2026" })] }))] }), !isLoadingShareable &&
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Fragment, useSyncExternalStore } from 'react';
3
3
  import { Button } from '@primer/react';
4
4
  import { Box, SlidingPanel } from '@datalayer/primer-addons';
@@ -21,11 +21,22 @@ export const ToastPanel = () => {
21
21
  // A new toast is a new panel: it slides in, and its own clock runs.
22
22
  , { isOpen: Boolean(shown),
23
23
  // A toast of several lines (a failed request's message and exception) says each on its own line.
24
- message: (shown?.message ?? '').split('\n').map((line, index) => (_jsxs(Fragment, { children: [index > 0 ? _jsx("br", {}) : null, line] }, index))), variant: shown?.variant ?? 'default', position: "north", durationMs: shown && shown.autoClose !== false ? shown.autoClose : undefined, onDismiss: close, details: shown?.actions?.length ? (_jsx(Box, { display: "flex", gap: 2, flexWrap: "wrap", mt: 2, children: shown.actions.map(action => (_jsx(Button, { size: "small", title: action.caption ?? action.label, variant: BUTTON_VARIANTS[action.displayType ?? 'default'], onClick: (event) => {
25
- action.callback(event.nativeEvent);
26
- if (!event.nativeEvent.defaultPrevented) {
27
- close();
28
- }
29
- }, children: action.label }, action.label))) })) : undefined }, shown?.id ?? 'none'));
24
+ message: (shown?.message ?? '').split('\n').map((line, index) => (_jsxs(Fragment, { children: [index > 0 ? _jsx("br", {}) : null, line] }, index))), variant: shown?.variant ?? 'default', position: "north", durationMs: shown && shown.autoClose !== false ? shown.autoClose : undefined, onDismiss: close, details: shown?.detail || shown?.actions?.length ? (_jsxs(_Fragment, { children: [shown?.detail ? (
25
+ // The technical detail: small, in monospace, whole, wrapped (never the message's type).
26
+ _jsx(Box, { as: "pre", "data-toast-detail": true, sx: {
27
+ m: 0,
28
+ mt: 1,
29
+ fontFamily: 'mono',
30
+ fontSize: 0,
31
+ whiteSpace: 'pre-wrap',
32
+ overflowWrap: 'anywhere',
33
+ maxHeight: '12rem',
34
+ overflowY: 'auto',
35
+ }, children: shown.detail })) : null, shown?.actions?.length ? (_jsx(Box, { display: "flex", gap: 2, flexWrap: "wrap", mt: 2, children: shown.actions.map(action => (_jsx(Button, { size: "small", title: action.caption ?? action.label, variant: BUTTON_VARIANTS[action.displayType ?? 'default'], onClick: (event) => {
36
+ action.callback(event.nativeEvent);
37
+ if (!event.nativeEvent.defaultPrevented) {
38
+ close();
39
+ }
40
+ }, children: action.label }, action.label))) })) : null] })) : undefined }, shown?.id ?? 'none'));
30
41
  };
31
42
  export default ToastPanel;
@@ -17,6 +17,8 @@ export type QueuedToast = {
17
17
  id: string;
18
18
  variant: ToastVariant;
19
19
  message: string;
20
+ /** A technical detail drawn under the message, small and in monospace (a request's status, a server's words). */
21
+ detail?: string;
20
22
  actions?: ToastAction[];
21
23
  /** Milliseconds before it goes on its own; `false` stays until dismissed. */
22
24
  autoClose: number | false;
@@ -20,6 +20,7 @@ export * from './useLocation';
20
20
  export * from './useLocationHandles';
21
21
  export * from './useMcp';
22
22
  export * from './useNavigate';
23
+ export * from './useKeptScroll';
23
24
  export * from './useOrganizationPlugins';
24
25
  export * from './useParams';
25
26
  export * from './useOnClickOutside';
@@ -25,6 +25,7 @@ export * from './useLocation.js';
25
25
  export * from './useLocationHandles.js';
26
26
  export * from './useMcp.js';
27
27
  export * from './useNavigate.js';
28
+ export * from './useKeptScroll.js';
28
29
  export * from './useOrganizationPlugins.js';
29
30
  export * from './useParams.js';
30
31
  export * from './useOnClickOutside.js';
@@ -33,7 +33,7 @@
33
33
  * ```
34
34
  */
35
35
  import { QueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
36
- import { IAnyOrganization, IContact, IIAMProviderName, IIAMToken, IOrganization, ISchool, ISecret, IStudent, ITeam, IUser, IUserOnboarding, IUserSettings } from '../models';
36
+ import { IAnyOrganization, IContact, IIAMProviderName, IIAMToken, IOrganization, ISchool, ISecret, IStudent, ITeam, IUser, IUserOnboarding, IUserSettings, type UsageRuntimeEngine } from '../models';
37
37
  type IAnySpace = any;
38
38
  export type CacheProps = {
39
39
  loginRoute?: string;
@@ -635,7 +635,9 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
635
635
  accountUid?: string;
636
636
  } | Omit<UseQueryOptions<unknown[]>, "queryKey" | "queryFn">, options?: Omit<UseQueryOptions<unknown[]>, "queryKey" | "queryFn">) => import("@tanstack/react-query").UseQueryResult<unknown[], Error>;
637
637
  useUsagesForUser: (userId: string, options?: Omit<UseQueryOptions<unknown[]>, "queryKey" | "queryFn">) => import("@tanstack/react-query").UseQueryResult<unknown[], Error>;
638
- usePlatformUsages: (options?: Omit<UseQueryOptions<unknown[]>, "queryKey" | "queryFn">) => import("@tanstack/react-query").UseQueryResult<unknown[], Error>;
638
+ usePlatformUsages: (options?: Omit<UseQueryOptions<unknown[]>, "queryKey" | "queryFn">, filter?: {
639
+ runtimeEngine?: UsageRuntimeEngine;
640
+ }) => import("@tanstack/react-query").UseQueryResult<unknown[], Error>;
639
641
  useOrganization: (organizationId: string) => import("@tanstack/react-query").UseQueryResult<IOrganization, Error>;
640
642
  useOrganizationByHandle: (handle: string) => import("@tanstack/react-query").UseQueryResult<IOrganization | null, Error>;
641
643
  useOrganizationPublicProfileByHandle: (handle: string) => import("@tanstack/react-query").UseQueryResult<any, Error>;
@@ -37,7 +37,7 @@
37
37
  * ```
38
38
  */
39
39
  import { useQuery, useQueries, useMutation, useQueryClient, } from '@tanstack/react-query';
40
- import { BOOTSTRAP_USER_ONBOARDING, asContact, asInvite, asOrganization, asSecret, asTeam, asToken, asUser, } from '../models/index.js';
40
+ import { BOOTSTRAP_USER_ONBOARDING, asContact, asInvite, asOrganization, asSecret, asTeam, asToken, asUser, usageRuntimeEngineOf, } from '../models/index.js';
41
41
  import { asSpace } from './cacheConverters.js';
42
42
  import { parseDeckSpec } from './deckSpec.js';
43
43
  import { libraryAppFieldsOf } from './libraryApp.js';
@@ -6592,9 +6592,15 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
6592
6592
  /**
6593
6593
  * Get platform-wide usage statistics
6594
6594
  */
6595
- const usePlatformUsages = (options) => {
6595
+ const usePlatformUsages = (options,
6596
+ /**
6597
+ * Only the usage of one engine (`runtime_engine`), filtered by IAM:
6598
+ * `kagent` for the usage page's KAgent tab. Every record otherwise.
6599
+ */
6600
+ filter) => {
6601
+ const runtimeEngine = filter?.runtimeEngine;
6596
6602
  return useQuery({
6597
- queryKey: ['usage', 'platform'],
6603
+ queryKey: ['usage', 'platform', runtimeEngine ?? 'all'],
6598
6604
  queryFn: async () => {
6599
6605
  const parseUsageDate = (value) => {
6600
6606
  if (!value) {
@@ -6642,11 +6648,14 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
6642
6648
  usagePeriodLabel: u.usage_period_label,
6643
6649
  usagePeriodStart: parseUsageDate(u.usage_period_start),
6644
6650
  usagePeriodEnd: parseUsageDate(u.usage_period_end),
6651
+ runtimeEngine: usageRuntimeEngineOf(u.runtime_engine),
6645
6652
  metadata,
6646
6653
  };
6647
6654
  };
6648
6655
  const resp = await requestDatalayer({
6649
- url: `${configuration.iamUrl}/api/iam/v1/usage/platform`,
6656
+ url: `${configuration.iamUrl}/api/iam/v1/usage/platform${runtimeEngine
6657
+ ? `?runtime_engine=${encodeURIComponent(runtimeEngine)}`
6658
+ : ''}`,
6650
6659
  method: 'GET',
6651
6660
  });
6652
6661
  // Transform snake_case API response to camelCase IUsage interface
@@ -0,0 +1,6 @@
1
+ /** The nearest ancestor of `marker` that scrolls vertically, if any. */
2
+ export declare function scrollingAncestor(marker: HTMLElement | null): HTMLElement | null;
3
+ /** Where the sidebar kept under `key` was last scrolled to, if it was. */
4
+ export declare const keptScrollOf: (key: string) => number | undefined;
5
+ export declare function useKeptScroll<T extends HTMLElement = HTMLElement>(key: string): import("react").RefObject<T | null>;
6
+ export default useKeptScroll;
@@ -0,0 +1,54 @@
1
+ /*
2
+ * Copyright (c) 2023-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A sidebar keeps its scroll across pages: an entry pressed far down the list
7
+ * leaves the list where it was, so the next one is still under the pointer.
8
+ *
9
+ * `useKeptScroll(key)` returns a ref for a small element placed inside the
10
+ * sidebar; the nearest ancestor that scrolls is the one kept. Its position is
11
+ * remembered under `key` while it scrolls, and given back when a sidebar with
12
+ * the same key is mounted again (a layout rendered anew by the next route).
13
+ * The panel must not be one `useNavigate` brings to the top: it is not marked
14
+ * `data-scroll-root`.
15
+ *
16
+ * @module hooks/useKeptScroll
17
+ */
18
+ import { useLayoutEffect, useRef } from 'react';
19
+ const kept = new Map();
20
+ const scrollsY = (element) => {
21
+ const overflow = window.getComputedStyle(element).overflowY;
22
+ return overflow === 'auto' || overflow === 'scroll';
23
+ };
24
+ /** The nearest ancestor of `marker` that scrolls vertically, if any. */
25
+ export function scrollingAncestor(marker) {
26
+ let element = marker?.parentElement ?? null;
27
+ while (element && !scrollsY(element)) {
28
+ element = element.parentElement;
29
+ }
30
+ return element;
31
+ }
32
+ /** Where the sidebar kept under `key` was last scrolled to, if it was. */
33
+ export const keptScrollOf = (key) => kept.get(key);
34
+ export function useKeptScroll(key) {
35
+ const ref = useRef(null);
36
+ useLayoutEffect(() => {
37
+ const panel = scrollingAncestor(ref.current);
38
+ if (!panel) {
39
+ return;
40
+ }
41
+ const saved = kept.get(key);
42
+ if (saved !== undefined) {
43
+ panel.scrollTop = saved;
44
+ }
45
+ const remember = () => kept.set(key, panel.scrollTop);
46
+ panel.addEventListener('scroll', remember, { passive: true });
47
+ return () => {
48
+ remember();
49
+ panel.removeEventListener('scroll', remember);
50
+ };
51
+ }, [key]);
52
+ return ref;
53
+ }
54
+ export default useKeptScroll;