@columenlabs/lumen-ui 0.1.196 → 0.1.198

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.
@@ -0,0 +1,3 @@
1
+ export default function ChatMarkdown({ children }: {
2
+ children: string;
3
+ }): import("react").JSX.Element;
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import Markdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { cn } from '../lib/utils.js';
5
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
6
+ const ALLOWED = [
7
+ 'p',
8
+ 'strong',
9
+ 'em',
10
+ 'del',
11
+ 'code',
12
+ 'ul',
13
+ 'ol',
14
+ 'li',
15
+ 'a',
16
+ 'pre',
17
+ 'br',
18
+ 'table',
19
+ 'thead',
20
+ 'tbody',
21
+ 'tr',
22
+ 'th',
23
+ 'td',
24
+ ];
25
+ // A number, with an optional sign, currency, thousands, decimals and a short unit (12, −3.5%,
26
+ // $1,204.10, 2.1×, 4.2K). A dash or an empty cell does not decide a column either way.
27
+ const NUMBER = /^[−+\-]?[$€£]?\d[\d,]*(\.\d+)?\s?(%|×|x|[KMBkmb])?$/;
28
+ const BLANK = /^(|—|–|-|n\/a)$/i;
29
+ function text(node) {
30
+ if (node.type === 'text')
31
+ return node.value ?? '';
32
+ return (node.children ?? []).map(text).join('');
33
+ }
34
+ const cells = (row) => (row.children ?? []).filter((c) => c.tagName === 'td' || c.tagName === 'th');
35
+ const rows = (section) => (section?.children ?? []).filter((c) => c.tagName === 'tr');
36
+ /**
37
+ * Marks each cell of a column that holds only numbers `dataNum`, so the column right-aligns on
38
+ * one edge, and gives every cell its text as `dataText` for its hover.
39
+ */
40
+ function rehypeNumberColumns() {
41
+ const visit = (node) => {
42
+ if (node.tagName === 'table') {
43
+ const head = node.children?.find((c) => c.tagName === 'thead');
44
+ const body = node.children?.find((c) => c.tagName === 'tbody');
45
+ const bodyRows = rows(body);
46
+ const width = Math.max(0, ...[...rows(head), ...bodyRows].map((r) => cells(r).length));
47
+ const numeric = Array.from({ length: width }, (_, i) => {
48
+ const values = bodyRows.map((r) => text(cells(r)[i] ?? { type: 'root' }).trim());
49
+ const filled = values.filter((v) => !BLANK.test(v));
50
+ return filled.length > 0 && filled.every((v) => NUMBER.test(v));
51
+ });
52
+ for (const r of [...rows(head), ...bodyRows]) {
53
+ cells(r).forEach((c, i) => {
54
+ c.properties = { ...c.properties, dataText: text(c).trim() };
55
+ if (numeric[i])
56
+ c.properties.dataNum = '';
57
+ });
58
+ }
59
+ return;
60
+ }
61
+ node.children?.forEach(visit);
62
+ };
63
+ return (tree) => visit(tree);
64
+ }
65
+ // A header keeps the label font; only its edge follows the numbers under it.
66
+ const cellClass = (p, head = false) => cn('max-w-xs overflow-hidden text-ellipsis', p['data-num'] !== undefined && (head ? 'text-right' : 'num text-right'));
67
+ const components = {
68
+ table: ({ children }) => (_jsx("div", { className: "not-prose my-2 rounded-md border", children: _jsx(Table, { className: "text-xs", children: children }) })),
69
+ thead: ({ children }) => _jsx(TableHeader, { children: children }),
70
+ tbody: ({ children }) => _jsx(TableBody, { children: children }),
71
+ tr: ({ children }) => _jsx(TableRow, { children: children }),
72
+ th: (p) => (_jsx(TableHead, { className: cn(cellClass(p, true), 'text-muted-foreground'), title: p['data-text'], children: p.children })),
73
+ td: (p) => (_jsx(TableCell, { className: cellClass(p), title: p['data-text'], children: p.children })),
74
+ };
75
+ export default function ChatMarkdown({ children }) {
76
+ return (_jsx(Markdown, { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeNumberColumns], allowedElements: ALLOWED, unwrapDisallowed: true, components: components, children: children }));
77
+ }
@@ -21,8 +21,8 @@ import { Textarea } from '../ui/textarea.js';
21
21
  import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
