@columenlabs/lumen-ui 0.1.197 → 0.1.199
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.
- package/dist/browser-runner.d.ts +28 -0
- package/dist/browser-runner.js +126 -0
- package/dist/frame-script.js +22 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/workspaces-config.d.ts +3 -1
- package/dist/workspaces.js +89 -30
- package/package.json +2 -2
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a frame's browser recipe (a script with `runtime: 'browser'`) in the person's page and
|
|
3
|
+
* saves what it gives as the frame's rows: one write. The script runs in a Web Worker inside an
|
|
4
|
+
* iframe with `sandbox="allow-scripts"` (the server's `<api>/sandbox` page: an opaque origin, no
|
|
5
|
+
* cookies, no network of its own), so it cannot reach the app or its data. Its one way out is
|
|
6
|
+
* `hub.fetch(url, {method, body, key})`, which this file carries to the app's fetch proxy
|
|
7
|
+
* (`<api>/fetch`: allowlisted hosts, a named data key added server side) and back.
|
|
8
|
+
*
|
|
9
|
+
* The script is the body of an async function of `fetched` (the answers to its `requests`, in
|
|
10
|
+
* order: `{url, vars, status, body}`) and `hub`. It returns rows, or `{columns?, rows}` where
|
|
11
|
+
* `columns` picks and orders the fields. Limits: `BROWSER_RUN_MS`, `FETCH_CALLS` web reads a run
|
|
12
|
+
* and the frame's own caps (rows, size), which the server checks when it saves.
|
|
13
|
+
*/
|
|
14
|
+
import { type FrameData, type FrameScript } from '@columenlabs/lumen-ui-types/frames';
|
|
15
|
+
/** Longest a browser run may take before it stops (the worker is ended, a loop included). */
|
|
16
|
+
export declare const BROWSER_RUN_MS = 30000;
|
|
17
|
+
/**
|
|
18
|
+
* Runs a browser script once and gives back its rows, or throws why it failed. Each web read it
|
|
19
|
+
* makes goes through `<api>/fetch`; more than `FETCH_CALLS` a run is refused.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runBrowserScript(api: string, script: Extract<FrameScript, {
|
|
22
|
+
kind: 'script';
|
|
23
|
+
}>, signal?: AbortSignal): Promise<Record<string, unknown>[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Runs a frame's browser recipe and saves the rows (or why it failed) on the frame: the ↻ of a
|
|
26
|
+
* browser tab. Gives back the frame as saved; a failed run throws after it is recorded.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runBrowserFrame(api: string, id: string): Promise<FrameData>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a frame's browser recipe (a script with `runtime: 'browser'`) in the person's page and
|
|
3
|
+
* saves what it gives as the frame's rows: one write. The script runs in a Web Worker inside an
|
|
4
|
+
* iframe with `sandbox="allow-scripts"` (the server's `<api>/sandbox` page: an opaque origin, no
|
|
5
|
+
* cookies, no network of its own), so it cannot reach the app or its data. Its one way out is
|
|
6
|
+
* `hub.fetch(url, {method, body, key})`, which this file carries to the app's fetch proxy
|
|
7
|
+
* (`<api>/fetch`: allowlisted hosts, a named data key added server side) and back.
|
|
8
|
+
*
|
|
9
|
+
* The script is the body of an async function of `fetched` (the answers to its `requests`, in
|
|
10
|
+
* order: `{url, vars, status, body}`) and `hub`. It returns rows, or `{columns?, rows}` where
|
|
11
|
+
* `columns` picks and orders the fields. Limits: `BROWSER_RUN_MS`, `FETCH_CALLS` web reads a run
|
|
12
|
+
* and the frame's own caps (rows, size), which the server checks when it saves.
|
|
13
|
+
*/
|
|
14
|
+
import { FETCH_CALLS, fetchCalls, runsInBrowser, } from '@columenlabs/lumen-ui-types/frames';
|
|
15
|
+
import { apiJson } from './api-fetch.js';
|
|
16
|
+
/** Longest a browser run may take before it stops (the worker is ended, a loop included). */
|
|
17
|
+
export const BROWSER_RUN_MS = 30_000;
|
|
18
|
+
/** What the script gave, as rows; a bad shape says what it should be. */
|
|
19
|
+
function rowsOf(out) {
|
|
20
|
+
const o = out;
|
|
21
|
+
const list = Array.isArray(out) ? out : Array.isArray(o?.rows) ? o.rows : null;
|
|
22
|
+
if (!list)
|
|
23
|
+
throw new Error('The script must return rows: a list, or {columns, rows}.');
|
|
24
|
+
const rows = list.map((r) => r && typeof r === 'object' && !Array.isArray(r)
|
|
25
|
+
? r
|
|
26
|
+
: { value: r });
|
|
27
|
+
const columns = Array.isArray(o?.columns) ? o.columns.map(String) : null;
|
|
28
|
+
return columns
|
|
29
|
+
? rows.map((r) => Object.fromEntries(columns.map((c) => [c, r[c] ?? null])))
|
|
30
|
+
: rows;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs a browser script once and gives back its rows, or throws why it failed. Each web read it
|
|
34
|
+
* makes goes through `<api>/fetch`; more than `FETCH_CALLS` a run is refused.
|
|
35
|
+
*/
|
|
36
|
+
export async function runBrowserScript(api, script, signal) {
|
|
37
|
+
let reads = 0;
|
|
38
|
+
const read = async (call) => {
|
|
39
|
+
if (++reads > FETCH_CALLS)
|
|
40
|
+
throw new Error(`A run makes at most ${FETCH_CALLS} web reads.`);
|
|
41
|
+
return apiJson(`${api}/fetch`, { method: 'POST', body: JSON.stringify(call), signal }, 'workspace.fetch');
|
|
42
|
+
};
|
|
43
|
+
// The recipe's own requests first, so the script starts with their answers.
|
|
44
|
+
const fetched = script.requests
|
|
45
|
+
? await Promise.all(fetchCalls(script.requests).map(async (c) => {
|
|
46
|
+
const a = await read({ url: c.url, method: c.method, body: c.body, key: c.key });
|
|
47
|
+
return { url: c.url, vars: c.vars, status: a.status, body: a.body };
|
|
48
|
+
}))
|
|
49
|
+
: [];
|
|
50
|
+
const frame = document.createElement('iframe');
|
|
51
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
52
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
53
|
+
frame.tabIndex = -1;
|
|
54
|
+
frame.hidden = true;
|
|
55
|
+
frame.src = `${api}/sandbox`;
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finish = (fn) => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
window.removeEventListener('message', onMessage);
|
|
64
|
+
signal?.removeEventListener('abort', onAbort);
|
|
65
|
+
frame.remove();
|
|
66
|
+
fn();
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`The script took over ${BROWSER_RUN_MS / 1000} s, so it stopped.`))), BROWSER_RUN_MS);
|
|
69
|
+
const onAbort = () => finish(() => reject(new DOMException('Stopped', 'AbortError')));
|
|
70
|
+
signal?.addEventListener('abort', onAbort);
|
|
71
|
+
const post = (m) => frame.contentWindow?.postMessage(m, '*');
|
|
72
|
+
function onMessage(e) {
|
|
73
|
+
// Only this run's own frame speaks here; its origin is opaque ("null").
|
|
74
|
+
if (e.source !== frame.contentWindow)
|
|
75
|
+
return;
|
|
76
|
+
const m = (e.data ?? {});
|
|
77
|
+
if (m.type === 'ready')
|
|
78
|
+
post({ type: 'run', code: script.code, fetched });
|
|
79
|
+
else if (m.type === 'fetch') {
|
|
80
|
+
const init = m.init ?? {};
|
|
81
|
+
read({
|
|
82
|
+
url: String(m.url),
|
|
83
|
+
method: init.method === 'POST' ? 'POST' : 'GET',
|
|
84
|
+
...(typeof init.body === 'string' ? { body: init.body } : {}),
|
|
85
|
+
...(typeof init.key === 'string' ? { key: init.key } : {}),
|
|
86
|
+
}).then((result) => post({ type: 'fetched', id: m.id, result }), (err) => post({ type: 'fetched', id: m.id, error: err.message }));
|
|
87
|
+
}
|
|
88
|
+
else if (m.type === 'done') {
|
|
89
|
+
if (m.error)
|
|
90
|
+
finish(() => reject(new Error(m.error)));
|
|
91
|
+
else
|
|
92
|
+
finish(() => {
|
|
93
|
+
try {
|
|
94
|
+
resolve(rowsOf(m.result));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
reject(err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
window.addEventListener('message', onMessage);
|
|
103
|
+
document.body.appendChild(frame);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Runs a frame's browser recipe and saves the rows (or why it failed) on the frame: the ↻ of a
|
|
108
|
+
* browser tab. Gives back the frame as saved; a failed run throws after it is recorded.
|
|
109
|
+
*/
|
|
110
|
+
export async function runBrowserFrame(api, id) {
|
|
111
|
+
const { script } = await apiJson(`${api}/frames/${id}/script`, {}, 'workspace.script');
|
|
112
|
+
if (!script || !runsInBrowser(script) || script.kind !== 'script')
|
|
113
|
+
throw new Error('This tab has no browser script to run.');
|
|
114
|
+
const started = performance.now();
|
|
115
|
+
const save = (body) => apiJson(`${api}/frames/${id}/rows`, { method: 'PUT', body: JSON.stringify(body) }, 'workspace.rows');
|
|
116
|
+
let rows;
|
|
117
|
+
try {
|
|
118
|
+
rows = await runBrowserScript(api, script);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const error = err.message || 'The script failed.';
|
|
122
|
+
await save({ error, ms: Math.round(performance.now() - started) }).catch(() => undefined);
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
return save({ rows, ms: Math.round(performance.now() - started) });
|
|
126
|
+
}
|
package/dist/frame-script.js
CHANGED
|
@@ -4,6 +4,7 @@ import { PlayIcon, RotateCcwIcon, WrapTextIcon } from 'lucide-react';
|
|
|
4
4
|
import { useEffect, useMemo, useState } from 'react';
|
|
5
5
|
import { apiJson } from './api-fetch.js';
|
|
6
6
|
import { useAssistantName } from './assistant-context.js';
|
|
7
|
+
import { runBrowserFrame } from './browser-runner.js';
|
|
7
8
|
// A frame's Script editor: the one script that makes an assistant's table (a call as
|
|
8
9
|
// JSON, SQL, or JavaScript), as code the person reads and edits, in a dialog off the card's signal
|
|
9
10
|
// dot or ⋯ menu. Run and save runs the edited script once as the person, with the read tools the
|
|
@@ -25,12 +26,17 @@ function scriptText(s) {
|
|
|
25
26
|
return { text: s.code, lang: 'js' };
|
|
26
27
|
if (s.kind === 'sql')
|
|
27
28
|
return { text: s.query, lang: 'sql' };
|
|
29
|
+
if (s.kind === 'fetch') {
|
|
30
|
+
const { kind: _k, ...reads } = s;
|
|
31
|
+
return { text: JSON.stringify(reads, null, 2), lang: 'json' };
|
|
32
|
+
}
|
|
28
33
|
return { text: JSON.stringify({ fn: s.fn, args: s.args }, null, 2), lang: 'json' };
|
|
29
34
|
}
|
|
30
35
|
/** The edited text back as a script; a call that is not JSON says where. */
|
|
31
36
|
function parseScript(was, text) {
|
|
37
|
+
// A browser script keeps where it runs and the reads it starts with.
|
|
32
38
|
if (was.kind === 'script')
|
|
33
|
-
return {
|
|
39
|
+
return { ...was, code: text };
|
|
34
40
|
if (was.kind === 'sql')
|
|
35
41
|
return { kind: 'sql', query: text };
|
|
36
42
|
let v;
|
|
@@ -38,9 +44,11 @@ function parseScript(was, text) {
|
|
|
38
44
|
v = JSON.parse(text);
|
|
39
45
|
}
|
|
40
46
|
catch (e) {
|
|
41
|
-
throw new Error(`The
|
|
47
|
+
throw new Error(`The recipe is not valid JSON: ${e.message}`);
|
|
42
48
|
}
|
|
43
49
|
const o = (v ?? {});
|
|
50
|
+
if (was.kind === 'fetch')
|
|
51
|
+
return { ...o, kind: 'fetch' };
|
|
44
52
|
return {
|
|
45
53
|
kind: 'call',
|
|
46
54
|
fn: String(o.fn ?? ''),
|
|
@@ -69,11 +77,15 @@ export function FrameScriptDialog({ api, frame, name, runs, open, onOpenChange,
|
|
|
69
77
|
setError(null);
|
|
70
78
|
}, [open, original]);
|
|
71
79
|
const save = useMutation({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
// A browser script is kept first (the server runs no code), then run here and its rows saved.
|
|
81
|
+
mutationFn: async (next) => {
|
|
82
|
+
const kept = await json(`${api}/frames/${frame}/script`, {
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ script: next }),
|
|
86
|
+
});
|
|
87
|
+
return kept.meta.browser ? runBrowserFrame(api, frame) : kept;
|
|
88
|
+
},
|
|
77
89
|
onSuccess: (data) => {
|
|
78
90
|
qc.setQueryData(frameKey(api, frame), data);
|
|
79
91
|
void qc.invalidateQueries({ queryKey: [...frameKey(api, frame), 'script'] });
|
|
@@ -96,7 +108,9 @@ export function FrameScriptDialog({ api, frame, name, runs, open, onOpenChange,
|
|
|
96
108
|
setError(e.message);
|
|
97
109
|
}
|
|
98
110
|
};
|
|
99
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "flex h-[min(44rem,calc(100dvh-2rem))] flex-col sm:max-w-4xl", children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { children: ["Script \u00B7 ", name] }),
|
|
111
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "flex h-[min(44rem,calc(100dvh-2rem))] flex-col sm:max-w-4xl", children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { children: ["Script \u00B7 ", name] }), _jsx(DialogDescription, { children: script?.kind === 'script' && script.runtime === 'browser'
|
|
112
|
+
? 'Runs in your browser, in a sandbox, to make these rows.'
|
|
113
|
+
: `What ${assistant} runs to make these rows. Saved only if it runs.` })] }), read.isPending ? (_jsx(Skeleton, { className: "min-h-0 flex-1" })) : read.error ? (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertTitle, { children: "Couldn't read the script" }), _jsx(AlertDescription, { children: read.error.message })] })) : !script ? (_jsxs(Alert, { children: [_jsx(AlertTitle, { children: "No script" }), _jsxs(AlertDescription, { children: [assistant, " wrote these rows once: nothing runs to make them."] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-end gap-1", children: [lang && (_jsx(Badge, { variant: "secondary", className: "font-mono", children: lang })), _jsx(IconButton, { icon: WrapTextIcon, label: wrap ? 'Stop wrapping lines' : 'Wrap long lines', pressed: wrap, onClick: () => setWrap(!wrap) })] }), _jsx(LumenCodeEditor, { className: "flex-1", value: text, lang: lang, wrap: wrap, onChange: setText }), runs && runs.length > 0 && _jsx(RunList, { runs: runs, assistant: assistant }), error && (_jsxs(Alert, { variant: "destructive", className: "max-h-32 overflow-y-auto", children: [_jsx(AlertTitle, { children: "Didn't save" }), _jsx(AlertDescription, { className: "break-words font-mono text-xs", children: error })] }))] })), _jsxs(DialogFooter, { children: [_jsx(IconButton, { icon: RotateCcwIcon, label: "Undo my changes", disabled: !changed || save.isPending, onClick: () => {
|
|
100
114
|
setText(original);
|
|
101
115
|
setError(null);
|
|
102
116
|
} }), _jsxs(Button, { disabled: !script || save.isPending, onClick: run, children: [save.isPending ? _jsx(Spinner, {}) : _jsx(PlayIcon, {}), " Run and save"] })] })] }) }));
|
package/dist/index.d.ts
CHANGED
|
@@ -105,6 +105,7 @@ export { type AppViewState, useAppView } from './app-views.js';
|
|
|
105
105
|
export { type ScreenApi, type ScreenMap, type ScreenOutcome, type ScreenStatus, type WorkspaceOnScreen, type AssistantMove, type AssistantMoveHandlers, type AssistantPage, type AssistantPageType, AssistantScreenProvider, screenMapFor, useAssistantMoves, useAssistantPages, useAssistantScreen, useAssistantWorkspace, } from './assistant-screen.js';
|
|
106
106
|
export { type AssistantActions, AssistantContext, type AssistantNote, type AssistantRef, type AssistantSlot, useAssistant, AssistantPendingContext, useAssistantName, useAssistantNote, useAssistantPending, } from './assistant-context.js';
|
|
107
107
|
export { WORKSPACES_KEY, frameKey, workspacesListQuery } from './workspace-keys.js';
|
|
108
|
+
export { BROWSER_RUN_MS, runBrowserFrame, runBrowserScript } from './browser-runner.js';
|
|
108
109
|
export { type WorkspaceApp, type WorkspaceDraftProps, WorkspacePage, type WorkspaceRecipe, type WorkspacesConfig, WorkspacesProvider, WorkspacesSection, } from './workspaces.js';
|
|
109
110
|
export { CHART_INDICATORS, type ChartIndicator, type IndicatorValues, LINE_COLOR, computeIndicators, formulaIndicator, lineColor, resolveIndicator, runFormula, } from './market/indicators.js';
|
|
110
111
|
export { IndicatorPicker } from './market/indicator-picker.js';
|
package/dist/index.js
CHANGED
|
@@ -112,6 +112,7 @@ export { useAppView } from './app-views.js';
|
|
|
112
112
|
export { AssistantScreenProvider, screenMapFor, useAssistantMoves, useAssistantPages, useAssistantScreen, useAssistantWorkspace, } from './assistant-screen.js';
|
|
113
113
|
export { AssistantContext, useAssistant, AssistantPendingContext, useAssistantName, useAssistantNote, useAssistantPending, } from './assistant-context.js';
|
|
114
114
|
export { WORKSPACES_KEY, frameKey, workspacesListQuery } from './workspace-keys.js';
|
|
115
|
+
export { BROWSER_RUN_MS, runBrowserFrame, runBrowserScript } from './browser-runner.js';
|
|
115
116
|
export { WorkspacePage, WorkspacesProvider, WorkspacesSection, } from './workspaces.js';
|
|
116
117
|
export { CHART_INDICATORS, LINE_COLOR, computeIndicators, formulaIndicator, lineColor, resolveIndicator, runFormula, } from './market/indicators.js';
|
|
117
118
|
export { IndicatorPicker } from './market/indicator-picker.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FrameScript } from '@columenlabs/lumen-ui-types/frames';
|
|
2
|
-
import type { CustomViewConfig } from '@columenlabs/lumen-ui-types/views';
|
|
2
|
+
import type { CustomViewConfig, ViewFormula } from '@columenlabs/lumen-ui-types/views';
|
|
3
3
|
import { type ComponentType } from 'react';
|
|
4
4
|
type Icon = ComponentType<{
|
|
5
5
|
className?: string;
|
|
@@ -38,6 +38,8 @@ export interface WorkspaceRecipe {
|
|
|
38
38
|
group?: string;
|
|
39
39
|
icon?: Icon;
|
|
40
40
|
recipe: FrameScript;
|
|
41
|
+
/** Formula columns the tab's layout starts with (dew point from temperature and humidity). */
|
|
42
|
+
formulas?: ViewFormula[];
|
|
41
43
|
}
|
|
42
44
|
/** The product's apps and domains for its workspaces. */
|
|
43
45
|
export declare const WorkspacesProvider: import("react").Provider<WorkspacesConfig>;
|
package/dist/workspaces.js
CHANGED
|
@@ -4,11 +4,12 @@ import { customViewConfigSchema, frameSpace, } from '@columenlabs/lumen-ui-types
|
|
|
4
4
|
import { workspacePath, } from '@columenlabs/lumen-ui-types/workspaces';
|
|
5
5
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
6
6
|
import { useNavigate, useParams, useSearch } from '@tanstack/react-router';
|
|
7
|
-
import { ArrowRightLeftIcon, CalendarIcon, ExternalLinkIcon, HashIcon, LayersIcon, PencilIcon, PlusIcon, RefreshCwIcon, SparklesIcon, TableIcon, TextCursorInputIcon, Trash2Icon, TypeIcon, } from 'lucide-react';
|
|
8
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
7
|
+
import { ArrowRightLeftIcon, CalendarIcon, CodeIcon, ExternalLinkIcon, FileSpreadsheetIcon, HashIcon, LayersIcon, PencilIcon, PlusIcon, RefreshCwIcon, SparklesIcon, TableIcon, TextCursorInputIcon, Trash2Icon, TypeIcon, WorkflowIcon, } from 'lucide-react';
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { apiJson } from './api-fetch.js';
|
|
10
10
|
import { useAssistant, useAssistantName, useAssistantPending } from './assistant-context.js';
|
|
11
11
|
import { useAssistantWorkspace } from './assistant-screen.js';
|
|
12
|
+
import { runBrowserFrame } from './browser-runner.js';
|
|
12
13
|
import { LumenConfirm } from './confirm.js';
|
|
13
14
|
import { FrameScriptDialog } from './frame-script.js';
|
|
14
15
|
import { editedCells, editedKey, frameColor, useFrameEdits } from './joins.js';
|
|
@@ -209,7 +210,11 @@ function WorkspaceBody({ api, w, tab, draft, }) {
|
|
|
209
210
|
const addTab = useMutation({
|
|
210
211
|
mutationFn: (r) => json(`${api}/workspaces/${w.id}/tabs`, {
|
|
211
212
|
method: 'POST',
|
|
212
|
-
body: JSON.stringify({
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
name: r.label,
|
|
215
|
+
recipe: r.recipe,
|
|
216
|
+
...(r.formulas?.length ? { start: { formulas: r.formulas } } : {}),
|
|
217
|
+
}),
|
|
213
218
|
}),
|
|
214
219
|
onSuccess: ({ workspace, tab: t }) => {
|
|
215
220
|
put(workspace);
|
|
@@ -219,14 +224,16 @@ function WorkspaceBody({ api, w, tab, draft, }) {
|
|
|
219
224
|
onError: (e) => lumenToast.error(`Couldn’t add the tab: ${e.message}`),
|
|
220
225
|
});
|
|
221
226
|
const runAll = useMutation({
|
|
222
|
-
mutationFn: () => json(`${api}/workspaces/${w.id}/refresh`, {
|
|
223
|
-
method: 'POST',
|
|
224
|
-
}),
|
|
227
|
+
mutationFn: () => json(`${api}/workspaces/${w.id}/refresh`, { method: 'POST' }),
|
|
225
228
|
onSuccess: (r) => {
|
|
226
229
|
void qc.invalidateQueries({ queryKey: WORKSPACES_KEY });
|
|
227
230
|
const ran = `Ran ${r.ran} ${r.ran === 1 ? 'tab' : 'tabs'}: ${r.rows.toLocaleString('en-US')} rows`;
|
|
231
|
+
// A browser tab runs only where it is open: each one's own ↻.
|
|
232
|
+
const open = r.browser?.length ? `. Open to refresh: ${r.browser.join(', ')}` : '';
|
|
228
233
|
if (r.failed.length)
|
|
229
|
-
lumenToast.warning(`${ran}. Failed: ${r.failed.join(', ')}`);
|
|
234
|
+
lumenToast.warning(`${ran}. Failed: ${r.failed.join(', ')}${open}`);
|
|
235
|
+
else if (open)
|
|
236
|
+
lumenToast.info(`${ran}${open}`);
|
|
230
237
|
else
|
|
231
238
|
lumenToast.success(ran);
|
|
232
239
|
},
|
|
@@ -234,7 +241,11 @@ function WorkspaceBody({ api, w, tab, draft, }) {
|
|
|
234
241
|
});
|
|
235
242
|
const recipes = config.recipes ?? [];
|
|
236
243
|
const runnable = w.tabs.some((t) => 'frame' in t.source);
|
|
237
|
-
const intro = (_jsx(LumenTabs, { tabs: w.tabs.map((t) => ({
|
|
244
|
+
const intro = (_jsx(LumenTabs, { tabs: w.tabs.map((t) => ({
|
|
245
|
+
value: t.id,
|
|
246
|
+
label: t.name,
|
|
247
|
+
icon: tabIcon(t, 'frame' in t.source ? w.frames[t.source.frame] : undefined),
|
|
248
|
+
})), value: tab?.id, onChange: (t) => void navigate({ href: workspacePath(w.id, t), replace: true }), actions: _jsxs(_Fragment, { children: [recipes.length > 0 && (_jsx(LumenSelector, { trigger: "icon", icon: PlusIcon, label: addTab.isPending ? 'Adding a tab…' : 'New tab from a recipe', align: "end", disabled: addTab.isPending, options: recipes.map((r) => ({
|
|
238
249
|
value: r.id,
|
|
239
250
|
label: r.label,
|
|
240
251
|
description: r.description,
|
|
@@ -247,7 +258,7 @@ function WorkspaceBody({ api, w, tab, draft, }) {
|
|
|
247
258
|
} })), tab && (_jsx(IconButton, { icon: PencilIcon, label: "Edit tab", onClick: () => setEditingTab(true) }))] }) }));
|
|
248
259
|
const crumbs = [{ label: 'Workspaces' }, { label: w.name }];
|
|
249
260
|
const actions = (_jsxs(_Fragment, { children: [runnable && (_jsx(IconButton, { icon: RefreshCwIcon, label: runAll.isPending ? 'Running every tab…' : 'Run every tab again', disabled: runAll.isPending, className: runAll.isPending ? '[&_svg]:animate-spin' : undefined, onClick: () => runAll.mutate() })), _jsx(IconButton, { icon: TextCursorInputIcon, label: "Rename workspace", onClick: () => setNaming('workspace') }), moveTo.length > 0 && (_jsx(LumenSelector, { trigger: "icon", icon: ArrowRightLeftIcon, label: `Move to ${noun}`, align: "end", search: false, options: moveTo.map((a) => ({ value: a.id, label: a.label, icon: a.icon })), value: null, onChange: (a) => move.mutate(a) })), _jsx(IconButton, { icon: Trash2Icon, label: "Delete workspace", className: "text-muted-foreground hover:text-destructive", onClick: () => setRemoving('workspace') })] }));
|
|
250
|
-
const dialogs = (_jsxs(_Fragment, { children: [_jsx(NameDialog, { open: naming !== null, title: "Rename workspace", value: w.name, onClose: () => setNaming(null), onSave: (name) => rename.mutate(name) }), tab && (_jsx(LumenTabDrawer, { open: editingTab, onClose: () => setEditingTab(false), name: tab.name, icon: tab.config?.icon, fallback: tabIcon({ ...tab, config: null }), description: "Its filters and columns save as you change them.", maxName: 60, busy: editTab.isPending, error: editTab.error?.message, onSave: ({ name, icon }) => editTab.mutate(icon === (tab.config?.icon ?? null)
|
|
261
|
+
const dialogs = (_jsxs(_Fragment, { children: [_jsx(NameDialog, { open: naming !== null, title: "Rename workspace", value: w.name, onClose: () => setNaming(null), onSave: (name) => rename.mutate(name) }), tab && (_jsx(LumenTabDrawer, { open: editingTab, onClose: () => setEditingTab(false), name: tab.name, icon: tab.config?.icon, fallback: tabIcon({ ...tab, config: null }, 'frame' in tab.source ? w.frames[tab.source.frame] : undefined), description: "Its filters and columns save as you change them.", maxName: 60, busy: editTab.isPending, error: editTab.error?.message, onSave: ({ name, icon }) => editTab.mutate(icon === (tab.config?.icon ?? null)
|
|
251
262
|
? { name }
|
|
252
263
|
: { name, config: { ...tabConfig(tab, w, config.defaultConfig), name, icon } }), remove: {
|
|
253
264
|
description: 'Its layout goes with it.',
|
|
@@ -389,15 +400,26 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
389
400
|
queryFn: () => json(`${api}/frames/${meta.id}`),
|
|
390
401
|
});
|
|
391
402
|
const refresh = useMutation({
|
|
392
|
-
|
|
403
|
+
// A browser recipe runs here, in a sandbox, and saves its rows; any other runs on the server.
|
|
404
|
+
mutationFn: () => (rows.data?.meta ?? meta).browser
|
|
405
|
+
? runBrowserFrame(api, meta.id)
|
|
406
|
+
: json(`${api}/frames/${meta.id}/refresh`, { method: 'POST' }),
|
|
393
407
|
onSuccess: (data) => {
|
|
394
408
|
qc.setQueryData(frameKey(api, meta.id), data);
|
|
395
409
|
void qc.invalidateQueries({ queryKey: [...WORKSPACES_KEY, api, 'ws'] });
|
|
396
410
|
},
|
|
397
411
|
});
|
|
398
412
|
// A frame refreshes only when the person taps ↻: opening a tab shows the
|
|
399
|
-
// stored rows.
|
|
413
|
+
// stored rows. A browser recipe that never ran runs once when its tab first opens.
|
|
400
414
|
const current = rows.data?.meta ?? meta;
|
|
415
|
+
const firstRun = Boolean(rows.data && current.browser && !current.runs?.length);
|
|
416
|
+
const started = useRef(false);
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
if (!firstRun || started.current)
|
|
419
|
+
return;
|
|
420
|
+
started.current = true;
|
|
421
|
+
refresh.mutate();
|
|
422
|
+
}, [firstRun, refresh.mutate]);
|
|
401
423
|
const reload = useLumenLoad(rows);
|
|
402
424
|
const data = useMemo(() => (rows.data?.rows ?? []).map((v, i) => ({ i, v })), [rows.data]);
|
|
403
425
|
const symbolAt = frameSymbolAt(current);
|
|
@@ -411,14 +433,15 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
411
433
|
edited,
|
|
412
434
|
save: (edit) => edits.mutateAsync({ id: current.id, edits: [edit] }),
|
|
413
435
|
}, drop, assistant), [current, symbolAt, edited, edits.mutateAsync, drop, assistant]);
|
|
414
|
-
const config = useMemo(() => tab.config ?? frameConfig(current), [tab.config, current]);
|
|
436
|
+
const config = useMemo(() => tab.config ?? frameConfig(current, tab.start), [tab.config, tab.start, current]);
|
|
415
437
|
// The cause once, even from a frame stored before the server unwrapped it.
|
|
416
438
|
const failed = (refresh.error?.message ?? current.error)?.replace(/^(?:(?:Code execution failed|Refresh failed):\s*)+/, '');
|
|
417
439
|
// The card's own ↻ runs the assistant's script again and its footer says when they last ran (per card, not the page header); rows the assistant wrote itself only reload.
|
|
418
440
|
const runs = current.mode !== 'static' && current.origin !== 'yours';
|
|
419
|
-
// The
|
|
441
|
+
// The recipe as code to read and edit; a Your data column and a pushed dataset have none.
|
|
420
442
|
const [scriptOpen, setScriptOpen] = useState(false);
|
|
421
|
-
const
|
|
443
|
+
const pushed = current.origin === 'harness';
|
|
444
|
+
const assistantMade = current.origin !== 'yours' && !pushed;
|
|
422
445
|
const load = useMemo(() => ({
|
|
423
446
|
...reload,
|
|
424
447
|
updated: {
|
|
@@ -431,11 +454,15 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
431
454
|
: 'fixed',
|
|
432
455
|
about: current.origin === 'yours'
|
|
433
456
|
? 'Your own data: you change it by typing.'
|
|
434
|
-
:
|
|
435
|
-
? `${
|
|
436
|
-
: current.
|
|
437
|
-
?
|
|
438
|
-
:
|
|
457
|
+
: pushed
|
|
458
|
+
? `${current.by ?? 'An agent'} pushed these rows: it pushes again to change them.`
|
|
459
|
+
: current.browser
|
|
460
|
+
? 'This recipe runs in your browser: ↻ runs it here and saves the rows.'
|
|
461
|
+
: !runs
|
|
462
|
+
? `${assistant} wrote these rows once: nothing to run again.`
|
|
463
|
+
: current.mode === 'asof'
|
|
464
|
+
? `${assistant}’s script reads the web, so it runs only when you ask.`
|
|
465
|
+
: `${assistant}’s script reads live data.`,
|
|
439
466
|
},
|
|
440
467
|
...(assistantMade ? { editScript: () => setScriptOpen(true) } : {}),
|
|
441
468
|
...(runs
|
|
@@ -445,9 +472,11 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
445
472
|
refresh: async () => {
|
|
446
473
|
await refresh.mutateAsync().catch(() => undefined);
|
|
447
474
|
},
|
|
448
|
-
refreshLabel: current.
|
|
449
|
-
?
|
|
450
|
-
:
|
|
475
|
+
refreshLabel: current.browser
|
|
476
|
+
? 'Run the script in your browser'
|
|
477
|
+
: current.mode === 'asof'
|
|
478
|
+
? `Run ${assistant}’s script again (it reads the web, so only when you ask)`
|
|
479
|
+
: `Run ${assistant}’s script again`,
|
|
451
480
|
}
|
|
452
481
|
: {}),
|
|
453
482
|
}), [
|
|
@@ -455,6 +484,9 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
455
484
|
current.at,
|
|
456
485
|
current.mode,
|
|
457
486
|
current.origin,
|
|
487
|
+
current.by,
|
|
488
|
+
current.browser,
|
|
489
|
+
pushed,
|
|
458
490
|
failed,
|
|
459
491
|
runs,
|
|
460
492
|
assistantMade,
|
|
@@ -464,9 +496,11 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
464
496
|
]);
|
|
465
497
|
return (_jsxs(_Fragment, { children: [_jsx(LumenLayout, { crumbs: crumbs, intro: intro, actions: actions, domain: domain, config: config, rows: data, onConfigChange: onChange, save: "sheet", rowTitle: (r) => String(r.v[symbolAt >= 0 ? symbolAt : 0] ?? `Row ${r.i + 1}`), load: load, status: _jsxs(_Fragment, { children: [_jsx(StatusItem, { label: "Frame", value: current.name }), _jsx(StatusItem, { label: "Rows", value: current.rows.toLocaleString('en-US') }), _jsx(StatusItem, { label: "Kind", value: current.origin === 'yours'
|
|
466
498
|
? `Your data, by ${current.key}`
|
|
467
|
-
:
|
|
468
|
-
?
|
|
469
|
-
:
|
|
499
|
+
: pushed
|
|
500
|
+
? 'Pushed dataset'
|
|
501
|
+
: current.kind === 'linked'
|
|
502
|
+
? `Linked by ${current.key}`
|
|
503
|
+
: 'Snapshot' }), current.edited ? (_jsx(StatusItem, { label: "Edited by hand", value: current.edited.toLocaleString('en-US') })) : null, pushed ? (_jsx(StatusItem, { label: "Pushed by", value: `${current.by ?? 'an agent'} · ${ago(current.at)}` })) : null, pushed ? null : (_jsx(StatusItem, { label: current.kind === 'linked'
|
|
470
504
|
? 'As of'
|
|
471
505
|
: current.mode === 'live'
|
|
472
506
|
? 'Live, read'
|
|
@@ -479,11 +513,33 @@ function FrameTab({ api, tab, meta, crumbs, actions, intro, onChange, }) {
|
|
|
479
513
|
day: 'numeric',
|
|
480
514
|
hour: 'numeric',
|
|
481
515
|
minute: '2-digit',
|
|
482
|
-
}) }), failed ? (_jsx(StatusItem, { label: "Refresh failed", className: "min-w-0", value: _jsx("span", { className: "inline-block max-w-72 truncate align-bottom", title: failed, children: failed }), ok: false })) : null] }) }, `frame:${tab.id}`), assistantMade && (_jsx(FrameScriptDialog, { api: api, frame: current.id, name: current.name, runs: current.runs, open: scriptOpen, onOpenChange: setScriptOpen }))] }));
|
|
516
|
+
}) })), failed ? (_jsx(StatusItem, { label: "Refresh failed", className: "min-w-0", value: _jsx("span", { className: "inline-block max-w-72 truncate align-bottom", title: failed, children: failed }), ok: false })) : null] }) }, `frame:${tab.id}`), assistantMade && (_jsx(FrameScriptDialog, { api: api, frame: current.id, name: current.name, runs: current.runs, open: scriptOpen, onOpenChange: setScriptOpen }))] }));
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A tab's icon: the person's pick, else what makes its frame (a file for a pushed CSV, a
|
|
520
|
+
* pipeline for web reads, code for a browser script, the assistant's mark), else a table.
|
|
521
|
+
*/
|
|
522
|
+
function tabIcon(t, meta) {
|
|
523
|
+
const made = !('frame' in t.source)
|
|
524
|
+
? TableIcon
|
|
525
|
+
: meta?.origin === 'harness'
|
|
526
|
+
? FileSpreadsheetIcon
|
|
527
|
+
: meta?.recipe === 'fetch'
|
|
528
|
+
? WorkflowIcon
|
|
529
|
+
: meta?.browser
|
|
530
|
+
? CodeIcon
|
|
531
|
+
: SparklesIcon;
|
|
532
|
+
return iconOf(t.config?.icon, made);
|
|
483
533
|
}
|
|
484
|
-
/**
|
|
485
|
-
function
|
|
486
|
-
|
|
534
|
+
/** How long ago, in short words: `4 min ago`, `2 h ago`, `3 d ago`. */
|
|
535
|
+
function ago(at) {
|
|
536
|
+
const min = Math.max(0, Math.round((Date.now() - Date.parse(at)) / 60_000));
|
|
537
|
+
if (min < 1)
|
|
538
|
+
return 'just now';
|
|
539
|
+
if (min < 60)
|
|
540
|
+
return `${min} min ago`;
|
|
541
|
+
const h = Math.round(min / 60);
|
|
542
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
487
543
|
}
|
|
488
544
|
/** A tab's layout as it shows: its own config, else its source's default. */
|
|
489
545
|
function tabConfig(t, w, defaultConfig) {
|
|
@@ -491,7 +547,10 @@ function tabConfig(t, w, defaultConfig) {
|
|
|
491
547
|
return t.config;
|
|
492
548
|
if ('frame' in t.source) {
|
|
493
549
|
const meta = w.frames[t.source.frame];
|
|
494
|
-
return {
|
|
550
|
+
return {
|
|
551
|
+
...(meta ? frameConfig(meta, t.start) : customViewConfigSchema.parse({})),
|
|
552
|
+
name: t.name,
|
|
553
|
+
};
|
|
495
554
|
}
|
|
496
555
|
const start = defaultConfig?.(t.source.domain);
|
|
497
556
|
return { ...customViewConfigSchema.parse(start ?? {}), name: t.name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.199",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@codemirror/lang-javascript": "6.2.5",
|
|
50
50
|
"@codemirror/lang-json": "6.0.2",
|
|
51
51
|
"@codemirror/lang-sql": "6.10.0",
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.199",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|