@datalayer/agent-runtimes 1.0.28 → 1.0.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 (31) hide show
  1. package/lib/api/index.js +16 -4
  2. package/lib/components/code-sandboxes/CodeSandboxEnvironmentSelect.d.ts +41 -0
  3. package/lib/components/code-sandboxes/CodeSandboxEnvironmentSelect.js +83 -0
  4. package/lib/components/code-sandboxes/CodeSandboxLauncher.d.ts +11 -0
  5. package/lib/components/code-sandboxes/CodeSandboxLauncher.js +96 -12
  6. package/lib/components/code-sandboxes/CodeSandboxNames.js +68 -23
  7. package/lib/components/code-sandboxes/CodeSandboxPicker.d.ts +14 -1
  8. package/lib/components/code-sandboxes/CodeSandboxPicker.js +155 -21
  9. package/lib/components/code-sandboxes/CodeSandboxUtils.d.ts +3 -0
  10. package/lib/components/code-sandboxes/CodeSandboxUtils.js +62 -11
  11. package/lib/components/code-sandboxes/NewCodeSandboxControls.js +2 -2
  12. package/lib/components/code-sandboxes/codeSandboxProviders.d.ts +29 -0
  13. package/lib/components/code-sandboxes/codeSandboxProviders.js +81 -0
  14. package/lib/components/code-sandboxes/index.d.ts +2 -0
  15. package/lib/components/code-sandboxes/index.js +2 -0
  16. package/lib/examples/AgentspecsExample.js +3 -3
  17. package/lib/identity/index.d.ts +1 -1
  18. package/lib/identity/index.js +1 -1
  19. package/lib/identity/types.d.ts +1 -1
  20. package/lib/identity/types.js +1 -1
  21. package/lib/models/CodeSandboxVariant.d.ts +45 -0
  22. package/lib/models/CodeSandboxVariant.js +65 -0
  23. package/lib/models/index.d.ts +1 -0
  24. package/lib/models/index.js +1 -0
  25. package/lib/runtimes/actions.js +14 -2
  26. package/lib/specs/envvars.d.ts +1 -1
  27. package/lib/specs/envvars.js +3 -3
  28. package/lib/specs/mcpServers.js +2 -2
  29. package/lib/state/substates/RuntimesState.js +4 -1
  30. package/package.json +1 -1
  31. package/scripts/gen_workers.py +1 -1
package/lib/api/index.js CHANGED
@@ -22,9 +22,21 @@ export * as notifications from './notifications';
22
22
  export * as output from './output';
23
23
  export * from '../runtimes';
24
24
  import { iamStore } from '@datalayer/core/lib/state';
25
+ import { runtimesStore } from '../state/substates';
25
26
  import { listEnvironments } from './runtimes/environments';
26
27
  import { listSnapshots, createSnapshot, deleteSnapshot, } from './runtimes/snapshots';
27
28
  import { asCodeSandboxSnapshot, } from '../models/CodeSandboxSnapshot';