22
22
  import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
23
23
  import { useAssistantChat } from './use-assistant-chat.js';
24
- // The Markdown renderer loads with the first answer (lazy-part.tsx).
25
- const Markdown = lazyPart(() => import('react-markdown').then((m) => m.default));
24
+ // The Markdown renderer (tables included) loads with the first answer (lazy-part.tsx).
25
+ const ChatMarkdown = lazyPart(() => import('./chat-markdown.js').then((m) => m.default));
26
26
  /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
27
27
  const CONTEXT_MAX = 200_000;
28
28
  const COMPACT_AT = 150_000;
@@ -256,7 +256,7 @@ function Item({ item, onSettle, busy, }) {
256
256
  case 'user':
257
257
  return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: "@", label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
258
258
  case 'text':
259
- return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(Markdown, { allowedElements: ['p', 'strong', 'em', 'code', 'ul', 'ol', 'li', 'a', 'pre', 'br'], unwrapDisallowed: true, children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
259
+ return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(ChatMarkdown, { children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
260
260
  case 'step':
261
261
  return _jsx(Step, { step: item, live: !!item.live });
262
262
  case 'draft':
@@ -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
+ }
@@ -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 { kind: 'script', code: text };
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 call is not valid JSON: ${e.message}`);
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
- mutationFn: (next) => json(`${api}/frames/${frame}/script`, {
73
- method: 'PUT',
74
- headers: { 'content-type': 'application/json' },
75
- body: JSON.stringify({ script: next }),
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] }), _jsxs(DialogDescription, { children: ["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: () => {
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>;
@@ -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({ name: r.label, recipe: r.recipe }),
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) => ({ value: t.id, label: t.name, icon: tabIcon(t) })), 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) => ({
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
- mutationFn: () => json(`${api}/frames/${meta.id}/refresh`, { method: 'POST' }),
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 assistant's script as code to read and edit; a Your data column has none.
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 assistantMade = current.origin !== 'yours';
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
- : !runs
435
- ? `${assistant} wrote these rows once: nothing to run again.`
436
- : current.mode === 'asof'
437
- ? `${assistant}’s script reads the web, so it runs only when you ask.`
438
- : `${assistant}’s script reads live data.`,
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.mode === 'asof'
449
- ? `Run ${assistant}’s script again (it reads the web, so only when you ask)`
450
- : `Run ${assistant}’s script again`,
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
- : current.kind === 'linked'
468
- ? `Linked by ${current.key}`
469
- : 'Snapshot' }), current.edited ? (_jsx(StatusItem, { label: "Edited by hand", value: current.edited.toLocaleString('en-US') })) : null, _jsx(StatusItem, { label: current.kind === 'linked'
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
- /** A tab's icon: the person's pick, else the assistant's mark on a frame, else a table. */
485
- function tabIcon(t) {
486
- return iconOf(t.config?.icon, 'frame' in t.source ? SparklesIcon : TableIcon);
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 { ...(meta ? frameConfig(meta) : customViewConfigSchema.parse({})), name: t.name };
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.196",
3
+ "version": "0.1.198",
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.196",
52
+ "@columenlabs/lumen-ui-types": "0.1.198",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",
@@ -80,6 +80,7 @@
80
80
  "react-markdown": "10.1.0",
81
81
  "react-resizable-panels": "^4.14.3",
82
82
  "recharts": "^3.10.1",
83
+ "remark-gfm": "4.0.1",
83
84
  "shiki": "4.5.0",
84
85
  "sonner": "^2.0.8",
85
86
  "tailwind-merge": "^3.7.0",