29
+ /*
30
+ * The runtimes URL of the PAGE's configuration, not the built-in default.
31
+ *
32
+ * Every function below takes an optional `baseUrl`, and the low-level API
33
+ * layer defaults it to the production URL. Callers all over the application
34
+ * pass nothing — so against a local plane they silently queried production,
35
+ * and the picker offered the environments of the wrong deployment.
36
+ */
37
+ function resolveRuntimesUrl(baseUrl) {
38
+ return baseUrl ?? runtimesStore.getState().runtimesUrl ?? undefined;
39
+ }
28
40
  function resolveToken(token) {
29
41
  const resolved = token ?? iamStore.getState()?.token;
30
42
  if (!resolved) {
@@ -33,14 +45,14 @@ function resolveToken(token) {
33
45
  return resolved;
34
46
  }
35
47
  export async function getEnvironments(token, baseUrl) {
36
- const response = await listEnvironments(resolveToken(token), baseUrl);
48
+ const response = await listEnvironments(resolveToken(token), resolveRuntimesUrl(baseUrl));
37
49
  return (response.environments ?? []).map(environment => ({
38
50
  ...environment,
39
51
  burningRate: environment.burningRate ?? environment.burning_rate,
40
52
  }));
41
53
  }
42
54
  export async function getSandboxSnapshots(token, baseUrl) {
43
- const response = await listSnapshots(resolveToken(token), baseUrl);
55
+ const response = await listSnapshots(resolveToken(token), resolveRuntimesUrl(baseUrl));
44
56
  return (response.snapshots ?? []).map(snapshot => asCodeSandboxSnapshot(snapshot));
45
57
  }
46
58
  export async function createSandboxSnapshot(data, token, baseUrl) {
@@ -49,11 +61,11 @@ export async function createSandboxSnapshot(data, token, baseUrl) {
49
61
  if (!('pod_name' in data)) {
50
62
  return data;
51
63
  }
52
- const response = await createSnapshot(resolveToken(token), data, baseUrl);
64
+ const response = await createSnapshot(resolveToken(token), data, resolveRuntimesUrl(baseUrl));
53
65
  return response.snapshot;
54
66
  }
55
67
  export async function deleteCodeSandboxSnapshot(snapshotId, token, baseUrl) {
56
- return deleteSnapshot(resolveToken(token), snapshotId, baseUrl);
68
+ return deleteSnapshot(resolveToken(token), snapshotId, resolveRuntimesUrl(baseUrl));
57
69
  }
58
70
  export async function exportCodeSandboxSnapshot(..._args) {
59
71
  throw new Error('exportCodeSandboxSnapshot is not implemented in @datalayer/agent-runtimes/api yet.');
@@ -0,0 +1,41 @@
1
+ /** One choice of the dropdown. */
2
+ export type ICodeSandboxEnvironmentOption = {
3
+ /** Identity of the choice, as the host knows it. */
4
+ key: string;
5
+ /** What it is called. */
6
+ title: string;
7
+ /** The environment name, shown as a label beside the title. */
8
+ name?: string;
9
+ /** Who runs it — Datalayer, Kaggle, Modal, the Jupyter Server… */
10
+ providerTitle?: string;
11
+ /** Where it runs: `local`, `remote`, `browser`. */
12
+ location?: string;
13
+ /** Credits per second, when it costs any. */
14
+ burningRate?: number;
15
+ /** The GPU the environment carries, when it carries one. */
16
+ gpu?: string | boolean;
17
+ /** The heading this choice is listed under, when the list is grouped. */
18
+ group?: string;
19
+ };
20
+ export type ICodeSandboxEnvironmentSelectProps = {
21
+ /** The choices, in the order they should read. */
22
+ options: ICodeSandboxEnvironmentOption[];
23
+ /** The `key` of the chosen one, if any. */
24
+ selectedKey?: string;
25
+ /** Called with the `key` of the choice made. */
26
+ onSelect: (key: string) => void;
27
+ /** Whether the control refuses to open. */
28
+ disabled?: boolean;
29
+ /** What the trigger reads when nothing is chosen. */
30
+ placeholder?: string;
31
+ /**
32
+ * Width of the overlay, `xlarge` by default.
33
+ *
34
+ * Wide because the entries carry their labels on the same line as their
35
+ * name, and a priced one — the credits it burns, the environment, the
36
+ * provider, where it runs — is nearly as wide as the row itself.
37
+ */
38
+ overlayWidth?: 'small' | 'medium' | 'large' | 'xlarge' | 'auto';
39
+ };
40
+ export declare function CodeSandboxEnvironmentSelect(props: ICodeSandboxEnvironmentSelectProps): JSX.Element;
41
+ export default CodeSandboxEnvironmentSelect;
@@ -0,0 +1,83 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2025-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * The one dropdown for choosing where code runs.
8
+ *
9
+ * The launcher asks it of a new sandbox and the picker asks it of an editor
10
+ * that needs one; both are the same question, and were drawn twice — a menu
11
+ * here, a column of radio buttons there. This is that control, once: the
12
+ * chosen entry reads on the trigger exactly as the entries read in the list,
13
+ * name to the left and labels to the right, so the closed control and the
14
+ * open one show the same thing.
15
+ *
16
+ * An `ActionMenu` rather than a `<select>`: what tells two entries apart is
17
+ * their labels — the environment name, the provider, the credits it burns —
18
+ * and no `<option>` can carry those.
19
+ *
20
+ * @module components/code-sandboxes/CodeSandboxEnvironmentSelect
21
+ */
22
+ import { ActionList, ActionMenu, Box, Label, Text } from '@primer/react';
23
+ /**
24
+ * The labels of an option, on one line: cost, name, provider, where.
25
+ *
26
+ * The price is the widest of them — "0.0008 credits/second" outruns the
27
+ * name, the provider and the location together — so the room comes from the
28
+ * overlay rather than from a second line: see `overlayWidth`.
29
+ */
30
+ function OptionLabels(props) {
31
+ const { option } = props;
32
+ return (_jsxs(Box, { as: "span", sx: {
33
+ display: 'inline-flex',
34
+ alignItems: 'center',
35
+ gap: 1,
36
+ whiteSpace: 'nowrap',
37
+ }, children: [option.burningRate ? (_jsxs(Label, { size: "small", variant: "sponsors", children: [option.burningRate, " credits/second"] })) : null, option.gpu ? (_jsx(Label, { size: "small", variant: "success", children: "GPU" })) : null, option.name ? _jsx(Label, { size: "small", children: option.name }) : null, option.providerTitle ? (_jsx(Label, { size: "small", variant: "accent", children: option.providerTitle })) : null, option.location ? (_jsx(Label, { size: "small", variant: "secondary", children: option.location })) : null] }));
38
+ }
39
+ export function CodeSandboxEnvironmentSelect(props) {
40
+ const { disabled, onSelect, options, overlayWidth = 'xlarge', placeholder = 'Select an environment', selectedKey, } = props;
41
+ const selected = options.find(option => option.key === selectedKey);
42
+ /*
43
+ * What costs credits leads.
44
+ *
45
+ * The sandboxes of the platform and of its providers are the ones worth
46
+ * choosing between — a kernel of this server is the fallback, always
47
+ * there and free — so they are read first rather than found under a list
48
+ * of local specifications. A stable partition: within each half the order
49
+ * given by the caller is kept.
50
+ */
51
+ const ordered = [
52
+ ...options.filter(option => option.burningRate),
53
+ ...options.filter(option => !option.burningRate),
54
+ ];
55
+ const grouped = ordered.some(option => option.group);
56
+ const groups = grouped
57
+ ? Array.from(new Set(ordered.map(option => option.group ?? '')))
58
+ : [];
59
+ const rows = (subset) => subset.map(option => (_jsxs(ActionList.Item, { selected: option.key === selectedKey, onSelect: () => onSelect(option.key), children: [option.title, _jsx(ActionList.TrailingVisual, { children: _jsx(OptionLabels, { option: option }) })] }, option.key)));
60
+ return (_jsxs(ActionMenu, { children: [_jsx(ActionMenu.Button, { block: true, disabled: disabled || ordered.length === 0,
61
+ // The button centers its text by default; the trigger must read like
62
+ // the rows below it — name left, labels right.
63
+ sx: {
64
+ // The content of a Primer button is a grid whose middle track is
65
+ // content-sized, which hugs the labels against the title. The
66
+ // template areas stay "leadingVisual text trailingVisual": only
67
+ // that track changes, from content-sized to 1fr.
68
+ '& [data-component="buttonContent"]': {
69
+ flex: 1,
70
+ gridTemplateColumns: 'min-content minmax(0, 1fr) min-content',
71
+ },
72
+ '& [data-component="text"]': { width: '100%', textAlign: 'left' },
73
+ }, children: selected ? (_jsxs(Box, { as: "span", sx: {
74
+ display: 'flex',
75
+ width: '100%',
76
+ alignItems: 'center',
77
+ justifyContent: 'space-between',
78
+ gap: 2,
79
+ }, children: [_jsx(Text, { children: selected.title }), _jsx(OptionLabels, { option: selected })] })) : (placeholder) }), _jsx(ActionMenu.Overlay, { width: overlayWidth, children: _jsx(ActionList, { selectionVariant: "single", children: grouped
80
+ ? groups.map(group => (_jsxs(ActionList.Group, { children: [group ? (_jsx(ActionList.GroupHeading, { children: group })) : null, rows(ordered.filter(option => (option.group ?? '') === group))] }, group || 'ungrouped')))
81
+ : rows(ordered) }) })] }));
82
+ }
83
+ export default CodeSandboxEnvironmentSelect;
@@ -46,6 +46,17 @@ export interface ICodeSandboxLauncherProps {
46
46
  * Upgrade subscription URL
47
47
  */
48
48
  upgradeSubscription?: string;
49
+ /**
50
+ * Where "Add credits" navigates.
51
+ *
52
+ * ABSOLUTE, always: a relative route resolves against the CURRENT
53
+ * location — opened from `/…/code-sandboxes`, `usage` landed on
54
+ * `code-sandboxes/usage` and a `:sandboxId` route swallowed it as a
55
+ * sandbox called "usage". The default is the web application's `/plans`
56
+ * (which redirects to the plan overview); a nested surface passes its
57
+ * own (e.g. `/datalayer/jupyter-kernels/usage/plans`).
58
+ */
59
+ usageRoute?: string;
49
60
  /**
50
61
  * Optional submit button label override.
51
62
  */
@@ -5,15 +5,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  */
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { useIsMounted } from 'usehooks-ts';
8
- import { Button, FormControl, Select, Spinner, Text, TextInput, ToggleSwitch, } from '@primer/react';
8
+ import { Button, FormControl, Spinner, Text, TextInput, ToggleSwitch, } from '@primer/react';
9
9
  import { Dialog } from '@primer/react/experimental';
10
10
  import { Box } from '@datalayer/primer-addons';
11
11
  import { useJupyterReactStore } from '@datalayer/jupyter-react';
12
- import { USAGE_ROUTE } from '@datalayer/core/lib/routes';
13
12
  import { useNavigate } from '@datalayer/core/lib/hooks';
14
13
  import { NO_RUNTIME_AVAILABLE_LABEL } from '@datalayer/core/lib/i18n';
14
+ import { isCodeSandboxProviderAvailable } from './codeSandboxProviders';
15
+ import { CodeSandboxVariant, codeSandboxVariantOf, codeSandboxVariantTitle, } from '../../models/CodeSandboxVariant';
16
+ import { CodeSandboxEnvironmentSelect, } from './CodeSandboxEnvironmentSelect';
15
17
  import { getCodeSandboxGivenName, listCodeSandboxGivenNames, nextCodeSandboxGivenName, setCodeSandboxGivenName, } from './CodeSandboxNames';
16
18
  import { iamStore, runtimesStore, useCoreStore, useIAMStore, } from '../../state/substates';
19
+ import { createRuntime as createRuntimeRecord } from '../../runtimes/actions';
17
20
  import { createNotebook, sleep } from '@datalayer/core/lib/utils';
18
21
  import { Markdown } from '@datalayer/core/lib/components/display';
19
22
  import { Timer } from '@datalayer/core/lib/components/progress';
@@ -42,10 +45,20 @@ const NOT_AVAILABLE_RETRIES = 5;
42
45
  * The launcher of a new code sandbox.
43
46
  */
44
47
  export function CodeSandboxLauncher(props) {
45
- const { dialogTitle, kernelSnapshot, manager, onSubmit, markdownParser, sanitizer, upgradeSubscription, submitLabel, startRuntime = true, } = props;
48
+ const { dialogTitle, kernelSnapshot, manager, onSubmit, markdownParser, sanitizer, upgradeSubscription, usageRoute = '/plans', submitLabel, startRuntime = true, } = props;
46
49
  const hasExample = startRuntime === 'with-example';
47
50
  const shouldStartRuntime = startRuntime !== 'defer';
48
51
  const user = iamStore.getState().user;
52
+ /*
53
+ * Where a sandbox may be launched: every provider that can be reached.
54
+ *
55
+ * The environments of the platform are offered wherever the account can
56
+ * reach it — withholding them inside JupyterLab left `ai-agents-env` off
57
+ * the list and no way to launch a remote sandbox from there. The Jupyter
58
+ * Server is the provider that depends on where this runs, and it is gated
59
+ * below.
60
+ */
61
+ const jupyterAvailable = isCodeSandboxProviderAvailable('local', runtimesStore.getState().multiServiceManager);
49
62
  const environments = manager.environments.get();
50
63
  /*
51
64
  * The sandboxes of this Jupyter Server, offered beside those of the platform.
@@ -57,6 +70,16 @@ export function CodeSandboxLauncher(props) {
57
70
  * prefixed, since a kernelspec and an environment are free to share a name.
58
71
  */
59
72
  const localSpecs = useMemo(() => {
73
+ /*
74
+ * ...and only inside JupyterLab.
75
+ *
76
+ * The rule cuts both ways: the web application talks to no Jupyter Server
77
+ * of its own, so a kernelspec of one is not something it can start — the
78
+ * entries read as choices that lead nowhere.
79
+ */
80
+ if (!jupyterAvailable) {
81
+ return [];
82
+ }
60
83
  const specs = runtimesStore.getState().multiServiceManager?.local?.kernelspecs.specs
61
84
  ?.kernelspecs ?? {};
62
85
  return Object.values(specs)
@@ -66,8 +89,29 @@ export function CodeSandboxLauncher(props) {
66
89
  title: spec.display_name,
67
90
  language: spec.language,
68
91
  }));
69
- }, []);
92
+ }, [jupyterAvailable]);
70
93
  const LOCAL_PREFIX = 'local:';
94
+ /*
95
+ * The choices of the dropdown: what the platform and the external
96
+ * providers offer, then the kernelspecs of this Jupyter Server. Each says
97
+ * whose machine it is — two entries called "GPU" are told apart by nothing
98
+ * else.
99
+ */
100
+ const environmentOptions = useMemo(() => [
101
+ ...environments.map(spec => ({
102
+ key: spec.name,
103
+ title: spec.title || spec.name,
104
+ name: spec.name,
105
+ providerTitle: codeSandboxVariantTitle(codeSandboxVariantOf(spec?.owner)),
106
+ burningRate: spec.burning_rate,
107
+ })),
108
+ ...localSpecs.map(spec => ({
109
+ key: `${LOCAL_PREFIX}${spec.name}`,
110
+ title: spec.title || spec.name,
111
+ name: spec.name,
112
+ providerTitle: codeSandboxVariantTitle(CodeSandboxVariant.JupyterServer),
113
+ })),
114
+ ], [environments, localSpecs]);
71
115
  const localSpecOf = (value) => value.startsWith(LOCAL_PREFIX)
72
116
  ? localSpecs.find(spec => spec.name === value.slice(LOCAL_PREFIX.length))
73
117
  : undefined;
@@ -85,6 +129,18 @@ export function CodeSandboxLauncher(props) {
85
129
  const jupyterReactStore = useJupyterReactStore();
86
130
  const jupyterLabAdapter = jupyterReactStore.jupyterLabAdapter;
87
131
  const [selection, setSelection] = useState((kernelSnapshot?.environment || environments[0]?.name) ?? '');
132
+ /*
133
+ * Something is always selected.
134
+ *
135
+ * With the environments of the platform withheld, the first choice of the
136
+ * list is a sandbox of this server — and nothing was selected at all, so the
137
+ * launcher opened on an empty dropdown that could not be submitted.
138
+ */
139
+ useEffect(() => {
140
+ if (!selection && localSpecs.length) {
141
+ setSelection(`${LOCAL_PREFIX}${localSpecs[0].name}`);
142
+ }
143
+ }, [localSpecs, selection]);
88
144
  const [timeLimit, setTimeLimit] = useState(10);
89
145
  /*
90
146
  * The name the sandbox is given, and what is offered for it.
@@ -125,12 +181,11 @@ export function CodeSandboxLauncher(props) {
125
181
  const allowance = useNewCodeSandboxAllowance(burningRate);
126
182
  const effectiveMaxMinutes = allowance.effectiveMaxMinutes;
127
183
  const outOfCredits = shouldStartRuntime && allowance.outOfCredits;
128
- const handleSelectionChange = useCallback((e) => {
129
- const selection = e.target.value;
130
- setSelection(selection);
184
+ const handleSelectionChange = useCallback((value) => {
185
+ setSelection(value);
131
186
  if (!hasCustomRuntimeName) {
132
- const local = localSpecOf(selection);
133
- setRuntimeName(givenNameFor(local ?? environments.find(env => env.name === selection)));
187
+ const local = localSpecOf(value);
188
+ setRuntimeName(givenNameFor(local ?? environments.find(env => env.name === value)));
134
189
  }
135
190
  }, [environments, givenNameFor, setSelection, hasCustomRuntimeName]);
136
191
  const handleSubmitRuntime = useCallback(async () => {
@@ -191,7 +246,36 @@ export function CodeSandboxLauncher(props) {
191
246
  desc.params['capabilities'] = ['user_storage'];
192
247
  }
193
248
  let success = true;
194
- if (shouldStartRuntime) {
249
+ const provider = codeSandboxVariantOf(spec?.owner);
250
+ if (shouldStartRuntime && provider !== CodeSandboxVariant.Datalayer) {
251
+ /*
252
+ * An EXTERNAL environment — Kaggle, Modal. The platform RECORDS such
253
+ * a sandbox rather than running it: the provider runs it under the
254
+ * user's own credentials, and what comes back is a record with no
255
+ * pod, no ingress and no kernel. Waiting for one, as the branch
256
+ * below does, would time out and then delete the record as broken.
257
+ */
258
+ success = false;
259
+ try {
260
+ await createRuntimeRecord({
261
+ environmentName: selection,
262
+ type: 'notebook',
263
+ givenName: runtimeName,
264
+ creditsLimit,
265
+ });
266
+ await manager.runtimesManager.refresh();
267
+ success = true;
268
+ }
269
+ catch (error) {
270
+ console.error('Failed to record the external sandbox.', error);
271
+ setFlashLevel('danger');
272
+ setError(_jsxs(Text, { children: ["The ", codeSandboxVariantTitle(provider), " sandbox could not be created."] }));
273
+ }
274
+ finally {
275
+ setWaitingForRuntime(false);
276
+ }
277
+ }
278
+ else if (shouldStartRuntime) {
195
279
  success = false;
196
280
  let availableTrial = 1;
197
281
  let retryDelay = NOT_AVAILABLE_INIT_RETRY;
@@ -334,7 +418,7 @@ export function CodeSandboxLauncher(props) {
334
418
  event.preventDefault();
335
419
  handleSubmitRuntime();
336
420
  }
337
- }, children: [_jsxs(FormControl, { disabled: !!kernelSnapshot?.environment || environments.length === 0, children: [_jsx(FormControl.Label, { children: "Environment" }), _jsxs(Select, { name: "environment", disabled: !!kernelSnapshot?.environment || environments.length === 0, value: selection, onChange: handleSelectionChange, block: true, children: [environments.map(spec => (_jsxs(Select.Option, { value: spec.name, children: [spec.name, spec.title && (_jsxs(_Fragment, { children: [' - ', spec.title] }))] }, spec.name))), localSpecs.map(spec => (_jsxs(Select.Option, { value: `${LOCAL_PREFIX}${spec.name}`, children: [spec.name, spec.title && _jsx(_Fragment, { children: ` - ${spec.title}` }), ' - Jupyter Server'] }, `${LOCAL_PREFIX}${spec.name}`)))] }), _jsx(FormControl.Caption, { children: _jsx(_Fragment, { children: markdownParser ? (_jsx(Box, { sx: { img: { maxWidth: '100%' } }, children: _jsx(Markdown, { text: description, markdownParser: markdownParser, sanitizer: sanitizer }) })) : sanitizer ? (
421
+ }, children: [_jsxs(FormControl, { disabled: !!kernelSnapshot?.environment || environments.length === 0, children: [_jsx(FormControl.Label, { children: "Environment" }), _jsx(CodeSandboxEnvironmentSelect, { disabled: !!kernelSnapshot?.environment || environments.length === 0, options: environmentOptions, selectedKey: selection, onSelect: handleSelectionChange }), _jsx(FormControl.Caption, { children: _jsx(_Fragment, { children: markdownParser ? (_jsx(Box, { sx: { img: { maxWidth: '100%' } }, children: _jsx(Markdown, { text: description, markdownParser: markdownParser, sanitizer: sanitizer }) })) : sanitizer ? (
338
422
  // The description of an environment carries markup. With no
339
423
  // parser to render it, the sanitizer of the application is
340
424
  // enough to show it as what it is rather than as its tags.
@@ -351,7 +435,7 @@ export function CodeSandboxLauncher(props) {
351
435
  // no time to reserve and no storage of the platform to mount.
352
436
  withReservation: !!startRuntime && !isLocalSelection, withUserStorage: !isLocalSelection, burningRate: burningRate, timeLimit: timeLimit, onTimeChange: setTimeLimit, max: effectiveMaxMinutes, disabled: outOfCredits, error: outOfCredits ? 'You must add credits to your account.' : undefined, addCredits: navigate
353
437
  ? () => {
354
- navigate(USAGE_ROUTE);
438
+ navigate(usageRoute);
355
439
  }
356
440
  : undefined, userStorage: userStorage, onUserStorageToggle: () => setUserStorage(current => !current) }), _jsxs(FormControl, { sx: { paddingTop: '10px' }, children: [_jsx(FormControl.Label, { children: "Given Name" }), _jsx(TextInput, { name: "name", value: runtimeName, onChange: handleKernelNameChange, block: true })] }), hasExample &&
357
441
  jupyterLabAdapter?.jupyterLab &&
@@ -32,25 +32,56 @@
32
32
  */
33
33
  /** Where the names are kept, and under which key. */
34
34
  const STORAGE_KEY = 'datalayer:code-sandbox-names';
35
- /** The names, by kernel identifier, as they were last written. */
36
- let names;
37
- function load() {
38
- if (names) {
39
- return names;
40
- }
35
+ /** When each name was written, so pruning can spare what is brand new. */
36
+ const STAMP_KEY = 'datalayer:code-sandbox-names:at';
37
+ /**
38
+ * How long a fresh name is safe from pruning.
39
+ *
40
+ * Pruning compares the names against a list of running kernels, and that list
41
+ * is a POLL: a sweep that was in flight while a sandbox was created and named
42
+ * finishes with a list from before it existed, and deleted the name the user
43
+ * had just typed — every surface then agreed on the renumbered default. A
44
+ * kernel identifier is never reused, so sparing a young name costs nothing:
45
+ * a truly dead one is collected on the sweeps after the grace.
46
+ */
47
+ const PRUNE_GRACE_MS = 10 * 60 * 1000;
48
+ /** The fallback for a browser that refuses its storage: this page only. */
49
+ let memory = {};
50
+ let memoryStamps = {};
51
+ function read(key) {
41
52
  try {
42
- const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
43
- names = raw ? JSON.parse(raw) : {};
53
+ const raw = globalThis.localStorage?.getItem(key);
54
+ return raw ? JSON.parse(raw) : {};
44
55
  }
45
56
  catch {
46
- // A browser that refuses its storage keeps the names for this page only.
47
- names = {};
57
+ return undefined;
48
58
  }
49
- return names;
50
59
  }
51
- function save() {
60
+ /*
61
+ * Read on every call, never cached in the module.
62
+ *
63
+ * A cached copy is a second truth: another instance of this module — another
64
+ * tab, another bundle holding its own copy — writes the storage, the cache
65
+ * here goes stale, and the next save writes the stale cache back over what
66
+ * the other instance stored. The maps are a handful of entries; reading them
67
+ * each time is nothing.
68
+ */
69
+ function load() {
70
+ const stored = read(STORAGE_KEY);
71
+ return stored === undefined ? memory : stored;
72
+ }
73
+ function loadStamps() {
74
+ const stored = read(STAMP_KEY);
75
+ return stored === undefined
76
+ ? memoryStamps
77
+ : stored;
78
+ }
79
+ function save(names, stamps) {
80
+ memory = names;
81
+ memoryStamps = stamps;
52
82
  try {
53
- globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(load()));
83
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(names));
84
+ globalThis.localStorage?.setItem(STAMP_KEY, JSON.stringify(stamps));
54
85
  }
55
86
  catch {
56
87
  /* the names stay in memory */
@@ -71,14 +102,17 @@ export function getCodeSandboxGivenName(kernelId) {
71
102
  * @param givenName What to call it; empty forgets the name
72
103
  */
73
104
  export function setCodeSandboxGivenName(kernelId, givenName) {
74
- const stored = load();
105
+ const names = load();
106
+ const stamps = loadStamps();
75
107
  if (givenName) {
76
- stored[kernelId] = asGivenName(givenName);
108
+ names[kernelId] = asGivenName(givenName);
109
+ stamps[kernelId] = Date.now();
77
110
  }
78
111
  else {
79
- delete stored[kernelId];
112
+ delete names[kernelId];
113
+ delete stamps[kernelId];
80
114
  }
81
- save();
115
+ save(names, stamps);
82
116
  }
83
117
  /**
84
118
  * Drop the names of the sandboxes that are no longer running.
@@ -87,16 +121,27 @@ export function setCodeSandboxGivenName(kernelId, givenName) {
87
121
  */
88
122
  export function pruneCodeSandboxGivenNames(runningKernelIds) {
89
123
  const alive = new Set(runningKernelIds);
90
- const stored = load();
124
+ const names = load();
125
+ const stamps = loadStamps();
126
+ const now = Date.now();
91
127
  let dropped = false;
92
- Object.keys(stored).forEach(kernelId => {
93
- if (!alive.has(kernelId)) {
94
- delete stored[kernelId];
95
- dropped = true;
128
+ Object.keys(names).forEach(kernelId => {
129
+ if (alive.has(kernelId)) {
130
+ return;
131
+ }
132
+ // Not in the list is not proof of death: the list is a poll, and a
133
+ // sandbox named a moment ago may not be in it yet. The young are spared;
134
+ // see PRUNE_GRACE_MS.
135
+ const namedAt = stamps[kernelId];
136
+ if (typeof namedAt === 'number' && now - namedAt < PRUNE_GRACE_MS) {
137
+ return;
96
138
  }
139
+ delete names[kernelId];
140
+ delete stamps[kernelId];
141
+ dropped = true;
97
142
  });
98
143
  if (dropped) {
99
- save();
144
+ save(names, stamps);
100
145
  }
101
146
  }
102
147
  /**
@@ -4,7 +4,7 @@ import { ITranslator } from '@jupyterlab/translation';
4
4
  import { IRuntimeDesc } from '../../models';
5
5
  import { type IMultiServiceManager } from '../../runtimes';
6
6
  import type { CodeSandboxTransfer } from './CodeSandboxTransfer';
7
- type IDisplayMode = 'menu' | 'radio';
7
+ type IDisplayMode = 'menu' | 'radio' | 'dropdown';
8
8
  /**
9
9
  * {@link CodeSandboxPicker} properties
10
10
  */
@@ -13,6 +13,19 @@ export interface ICodeSandboxPickerProps {
13
13
  * The display mode.
14
14
  */
15
15
  display: IDisplayMode;
16
+ /**
17
+ * Where the sandboxes that ALREADY RUN are offered, in the `dropdown`
18
+ * display.
19
+ *
20
+ * `separated`, the default: as radio buttons of their own, above a
21
+ * dropdown that then offers only the environments — so the dropdown reads
22
+ * as "start a new one" and the running sandboxes are all in sight, which
23
+ * is what picking one of them is usually about.
24
+ *
25
+ * `combined`: everything in the one dropdown, running sandboxes and
26
+ * environments alike, under their headings.
27
+ */
28
+ sandboxLayout?: 'separated' | 'combined';
16
29
  /**
17
30
  * Additional actions items to be placed at the top of the picker
18
31
  */