@columenlabs/lumen-ui 0.1.91 → 0.1.93

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.
@@ -1,4 +1,4 @@
1
- import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AllowanceRequest, AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  type Test = NonNullable<KeyState['test']>;
3
3
  export interface AssistantAdapter {
4
4
  /** Names the cache its reads keep (two assistants on one page need two keys). */
@@ -14,7 +14,8 @@ export interface AssistantAdapter {
14
14
  ownKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
15
15
  /** What the person may spend a month on their own keys. */
16
16
  ownCap(usd: number): Promise<AssistantStatus>;
17
- testOwnKey(provider: AssistantProvider): Promise<Test>;
17
+ /** Tests the person's key or plan (`way`; by default the one in use). */
18
+ testOwnKey(provider: AssistantProvider, way?: AssistantOwnWay): Promise<Test>;
18
19
  /** Starts a plan sign-in (ChatGPT): the address to open and the one it ends on. */
19
20
  startSignin(provider: AssistantProvider): Promise<{
20
21
  url: string;
@@ -22,6 +23,8 @@ export interface AssistantAdapter {
22
23
  }>;
23
24
  /** Finishes it from the address the sign-in tab ended on. */
24
25
  finishSignin(provider: AssistantProvider, url: string): Promise<AssistantStatus>;
26
+ /** Signs out of the plan; the person's key, if set, runs on. */
27
+ signOut(provider: AssistantProvider): Promise<AssistantStatus>;
25
28
  /** Admin: ScrapeQL's Access service token; null removes it. */
26
29
  scrapeql(token: {
27
30
  client_id: string;
@@ -49,9 +49,10 @@ export function assistantClient(base) {
49
49
  ? send('PUT', `/my-key?provider=${p}`, { api_key: key })
50
50
  : send('DELETE', `/my-key?provider=${p}`),
51
51
  ownCap: (usd) => send('PUT', '/my-key/cap', { cap_usd: usd }),
52
- testOwnKey: (p) => send('POST', `/my-key/test?provider=${p}`),
52
+ testOwnKey: (p, way) => send('POST', `/my-key/test?provider=${p}${way ? `&way=${way}` : ''}`),
53
53
  startSignin: (p) => send('POST', `/my-signin?provider=${p}`),
54
54
  finishSignin: (p, url) => send('POST', `/my-signin/finish?provider=${p}`, { url }),
55
+ signOut: (p) => send('DELETE', `/my-signin?provider=${p}`),
55
56
  scrapeql: (token) => token ? send('PUT', '/web/scrapeql', token) : send('DELETE', '/web/scrapeql'),
56
57
  testScrapeql: () => send('POST', '/web/test'),
57
58
  chats: () => get('/chats'),
@@ -1,13 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { CheckIcon, CoinsIcon, ExternalLinkIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, ReceiptIcon, SparklesIcon, Trash2Icon, WrenchIcon, } from 'lucide-react';
3
+ import { CoinsIcon, GlobeIcon, InboxIcon, KeyRoundIcon, LayersIcon, LogInIcon, ReceiptIcon, SparklesIcon, WrenchIcon, } from 'lucide-react';
4
4
  import { useId, useState } from 'react';
5
- import { LumenChatgptSignin } from '../chatgpt-signin.js';
5
+ import { ConnectionLogo, LumenConnection, } from '../connection.js';
6
6
  import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
7
7
  import { SaveButton } from '../form-issues.js';
8
- import { IconButton } from '../icon-button.js';
9
8
  import { InfoLabel } from '../info-tip.js';
10
- import { KeyTestLine, TestBadge, errorText } from '../integrations.js';
9
+ import { errorText } from '../integrations.js';
11
10
  import { LumenSelector } from '../lumen-selector.js';
12
11
  import { lumenToast } from '../lumen-toast.js';
13
12
  import { ShareMeter } from '../share-meter.js';
@@ -70,14 +69,6 @@ function WithStatus({ client, children, }) {
70
69
  function Row({ label, info, children }) {
71
70
  return (_jsxs("div", { "data-slot": "setting-row", className: "flex flex-wrap items-center gap-x-4 gap-y-2 border-b py-3 last:border-b-0", children: [_jsx(Label, { className: "min-w-0 flex-1", children: _jsx(InfoLabel, { info: info, children: label }) }), _jsx("div", { className: "shrink-0", children: children })] }));
72
71
  }
73
- const testOf = (k) => k.test
74
- ? {
75
- at: new Date(k.test.at).toISOString(),
76
- ok: k.test.ok,
77
- detail: k.test.detail,
78
- error: k.test.ok ? null : k.test.detail,
79
- }
80
- : null;
81
72
  // ---------------------------------------------------------------- the assistant
82
73
  function General({ client }) {
83
74
  const { apply } = useApply(client);
@@ -98,27 +89,6 @@ function General({ client }) {
98
89
  } }));
99
90
  }
100
91
  // ---------------------------------------------------------------- model providers
101
- /** Paste a key, Save tests it first; then its hint, Test and Remove. */
102
- function KeyBlock({ label, info, prefix, provider, state, canEdit, onSave, onRemove, onTest, keysUrl, }) {
103
- const id = useId();
104
- const [key, setKey] = useState('');
105
- const [replacing, setReplacing] = useState(false);
106
- const [busy, setBusy] = useState(false);
107
- const editing = canEdit && (!state.set || replacing);
108
- const issues = [];
109
- if (editing && key.trim() && !key.trim().startsWith(prefix))
110
- issues.push({ where: label, message: `An ${provider} key starts with ${prefix}`, target: id });
111
- const save = async () => {
112
- setBusy(true);
113
- if (await onSave(key.trim())) {
114
- setKey('');
115
- setReplacing(false);
116
- }
117
- setBusy(false);
118
- };
119
- return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoLabel, { className: "flex-1 font-medium text-sm", info: info, children: label }), state.set ? (state.hint ? (_jsx("span", { className: "font-code text-muted-foreground text-xs", children: state.hint })) : (_jsx(Badge, { variant: "secondary", children: "Set" }))) : state.from_env ? (_jsx(Badge, { variant: "secondary", children: "From the server" })) : (_jsx(Badge, { variant: "outline", children: "Not set" })), state.set && state.test && _jsx(TestBadge, { test: state.test })] }), editing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, type: "password", autoComplete: "off", placeholder: `${prefix}…`, className: "font-code text-xs", value: key, onChange: (e) => setKey(e.target.value) }), keysUrl && (_jsx(IconButton, { icon: ExternalLinkIcon, label: `Get an ${provider} key`, href: keysUrl, variant: "outline" })), replacing && (_jsx(IconButton, { icon: Trash2Icon, label: "Keep the saved key", onClick: () => setReplacing(false) })), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !key.trim(), label: "Test and save", onSave: save })] })) : (state.set &&
120
- canEdit && (_jsxs(_Fragment, { children: [_jsx(KeyTestLine, { test: testOf(state), run: onTest }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: `Remove ${label.toLowerCase()}`, variant: "outline", onClick: () => void onRemove() }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Replace", onSave: () => setReplacing(true) })] })] })))] }));
121
- }
122
92
  /** A dollar cap a month, saved on Enter or leaving the field. */
123
93
  function CapInput({ label, value, onSave, }) {
124
94
  const [text, setText] = useState(String(value));
@@ -131,31 +101,87 @@ function CapInput({ label, value, onSave, }) {
131
101
  };
132
102
  return (_jsxs("div", { className: "flex items-center gap-1 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "$" }), _jsx(Input, { "aria-label": label, inputMode: "decimal", className: "h-8 w-20 text-right tabular-nums", value: text, onChange: (e) => setText(e.target.value), onBlur: commit, onKeyDown: (e) => e.key === 'Enter' && commit() }), _jsx("span", { className: "text-muted-foreground", children: "/ month" })] }));
133
103
  }
134
- /** A person's ChatGPT plan sign-in, on the assistant's own routes. */
135
- function ChatgptSignin({ client, p }) {
136
- const { apply, refresh } = useApply(client);
137
- return (_jsx(LumenChatgptSignin, { state: p.own, start: () => client.startSignin(p.id), finish: (url) => apply(`signin:${p.id}`, () => client.finishSignin(p.id, url), 'Signed in'), signOut: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Signed out'), test: () => client.testOwnKey(p.id).finally(refresh) }));
138
- }
139
- const ready = (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] }));
140
- const notSet = _jsx(Badge, { variant: "outline", children: "Not set" });
141
- /** One provider, collapsed to a row: the person's own key (or their ChatGPT plan sign-in). */
104
+ /** A key's last test as a connection way shows it; a plan that failed asks for a new sign-in. */
105
+ const wayTest = (k, plan = false) => k?.test
106
+ ? {
107
+ at: k.test.at,
108
+ ok: k.test.ok,
109
+ detail: k.test.detail,
110
+ error: k.test.ok ? null : k.test.detail,
111
+ expired: plan && !k.test.ok,
112
+ }
113
+ : null;
114
+ /** A provider's mark: its initial on a tile. */
115
+ const mark = (label) => _jsx(ConnectionLogo, { text: label.charAt(0) });
116
+ /** One provider, collapsed to a row: the person's own key and, where it has one, their plan sign-in. */
142
117
  function OwnProviderKey({ client, p }) {
143
118
  const { apply, refresh } = useApply(client);
144
- const plan = p.own.via === 'chatgpt';
145
- const badge = p.own.set && p.own.test && !p.own.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : p.own.set ? (ready) : (notSet);
146
- return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: plan ? 'Your ChatGPT plan' : p.own.set ? 'Your key' : 'No key', badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your API key' : 'Your key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }))] }) }));
119
+ const ways = [];
120
+ if (p.signin === 'chatgpt')
121
+ ways.push({
122
+ id: 'plan',
123
+ label: 'ChatGPT plan',
124
+ icon: LogInIcon,
125
+ info: 'Run OpenAI models on your own ChatGPT plan instead of an API key.',
126
+ set: !!p.plan?.set,
127
+ ident: p.plan?.hint,
128
+ test: wayTest(p.plan, true),
129
+ signin: { start: () => client.startSignin(p.id), provider: 'ChatGPT' },
130
+ onFinish: (url) => apply(`signin:${p.id}`, () => client.finishSignin(p.id, url), 'Signed in'),
131
+ onTest: () => client.testOwnKey(p.id, 'plan').finally(refresh),
132
+ onRemove: () => apply(`plan:${p.id}`, () => client.signOut(p.id), 'Signed out'),
133
+ removeTitle: 'Sign out of your ChatGPT plan?',
134
+ removeText: 'Your API key, if you set one, runs from then on.',
135
+ removeLabel: 'Sign out',
136
+ });
137
+ ways.push({
138
+ id: 'key',
139
+ label: 'API key',
140
+ icon: KeyRoundIcon,
141
+ info: `Your own ${p.label} key, billed by ${p.label}.`,
142
+ set: p.own.set,
143
+ ident: p.own.hint,
144
+ test: wayTest(p.own),
145
+ fields: [{ id: 'key', label: `${p.label} API key`, secret: true, prefix: p.prefix }],
146
+ getUrl: p.keys_url,
147
+ onSave: (v) => apply(`own:${p.id}`, () => client.ownKey(p.id, v.key ?? ''), 'Key saved'),
148
+ onTest: () => client.testOwnKey(p.id, 'key').finally(refresh),
149
+ onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'),
150
+ removeTitle: `Remove your ${p.label} key?`,
151
+ });
152
+ return (_jsx(LumenConnection, { id: p.id, name: p.label, logo: mark(p.label), ways: ways, use: p.use, onUse: (use) => apply(`use:${p.id}`, () => client.settings({ own_use: { [p.id]: use } })) }));
147
153
  }
148
154
  /** A person's own keys, one row a provider, and what they may spend on them. */
149
155
  function Providers({ client }) {
150
156
  const { apply } = useApply(client);
151
- return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
157
+ return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set || p.plan?.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
152
158
  }
153
159
  /** One provider's shared key, which an admin sets for everyone. */
154
160
  function SharedProviderKey({ client, p }) {
155
161
  const { apply, refresh } = useApply(client);
156
- const on = p.app.set || !!p.app.from_env;
157
- const badge = p.app.set && p.app.test && !p.app.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : on ? (ready) : (notSet);
158
- return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: p.app.from_env ? 'From the server' : on ? 'Shared key' : 'No key', badge: badge, children: _jsx(KeyBlock, { label: "Shared key", info: "Everyone's answers run on it, within the monthly allowance below.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }) }));
162
+ const env = !p.app.set && !!p.app.from_env;
163
+ return (_jsx(LumenConnection, { id: p.id, name: p.label, logo: mark(p.label), ways: [
164
+ {
165
+ id: 'key',
166
+ label: 'Shared key',
167
+ icon: KeyRoundIcon,
168
+ info: "Everyone's answers run on it, within the allowance below.",
169
+ set: p.app.set || env,
170
+ ident: env ? 'From the server' : p.app.hint,
171
+ test: wayTest(p.app),
172
+ fields: [{ id: 'key', label: `${p.label} API key`, secret: true, prefix: p.prefix }],
173
+ getUrl: p.keys_url,
174
+ onSave: (v) => apply(`app:${p.id}`, () => client.appKey(p.id, v.key ?? ''), 'Key saved'),
175
+ onTest: () => client.testAppKey(p.id).finally(refresh),
176
+ ...(p.app.set
177
+ ? {
178
+ onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'),
179
+ removeTitle: `Remove the shared ${p.label} key?`,
180
+ removeText: 'Answers on it stop until a key is set again.',
181
+ }
182
+ : {}),
183
+ },
184
+ ] }));
159
185
  }
160
186
  /** Admins only: the app's shared keys and what each person may spend on them. */
161
187
  function SharedKeys({ client }) {
@@ -171,7 +197,7 @@ function Models({ client }) {
171
197
  const tiers = s.tiers.map((t) => ({ value: t.id, label: t.label }));
172
198
  return (_jsxs("div", { className: "flex flex-col", children: [s.tiers.map((t) => {
173
199
  const p = s.providers.find((x) => x.id === t.provider);
174
- const keyless = p && !p.app.set && !p.app.from_env && !p.own.set;
200
+ const keyless = p && !p.app.set && !p.app.from_env && !p.own.set && !p.plan?.set;
175
201
  return (_jsx(Row, { label: t.label, info: t.about, children: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [keyless && _jsxs(Badge, { variant: "outline", children: ["No ", p.label, " key"] }), _jsx(LumenSelector, { label: `${t.label} model`, size: "sm", className: "w-36 sm:w-44", value: t.model, options: models, onChange: (model) => set({ tiers: { [t.id]: { model } } }) }), _jsx(LumenSelector, { label: `${t.label} effort`, size: "sm", className: "w-24 sm:w-32", value: t.effort, options: EFFORTS, onChange: (effort) => set({ tiers: { [t.id]: { effort } } }) })] }) }, t.id));
176
202
  }), s.tasks.length > 1 &&
177
203
  s.tasks.map((task) => (_jsx(Row, { label: `${task.label} starts on`, children: _jsx(LumenSelector, { label: `${task.label} starts on`, size: "sm", className: "w-44", value: task.tier, options: tiers, onChange: (tier) => set({ tasks: { [task.id]: tier } }) }) }, task.id)))] }));
@@ -186,33 +212,33 @@ function Tools({ client }) {
186
212
  // ---------------------------------------------------------------- web
187
213
  function ScrapeqlBlock({ client, s }) {
188
214
  const { apply, refresh } = useApply(client);
189
- const ids = useId();
190
- const [id, setId] = useState('');
191
- const [secret, setSecret] = useState('');
192
- const [replacing, setReplacing] = useState(false);
193
- const [busy, setBusy] = useState(false);
194
215
  const state = s.web.scrapeql;
195
216
  if (!state)
196
217
  return null;
197
- const editing = s.admin && (!state.set || replacing);
198
- const issues = [];
199
- if (editing && id && !secret)
200
- issues.push({
201
- where: 'Client Secret',
202
- message: 'Paste the secret too',
203
- target: `${ids}-secret`,
204
- });
205
- const save = async () => {
206
- setBusy(true);
207
- if (await apply('scrapeql', () => client.scrapeql({ client_id: id.trim(), client_secret: secret.trim() }), 'Token saved')) {
208
- setId('');
209
- setSecret('');
210
- setReplacing(false);
211
- }
212
- setBusy(false);
213
- };
214
- return (_jsxs("div", { className: "flex flex-col gap-3 border-b py-3 last:border-b-0", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex-1 font-medium text-sm", children: _jsx(InfoLabel, { info: "Search and pages that need a real browser. Paste a Cloudflare Access service token that ScrapeQL lets in.", children: "ScrapeQL" }) }), state.set ? (state.hint ? (_jsx("span", { className: "font-code text-muted-foreground text-xs", children: state.hint })) : (_jsx(Badge, { variant: "secondary", children: "Set" }))) : (_jsx(Badge, { variant: "outline", children: "Not set" })), state.set && state.test && _jsx(TestBadge, { test: state.test })] }), editing ? (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx(Input, { id: `${ids}-id`, "aria-label": "Client ID", placeholder: "Client ID", autoComplete: "off", className: "font-code text-xs", value: id, onChange: (e) => setId(e.target.value) }), _jsx(Input, { id: `${ids}-secret`, "aria-label": "Client Secret", placeholder: "Client Secret", type: "password", autoComplete: "off", className: "font-code text-xs", value: secret, onChange: (e) => setSecret(e.target.value) }), _jsxs("div", { className: "flex justify-end gap-2", children: [replacing && (_jsx(IconButton, { icon: Trash2Icon, label: "Keep the saved token", onClick: () => setReplacing(false) })), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !id.trim() || !secret.trim(), label: "Test and save", onSave: save })] })] })) : (state.set &&
215
- s.admin && (_jsxs(_Fragment, { children: [_jsx(KeyTestLine, { test: testOf(state), run: () => client.testScrapeql().finally(refresh) }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: "Remove the token", variant: "outline", onClick: () => void apply('scrapeql', () => client.scrapeql(null), 'Token removed') }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Replace", onSave: () => setReplacing(true) })] })] })))] }));
218
+ return (_jsx(LumenExpandList, { children: _jsx(LumenConnection, { id: "scrapeql", name: "ScrapeQL", logo: _jsx(ConnectionLogo, { icon: GlobeIcon }), meta: state.set ? undefined : 'Search and pages that need a real browser', ways: [
219
+ {
220
+ id: 'token',
221
+ label: 'Service token',
222
+ icon: KeyRoundIcon,
223
+ info: 'A Cloudflare Access service token that ScrapeQL lets in.',
224
+ set: state.set,
225
+ ident: state.hint,
226
+ test: wayTest(state),
227
+ readOnly: !s.admin,
228
+ fields: [
229
+ { id: 'client_id', label: 'Client ID' },
230
+ { id: 'client_secret', label: 'Client Secret', secret: true },
231
+ ],
232
+ onSave: (v) => apply('scrapeql', () => client.scrapeql({
233
+ client_id: v.client_id ?? '',
234
+ client_secret: v.client_secret ?? '',
235
+ }), 'Token saved'),
236
+ ...(s.admin ? { onTest: () => client.testScrapeql().finally(refresh) } : {}),
237
+ onRemove: () => apply('scrapeql', () => client.scrapeql(null), 'Token removed'),
238
+ removeTitle: 'Remove the ScrapeQL token?',
239
+ removeText: 'Search and browser pages stop until a token is set again.',
240
+ },
241
+ ] }) }));
216
242
  }
217
243
  function Web({ client }) {
218
244
  const { apply } = useApply(client);
@@ -14,5 +14,8 @@ export interface LumenChatgptSigninProps {
14
14
  /** One free call that says whether the sign-in still works; the app refreshes its data after. */
15
15
  test: () => Promise<unknown>;
16
16
  }
17
- /** Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended. */
17
+ /**
18
+ * Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended.
19
+ * @deprecated Use `LumenConnection` with a `signin` way; the assistant keeps the plan apart from the key.
20
+ */
18
21
  export declare function LumenChatgptSignin({ state, start, finish, signOut, test, }: LumenChatgptSigninProps): import("react").JSX.Element;
@@ -17,7 +17,10 @@ const testOf = (k) => k.test
17
17
  error: k.test.ok ? null : k.test.detail,
18
18
  }
19
19
  : null;
20
- /** Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended. */
20
+ /**
21
+ * Sign in with ChatGPT: opens in a new tab, finished by pasting back where it ended.
22
+ * @deprecated Use `LumenConnection` with a `signin` way; the assistant keeps the plan apart from the key.
23
+ */
21
24
  export function LumenChatgptSignin({ state, start, finish, signOut, test, }) {
22
25
  const id = useId();
23
26
  const [started, setStarted] = useState(null);
@@ -0,0 +1,117 @@
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ /** A way's last test. `at` is an ISO time or epoch ms; `expired` asks for a new key or sign-in. */
3
+ export interface ConnectionTest {
4
+ at: string | number;
5
+ ok: boolean;
6
+ /** What it read when it worked (`3 models`, the account). */
7
+ detail?: string | null;
8
+ /** Why it failed, in the person's words. */
9
+ error?: string | null;
10
+ expired?: boolean;
11
+ }
12
+ /** One value a way is set up with: a key, a client id, a secret. */
13
+ export interface ConnectionField {
14
+ id: string;
15
+ label: string;
16
+ /** Typed as a password and never shown again. */
17
+ secret?: boolean;
18
+ /** What it starts with (`sk-`); anything else is an issue before Save. */
19
+ prefix?: string;
20
+ placeholder?: string;
21
+ info?: ReactNode;
22
+ }
23
+ /** A sign-in that opens in a new tab and ends on an address the person pastes back. */
24
+ export interface ConnectionSignin {
25
+ /** Starts it on the server: the address to open and, when it varies, the one it ends on. */
26
+ start: () => Promise<{
27
+ url: string;
28
+ redirect?: string;
29
+ }>;
30
+ /** Where it ends when `start` does not say (`http://localhost:8765/`). */
31
+ redirect?: string;
32
+ /** Whose sign-in, for the button: `Sign in with ChatGPT`. */
33
+ provider?: string;
34
+ }
35
+ /** One way to connect. */
36
+ export interface ConnectionWay {
37
+ id: string;
38
+ /** `API key`, `ChatGPT plan`, `Service token`. */
39
+ label: string;
40
+ icon?: ComponentType<{
41
+ className?: string;
42
+ }>;
43
+ info?: ReactNode;
44
+ /** Set up now. */
45
+ set: boolean;
46
+ /** Who or what it is once set: an email, a key's last characters. */
47
+ ident?: ReactNode;
48
+ test?: ConnectionTest | null;
49
+ /** A short line in place of the actions (`From the server`, `Only an admin can change it`). */
50
+ note?: ReactNode;
51
+ /** Pasted values: the built-in form, saved by `onSave`. */
52
+ fields?: ConnectionField[];
53
+ /** Where a key is made, beside the empty fields. */
54
+ getUrl?: string;
55
+ signin?: ConnectionSignin;
56
+ /** Tests and keeps the values; false (the app said why) or a throw keeps the form open. */
57
+ onSave?: (values: Record<string, string>) => Promise<unknown>;
58
+ /** Finishes a sign-in from the pasted address; false or a throw keeps it open. */
59
+ onFinish?: (pasted: string) => Promise<unknown>;
60
+ /** One cheap call that says whether it still works; the app refreshes its data after. */
61
+ onTest?: () => Promise<unknown>;
62
+ /** Removes it (asks first). */
63
+ onRemove?: () => Promise<unknown>;
64
+ /** The confirm's question and action: `Sign out of ChatGPT?`, `Sign out`. */
65
+ removeTitle?: string;
66
+ removeText?: ReactNode;
67
+ removeLabel?: string;
68
+ /** The button that sets it up: `Add api key`, `Sign in with …` by default. */
69
+ addLabel?: string;
70
+ /** The app's own editor in place of `fields` and `signin`; `close` once saved or cancelled. */
71
+ form?: (close: () => void) => ReactNode;
72
+ /** Shown but not changed here (a person who is not an admin). */
73
+ readOnly?: boolean;
74
+ }
75
+ /** A label and value under the ways: `Server`, `Sent from`. */
76
+ export interface ConnectionLine {
77
+ label: string;
78
+ value: ReactNode;
79
+ info?: ReactNode;
80
+ mono?: boolean;
81
+ }
82
+ export interface LumenConnectionProps {
83
+ /** Its id inside a LumenExpandList. */
84
+ id?: string;
85
+ name: ReactNode;
86
+ /** The collapsed row's line; by default the way in use and who it is. */
87
+ meta?: ReactNode;
88
+ /** A logo or icon before the name. */
89
+ logo?: ReactNode;
90
+ ways: ConnectionWay[];
91
+ /** The way in use when several are set up; by default the first set up. */
92
+ use?: string;
93
+ /** Picks the way in use; with it and two ways or more the ways show radios. */
94
+ onUse?: (way: string) => unknown;
95
+ lines?: ConnectionLine[];
96
+ defaultOpen?: boolean;
97
+ children?: ReactNode;
98
+ }
99
+ export type ConnectionStatus = 'connected' | 'untested' | 'expired' | 'failed' | 'off';
100
+ /** Where a way stands: the row's pill. */
101
+ export declare function connectionStatus(way: ConnectionWay | undefined): ConnectionStatus;
102
+ /** The way in use: the picked one when it is set up, else the first set up. */
103
+ export declare function activeWay(ways: ConnectionWay[], use?: string): ConnectionWay | undefined;
104
+ /** The row's pill for a status. */
105
+ export declare function ConnectionBadge({ status, signin, }: {
106
+ status: ConnectionStatus;
107
+ signin?: boolean;
108
+ }): import("react").JSX.Element;
109
+ /** A connection's mark: an icon or a letter on a tile. */
110
+ export declare function ConnectionLogo({ icon: Icon, text, }: {
111
+ icon?: ComponentType<{
112
+ className?: string;
113
+ }>;
114
+ text?: string;
115
+ }): import("react").JSX.Element;
116
+ /** One connection: a row with its status, the ways to connect inside. */
117
+ export declare function LumenConnection({ id, name, meta, logo, ways, use, onUse, lines, defaultOpen, children, }: LumenConnectionProps): import("react").JSX.Element;
@@ -0,0 +1,229 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // One connection, one look on every app: an AI provider, an email relay, a Google account, a
3
+ // connector. A row that expands in place (LumenExpandRow) with one status pill; inside, each way to
4
+ // connect (a pasted key or token, a sign-in that ends on an address the person pastes back, or the
5
+ // app's own form). Several ways show radios and an In use tag, so a person who has both an API key and
6
+ // a plan sign-in picks which one runs. A way that is set up shows who or what it is and its last test,
7
+ // then Test, Remove (asks first) and Replace or Sign in again, all on the right. `lines` and
8
+ // `children` hold the app's own settings below the ways (a server, a sender, folders).
9
+ import { AlertTriangleIcon, CheckIcon, ExternalLinkIcon, KeyRoundIcon, LogInIcon, PlusIcon, RefreshCwIcon, Trash2Icon, XIcon, } from 'lucide-react';
10
+ import { useId, useState } from 'react';
11
+ import { LumenConfirm } from './confirm.js';
12
+ import { LumenExpandRow } from './expand-list.js';
13
+ import { SaveButton } from './form-issues.js';
14
+ import { IconButton } from './icon-button.js';
15
+ import { InfoLabel } from './info-tip.js';
16
+ import { ErrorLine, errorText, useClock } from './integrations.js';
17
+ import { cn } from './lib/utils.js';
18
+ import { lumenToast } from './lumen-toast.js';
19
+ import { Badge } from './ui/badge.js';
20
+ import { Button } from './ui/button.js';
21
+ import { Input } from './ui/input.js';
22
+ import { Label } from './ui/label.js';
23
+ import { RadioGroup, RadioGroupItem } from './ui/radio-group.js';
24
+ /** Where a way stands: the row's pill. */
25
+ export function connectionStatus(way) {
26
+ if (!way?.set)
27
+ return 'off';
28
+ if (!way.test)
29
+ return 'untested';
30
+ if (way.test.ok)
31
+ return 'connected';
32
+ return way.test.expired ? 'expired' : 'failed';
33
+ }
34
+ /** The way in use: the picked one when it is set up, else the first set up. */
35
+ export function activeWay(ways, use) {
36
+ return ways.find((w) => w.id === use && w.set) ?? ways.find((w) => w.set);
37
+ }
38
+ /** The row's pill for a status. */
39
+ export function ConnectionBadge({ status, signin, }) {
40
+ switch (status) {
41
+ case 'connected':
42
+ return (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Connected"] }));
43
+ case 'untested':
44
+ return _jsx(Badge, { variant: "outline", children: "Not tested" });
45
+ case 'expired':
46
+ return (_jsxs(Badge, { variant: "outline", className: "text-warning", children: [_jsx(AlertTriangleIcon, {}), signin ? 'Sign in again' : 'Key expired'] }));
47
+ case 'failed':
48
+ return (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] }));
49
+ default:
50
+ return (_jsx(Badge, { variant: "outline", className: "border-dashed text-muted-foreground", children: "Not connected" }));
51
+ }
52
+ }
53
+ /** A connection's mark: an icon or a letter on a tile. */
54
+ export function ConnectionLogo({ icon: Icon, text, }) {
55
+ return (_jsx("span", { "aria-hidden": true, className: "flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted font-semibold text-muted-foreground text-sm", children: Icon ? _jsx(Icon, { className: "size-4" }) : text }));
56
+ }
57
+ /** `just now`, `5 min ago`, `3 h ago`, `2 d ago`. */
58
+ function ago(ms) {
59
+ const s = Math.max(0, (Date.now() - ms) / 1000);
60
+ if (s < 60)
61
+ return 'just now';
62
+ if (s < 3600)
63
+ return `${Math.floor(s / 60)} min ago`;
64
+ if (s < 86400)
65
+ return `${Math.floor(s / 3600)} h ago`;
66
+ return `${Math.floor(s / 86400)} d ago`;
67
+ }
68
+ /** `Service token` → `service token`; `API key` stays. */
69
+ const lower = (label) => /^[A-Z][a-z]/.test(label) ? label.charAt(0).toLowerCase() + label.slice(1) : label;
70
+ const toMs = (at) => (typeof at === 'number' ? at : Date.parse(at));
71
+ /** One connection: a row with its status, the ways to connect inside. */
72
+ export function LumenConnection({ id, name, meta, logo, ways, use, onUse, lines, defaultOpen, children, }) {
73
+ const active = activeWay(ways, use);
74
+ const status = connectionStatus(active);
75
+ const pick = ways.length > 1 && !!onUse;
76
+ const [picking, setPicking] = useState(false);
77
+ const choose = async (way) => {
78
+ if (!onUse || way === active?.id)
79
+ return;
80
+ setPicking(true);
81
+ try {
82
+ await onUse(way);
83
+ }
84
+ catch (e) {
85
+ lumenToast.error(errorText(e));
86
+ }
87
+ finally {
88
+ setPicking(false);
89
+ }
90
+ };
91
+ const line = meta ??
92
+ (active ? (_jsxs(_Fragment, { children: [ways.length > 1 && active.label, ways.length > 1 && active.ident ? ' · ' : '', active.ident] })) : ('Not set up'));
93
+ const list = ways.map((w) => (_jsx(WayRow, { way: w, pick: pick, inUse: w.id === active?.id && ways.length > 1 }, w.id)));
94
+ return (_jsxs(LumenExpandRow, { id: id, name: name, meta: line, icon: logo, defaultOpen: defaultOpen, badge: _jsx(ConnectionBadge, { status: status, signin: !!active?.signin }), children: [_jsx("div", { className: "grid grid-cols-1 gap-2", children: pick ? (_jsx(RadioGroup, { value: active?.id ?? '', onValueChange: (v) => void choose(v), disabled: picking, "aria-label": `Which way ${typeof name === 'string' ? name : ''} connects`, className: "gap-2", children: list })) : (list) }), lines?.length ? (_jsx("dl", { className: "grid grid-cols-1 text-sm", children: lines.map((l) => (_jsxs("div", { className: "flex items-center gap-3 border-b py-2 last:border-b-0", children: [_jsx("dt", { className: "shrink-0 text-muted-foreground", children: _jsx(InfoLabel, { info: l.info, children: l.label }) }), _jsx("dd", { className: cn('min-w-0 flex-1 truncate text-right', l.mono && 'font-code text-xs'), title: typeof l.value === 'string' ? l.value : undefined, children: l.value })] }, l.label))) })) : null, children] }));
95
+ }
96
+ /** One way: its name and state, its actions on the right, its form when open. */
97
+ function WayRow({ way, pick, inUse }) {
98
+ const radioId = useId();
99
+ const clock = useClock();
100
+ const [editing, setEditing] = useState(false);
101
+ const [testing, setTesting] = useState(false);
102
+ const [error, setError] = useState(null);
103
+ const Icon = way.icon;
104
+ const status = connectionStatus(way);
105
+ const failing = status === 'failed' || status === 'expired';
106
+ const editable = !way.readOnly && !way.note;
107
+ const hasForm = !!(way.form || way.fields?.length || way.signin);
108
+ const test = async () => {
109
+ if (!way.onTest)
110
+ return;
111
+ setTesting(true);
112
+ setError(null);
113
+ try {
114
+ await way.onTest();
115
+ }
116
+ catch (e) {
117
+ setError(e);
118
+ }
119
+ finally {
120
+ setTesting(false);
121
+ }
122
+ };
123
+ const close = () => setEditing(false);
124
+ const fixLabel = way.signin ? 'Sign in again' : 'Replace';
125
+ const addLabel = way.addLabel ??
126
+ (way.signin
127
+ ? way.signin.provider
128
+ ? `Sign in with ${way.signin.provider}`
129
+ : 'Sign in'
130
+ : `Add ${lower(way.label)}`);
131
+ const t = way.test;
132
+ return (_jsxs("div", { className: cn('grid grid-cols-1 gap-2 rounded-md border px-3 py-2.5', inUse && 'border-primary/60 bg-primary/5'), children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [pick && (_jsx(RadioGroupItem, { id: radioId, value: way.id, disabled: !way.set, "aria-label": `Use ${way.label}` })), Icon && _jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "min-w-0 flex-1 basis-40", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [pick ? (_jsx(Label, { htmlFor: radioId, className: "whitespace-nowrap font-medium", children: _jsx(InfoLabel, { info: way.info, children: way.label }) })) : (_jsx(InfoLabel, { className: "whitespace-nowrap font-medium text-sm", info: way.info, children: way.label })), inUse && (_jsx(Badge, { variant: "outline", className: "h-5 shrink-0 border-primary/40 text-primary", children: "In use" }))] }), _jsx("span", { className: "flex min-w-0 items-center gap-1 text-muted-foreground text-xs", children: way.set ? (_jsxs(_Fragment, { children: [way.ident && (_jsx("span", { className: "truncate", title: typeof way.ident === 'string' ? way.ident : undefined, children: way.ident })), way.ident && _jsx("span", { "aria-hidden": true, children: "\u00B7" }), t ? (_jsxs("span", { className: cn('flex shrink-0 items-center gap-1', t.ok ? 'text-gain' : 'text-loss'), title: `Tested ${clock(new Date(toMs(t.at)).toISOString())}${t.ok && t.detail ? ` · ${t.detail}` : ''}`, children: [t.ok ? (_jsx(CheckIcon, { className: "size-3" })) : (_jsx(AlertTriangleIcon, { className: "size-3" })), t.ok ? 'Works' : t.expired ? 'Expired' : 'Failed', " ", ago(toMs(t.at))] })) : (_jsx("span", { className: "shrink-0", children: "Not tested" }))] })) : (_jsx("span", { children: "Not set up" })) })] }), way.note ? (_jsx("span", { className: "ml-auto shrink-0 text-muted-foreground text-xs", children: way.note })) : way.set ? (_jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [way.onTest && (_jsx(IconButton, { icon: RefreshCwIcon, label: testing ? 'Testing…' : `Test ${way.label}`, busy: testing, disabled: testing, onClick: () => void test() })), editable && way.onRemove && (_jsx(LumenConfirm, { title: way.removeTitle ?? `Remove ${lower(way.label)}?`, description: way.removeText ?? 'You can add it again any time.', action: way.removeLabel ?? 'Remove', onConfirm: () => way.onRemove?.(), problem: errorText, children: _jsx(IconButton, { icon: Trash2Icon, label: way.removeLabel ?? `Remove ${way.label}` }) })), editable && hasForm && !editing && (_jsxs(Button, { size: "sm", variant: failing ? 'default' : 'outline', onClick: () => setEditing(true), children: [way.signin ? _jsx(LogInIcon, {}) : _jsx(KeyRoundIcon, {}), fixLabel] }))] })) : (editable &&
133
+ hasForm &&
134
+ !editing && (_jsxs(Button, { size: "sm", className: "ml-auto shrink-0", onClick: () => setEditing(true), children: [way.signin ? _jsx(LogInIcon, {}) : _jsx(PlusIcon, {}), addLabel] })))] }), t && !t.ok && t.error && (_jsx("p", { role: "alert", className: "text-xs text-loss", children: t.error })), _jsx(ErrorLine, { error: error }), editing &&
135
+ (way.form ? (way.form(close)) : way.signin ? (_jsx(SigninForm, { way: way, close: close })) : (_jsx(FieldsForm, { way: way, close: close })))] }));
136
+ }
137
+ /** Runs a save; false or a throw keeps the form open (a throw says why). */
138
+ async function keep(run, setError) {
139
+ setError(null);
140
+ try {
141
+ return (await run()) !== false;
142
+ }
143
+ catch (e) {
144
+ setError(e);
145
+ return false;
146
+ }
147
+ }
148
+ /** The way's fields, checked before Test and save. */
149
+ function FieldsForm({ way, close }) {
150
+ const ids = useId();
151
+ const fields = way.fields ?? [];
152
+ const [values, setValues] = useState({});
153
+ const [busy, setBusy] = useState(false);
154
+ const [error, setError] = useState(null);
155
+ const val = (f) => (values[f.id] ?? '').trim();
156
+ const issues = [];
157
+ for (const f of fields) {
158
+ const v = val(f);
159
+ if (v && f.prefix && !v.startsWith(f.prefix))
160
+ issues.push({
161
+ where: f.label,
162
+ message: `It starts with ${f.prefix}`,
163
+ target: `${ids}-${f.id}`,
164
+ });
165
+ }
166
+ const empty = fields.some((f) => !val(f));
167
+ const save = async () => {
168
+ if (!way.onSave)
169
+ return;
170
+ setBusy(true);
171
+ const trimmed = Object.fromEntries(fields.map((f) => [f.id, val(f)]));
172
+ if (await keep(() => Promise.resolve(way.onSave?.(trimmed)), setError))
173
+ close();
174
+ setBusy(false);
175
+ };
176
+ return (_jsxs("div", { className: "grid grid-cols-1 gap-2 border-t pt-2", children: [fields.map((f) => (_jsxs("div", { className: "grid grid-cols-1 gap-1", children: [fields.length > 1 && (_jsx(Label, { htmlFor: `${ids}-${f.id}`, className: "text-xs", children: _jsx(InfoLabel, { info: f.info, children: f.label }) })), _jsx(Input, { id: `${ids}-${f.id}`, "aria-label": f.label, type: f.secret ? 'password' : 'text', autoComplete: "off", placeholder: f.placeholder ?? (f.prefix ? `${f.prefix}…` : f.label), className: "font-code text-xs", value: values[f.id] ?? '', onChange: (e) => setValues((s) => ({ ...s, [f.id]: e.target.value })), onKeyDown: (e) => e.key === 'Enter' && !empty && !issues.length && void save() })] }, f.id))), _jsx(ErrorLine, { error: error }), _jsxs("div", { className: "flex items-center justify-end gap-1", children: [way.getUrl && (_jsx(IconButton, { icon: ExternalLinkIcon, label: `Get ${/^[AEIOU]/i.test(way.label) ? 'an' : 'a'} ${lower(way.label)}`, href: way.getUrl })), _jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: close }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: empty, label: "Test and save", onSave: () => void save() })] })] }));
177
+ }
178
+ /** Open the sign-in in a new tab, then paste back the address it ended on. */
179
+ function SigninForm({ way, close }) {
180
+ const id = useId();
181
+ const signin = way.signin;
182
+ const [redirect, setRedirect] = useState(null);
183
+ const [pasted, setPasted] = useState('');
184
+ const [busy, setBusy] = useState(false);
185
+ const [error, setError] = useState(null);
186
+ const open = async () => {
187
+ // Open the tab inside the click, or the browser blocks it; point it once the address is known.
188
+ const tab = window.open('about:blank', '_blank');
189
+ if (tab)
190
+ tab.opener = null;
191
+ setError(null);
192
+ try {
193
+ const r = await signin.start();
194
+ if (tab)
195
+ tab.location.href = r.url;
196
+ else
197
+ window.open(r.url, '_blank', 'noopener');
198
+ setRedirect(r.redirect ?? signin.redirect ?? '');
199
+ }
200
+ catch (e) {
201
+ tab?.close();
202
+ setError(e);
203
+ }
204
+ };
205
+ const origin = (() => {
206
+ try {
207
+ return redirect ? new URL(redirect).origin : '';
208
+ }
209
+ catch {
210
+ return redirect ?? '';
211
+ }
212
+ })();
213
+ const issues = [];
214
+ const p = pasted.trim();
215
+ if (p && origin && !p.startsWith(origin))
216
+ issues.push({ where: 'Address', message: `It starts with ${redirect}`, target: id });
217
+ const finish = async () => {
218
+ if (!way.onFinish)
219
+ return;
220
+ setBusy(true);
221
+ if (await keep(() => Promise.resolve(way.onFinish?.(p)), setError))
222
+ close();
223
+ setBusy(false);
224
+ };
225
+ return (_jsxs("ol", { className: "grid grid-cols-1 gap-3 border-t pt-2 text-sm", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Step, { n: 1 }), _jsx("span", { className: "flex-1", children: "Sign in in a new tab" }), _jsxs(Button, { size: "sm", variant: redirect === null ? 'default' : 'outline', onClick: () => void open(), children: [_jsx(ExternalLinkIcon, {}), redirect === null ? 'Open sign-in' : 'Open again'] })] }), _jsxs("li", { className: "grid grid-cols-1 gap-2", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Step, { n: 2 }), _jsx(Label, { htmlFor: id, className: cn(redirect === null && 'text-muted-foreground'), children: _jsx(InfoLabel, { info: `The sign-in ends on ${redirect || signin.redirect || 'an address'} that does not load. That is expected: copy the whole address from that tab's address bar.`, children: "Paste the address it ended on" }) })] }), _jsx("span", { className: "flex gap-2 pl-7", children: _jsx(Input, { id: id, autoComplete: "off", disabled: redirect === null, placeholder: `${redirect || signin.redirect || 'http://localhost'}…`, className: "font-code text-xs", value: pasted, onChange: (e) => setPasted(e.target.value) }) })] }), _jsx(ErrorLine, { error: error }), _jsxs("li", { className: "flex items-center justify-end gap-1", children: [_jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: close }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !p, label: "Finish", onSave: () => void finish() })] })] }));
226
+ }
227
+ function Step({ n }) {
228
+ return (_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-xs tabular-nums", children: n }));
229
+ }
@@ -9,11 +9,13 @@ export declare function LumenExpandList({ defaultOpen, className, children, }: {
9
9
  * One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
10
10
  * `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
11
11
  */
12
- export declare function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenChange, defaultOpen, children, }: {
12
+ export declare function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen, children, }: {
13
13
  id?: string;
14
14
  name: ReactNode;
15
15
  meta?: ReactNode;
16
16
  badge?: ReactNode;
17
+ /** A logo or icon before the name. */
18
+ icon?: ReactNode;
17
19
  open?: boolean;
18
20
  onOpenChange?: (open: boolean) => void;
19
21
  defaultOpen?: boolean;
@@ -16,7 +16,7 @@ export function LumenExpandList({ defaultOpen = null, className, children, }) {
16
16
  * One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
17
17
  * `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
18
18
  */
19
- export function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenChange, defaultOpen = false, children, }) {
19
+ export function LumenExpandRow({ id, name, meta, badge, icon, open: openProp, onOpenChange, defaultOpen = false, children, }) {
20
20
  const list = useContext(ListCtx);
21
21
  const [own, setOwn] = useState(defaultOpen);
22
22
  const inList = list && id !== undefined;
@@ -29,5 +29,5 @@ export function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenCh
29
29
  else
30
30
  setOwn(o);
31
31
  };
32
- return (_jsxs(Collapsible, { open: open, onOpenChange: change, className: "border-b last:border-b-0", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-3 px-1 py-3 text-left hover:bg-muted/50", children: [_jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate font-medium", title: typeof name === 'string' ? name : undefined, children: name }), meta && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", title: typeof meta === 'string' ? meta : undefined, children: meta }))] }), badge, _jsx(ChevronRightIcon, { className: cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') })] }) }), _jsx(CollapsibleContent, { className: "grid grid-cols-1 gap-5 px-1 pt-1 pb-5", children: children })] }));
32
+ return (_jsxs(Collapsible, { open: open, onOpenChange: change, className: "border-b last:border-b-0", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-3 px-1 py-3 text-left hover:bg-muted/50", children: [icon, _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate font-medium", title: typeof name === 'string' ? name : undefined, children: name }), meta && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", title: typeof meta === 'string' ? meta : undefined, children: meta }))] }), badge, _jsx(ChevronRightIcon, { className: cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') })] }) }), _jsx(CollapsibleContent, { className: "grid grid-cols-1 gap-5 px-1 pt-1 pb-5", children: children })] }));
33
33
  }
package/dist/index.d.ts CHANGED
@@ -50,6 +50,7 @@ export { type Crumb, IconButton, type IconButtonProps, Page, PageBody, PageHeade
50
50
  export { DEFAULT_PREFS, deviceTimeZone, type Prefs, TIME_ZONES, type Theme, type TimeZoneId, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
51
51
  export { KIT, type KitConfig, configureLumen } from './kit-config.js';
52
52
  export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestResult, TestBadge, errorText, useClock, } from './integrations.js';
53
+ export { ConnectionBadge, type ConnectionField, type ConnectionLine, ConnectionLogo, type ConnectionSignin, type ConnectionStatus, type ConnectionTest, type ConnectionWay, LumenConnection, type LumenConnectionProps, activeWay, connectionStatus, } from './connection.js';
53
54
  export { LumenChatgptSignin, type LumenChatgptSigninProps } from './chatgpt-signin.js';
54
55
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
55
56
  export { type Fact, FactList, type FactListProps } from './fact-list.js';
@@ -69,7 +70,7 @@ export { WhatsNew } from './whats-new.js';
69
70
  export { type LumenApp, LumenAppSwitcher, LumenSuiteSwitcher, type SuiteApp, type SuiteList, suiteApps, } from './app-switcher.js';
70
71
  export { type BriefActions, type BriefChange, type ScheduleChange, type ScheduleItem, type ScheduleRun, SchedulesSection, } from './schedules.js';
71
72
  export { NotificationsSettings } from './notifications.js';
72
- export { type TasksConfig, TasksButton, TasksProvider, useTasks } from './tasks.js';
73
+ export { type TasksConfig, TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
73
74
  export { type McpServer, McpSettings } from './mcp-settings.js';
74
75
  export { lumenToast } from './lumen-toast.js';
75
76
  export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export { IconButton, Page, PageBody, PageHeader, } from './page.js';
57
57
  export { DEFAULT_PREFS, deviceTimeZone, TIME_ZONES, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
58
58
  export { KIT, configureLumen } from './kit-config.js';
59
59
  export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, errorText, useClock, } from './integrations.js';
60
+ export { ConnectionBadge, ConnectionLogo, LumenConnection, activeWay, connectionStatus, } from './connection.js';
60
61
  export { LumenChatgptSignin } from './chatgpt-signin.js';
61
62
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
62
63
  export { FactList } from './fact-list.js';
@@ -76,7 +77,7 @@ export { WhatsNew } from './whats-new.js';
76
77
  export { LumenAppSwitcher, LumenSuiteSwitcher, suiteApps, } from './app-switcher.js';
77
78
  export { SchedulesSection, } from './schedules.js';
78
79
  export { NotificationsSettings } from './notifications.js';
79
- export { TasksButton, TasksProvider, useTasks } from './tasks.js';
80
+ export { TasksButton, TasksProvider, useTaskFor, useTasks } from './tasks.js';
80
81
  export { McpSettings } from './mcp-settings.js';
81
82
  export { lumenToast } from './lumen-toast.js';
82
83
  export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidden, useApp, useShell, } from './shell.js';
@@ -1,7 +1,8 @@
1
1
  import type { PeopleAdapter } from './client.js';
2
- /** The Email section's body. In LumenSettings its badge sits in the header and Save in the footer;
3
- * `inline` draws both in the body instead. */
4
- export declare function LumenEmailSettings({ adapter, inline, }: {
2
+ /** The Email section's body: the relay as one connection (its key, its last check, Test, Remove),
3
+ * set up or changed in place through the four steps. */
4
+ export declare function LumenEmailSettings({ adapter, }: {
5
5
  adapter: PeopleAdapter;
6
+ /** Kept for apps that pass it; the steps always sit in the row now. */
6
7
  inline?: boolean;
7
8
  }): import("react").JSX.Element;
@@ -1,14 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { CableIcon, CheckIcon, CircleHelpIcon, GlobeIcon, MailIcon, PlugZapIcon, XIcon, } from 'lucide-react';
3
+ import { CableIcon, CheckIcon, CircleHelpIcon, GlobeIcon, KeyRoundIcon, MailIcon, PlugZapIcon, XIcon, } from 'lucide-react';
4
4
  import { useEffect, useId, useState } from 'react';
5
+ import { ConnectionLogo, LumenConnection } from '../connection.js';
6
+ import { LumenExpandList } from '../expand-list.js';
5
7
  import { SaveButton } from '../form-issues.js';
8
+ import { IconButton } from '../icon-button.js';
6
9
  import { InfoLabel } from '../info-tip.js';
7
10
  import { errorText } from '../integrations.js';
8
11
  import { LumenChoice } from '../lumen-choice.js';
9
12
  import { lumenToast } from '../lumen-toast.js';
10
- import { SettingsActions, SettingsFooter } from '../settings.js';
11
- import { Badge } from '../ui/badge.js';
12
13
  import { Button } from '../ui/button.js';
13
14
  import { Input } from '../ui/input.js';
14
15
  import { Spinner } from '../ui/spinner.js';
@@ -28,19 +29,68 @@ const same = (a, b) => a.route === b.route &&
28
29
  (a.apiKey ?? '') === (b.apiKey ?? '') &&
29
30
  a.from === b.from &&
30
31
  a.fromName === b.fromName;
31
- /** The Email section's body. In LumenSettings its badge sits in the header and Save in the footer;
32
- * `inline` draws both in the body instead. */
33
- export function LumenEmailSettings({ adapter, inline = false, }) {
32
+ /** The Email section's body: the relay as one connection (its key, its last check, Test, Remove),
33
+ * set up or changed in place through the four steps. */
34
+ export function LumenEmailSettings({ adapter, }) {
34
35
  const api = adapter.email;
36
+ const qc = useQueryClient();
37
+ const key = ['lumen-people', adapter.key, 'email'];
35
38
  const q = useQuery({
36
- queryKey: ['lumen-people', adapter.key, 'email'],
39
+ queryKey: key,
37
40
  queryFn: () => api?.view() ?? Promise.reject(new Error('Email settings are not set up here.')),
38
41
  });
39
- if (!q.data)
42
+ if (!q.data || !api)
40
43
  return (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isError ? errorText(q.error) : 'Loading…' }));
41
- return _jsx(EmailForm, { adapter: adapter, view: q.data, inline: inline });
44
+ const view = q.data;
45
+ const cur = view.current;
46
+ const last = view.lastTest;
47
+ const refresh = () => qc.invalidateQueries({ queryKey: ['lumen-people', adapter.key] });
48
+ const failed = last?.steps.find((x) => x.ok === false);
49
+ return (_jsx(LumenExpandList, { defaultOpen: cur ? null : 'email', children: _jsx(LumenConnection, { id: "email", name: "Email relay", logo: _jsx(ConnectionLogo, { icon: MailIcon }), meta: cur
50
+ ? `${cur.fromName ? `${cur.fromName} · ` : ''}${cur.from}`
51
+ : 'Invites and alerts are not emailed', ways: [
52
+ {
53
+ id: 'api',
54
+ label: 'Email API',
55
+ icon: KeyRoundIcon,
56
+ info: 'A Resend-compatible email API: Resend itself or a self-hosted one.',
57
+ set: !!cur,
58
+ ident: cur?.keyHint,
59
+ test: last
60
+ ? {
61
+ at: last.at,
62
+ ok: last.ok,
63
+ detail: last.kind === 'test' ? 'Test email sent' : 'Connection checked',
64
+ error: last.ok ? null : (failed?.text ?? 'The last check failed.'),
65
+ }
66
+ : null,
67
+ addLabel: 'Set up',
68
+ onTest: async () => {
69
+ const check = await api.check(blank(view));
70
+ await refresh();
71
+ if (!check.ok)
72
+ throw new Error(check.steps.find((x) => x.ok === false)?.text ?? 'The check failed.');
73
+ },
74
+ onRemove: async () => {
75
+ await api.clear();
76
+ await refresh();
77
+ },
78
+ removeTitle: 'Remove the email relay?',
79
+ removeText: 'Nothing is emailed until it is set up again.',
80
+ form: (close) => _jsx(EmailForm, { adapter: adapter, view: view, onDone: close }),
81
+ },
82
+ ], lines: cur
83
+ ? [
84
+ {
85
+ label: 'Reached through',
86
+ value: view.routes.find((r) => r.id === cur.route)?.label ?? cur.route,
87
+ },
88
+ { label: 'Server', value: cur.url, mono: true },
89
+ { label: 'Saved', value: `${cur.updatedBy ?? '—'} ${when(cur.updatedAt)}` },
90
+ ]
91
+ : undefined }) }));
42
92
  }
43
- function EmailForm({ adapter, view, inline, }) {
93
+ function EmailForm({ adapter, view, onDone, }) {
44
94
  const api = adapter.email;
45
95
  const qc = useQueryClient();
46
96
  const ids = useId();
@@ -89,6 +139,7 @@ function EmailForm({ adapter, view, inline, }) {
89
139
  setReplacing(false);
90
140
  setResult(null);
91
141
  lumenToast.success('Email saved. Invites now go out by email.');
142
+ onDone();
92
143
  }
93
144
  catch (e) {
94
145
  lumenToast.error(errorText(e));
@@ -111,18 +162,12 @@ function EmailForm({ adapter, view, inline, }) {
111
162
  const ready = issues.length === 0;
112
163
  if (ready && changed && !passed)
113
164
  issues.push({ where: 'Test it', message: 'Check the connection or send a test first' });
114
- const badge = passed ? (_jsx(Badge, { variant: "secondary", children: "Ready to save" })) : view.current ? (view.lastTest && !view.lastTest.ok ? (_jsx(Badge, { variant: "destructive", children: "Last test failed" })) : (_jsx(Badge, { variant: "secondary", className: "text-gain", children: "Working" }))) : (_jsx(Badge, { variant: "outline", children: "Not set up" }));
115
165
  const footer = (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: passed
116
166
  ? 'Passed'
117
167
  : view.current
118
168
  ? `Saved by ${view.current.updatedBy ?? '—'} ${when(view.current.updatedAt)}`
119
- : 'Test to save' }), changed && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
120
- setDraft(blank(view));
121
- setReplacing(!view.current);
122
- setPerRoute({});
123
- setResult(null);
124
- }, children: "Revert" })), _jsx(SaveButton, { issues: issues, busy: busy === 'save', disabled: !changed, onSave: save })] }));
125
- return (_jsxs("div", { className: "flex flex-col", children: [inline ? (_jsx("div", { className: "flex justify-end pb-2", children: badge })) : (_jsx(SettingsActions, { children: badge })), _jsx(Step, { n: 1, title: "Reach the server through", children: _jsx(LumenChoice, { label: "Reach the server through", value: draft.route, options: view.routes.map((r) => ({
169
+ : 'Test to save' }), _jsx(IconButton, { icon: XIcon, label: "Cancel", onClick: onDone }), _jsx(SaveButton, { issues: issues, busy: busy === 'save', disabled: !changed, onSave: save })] }));
170
+ return (_jsxs("div", { className: "flex flex-col border-t", children: [_jsx(Step, { n: 1, title: "Reach the server through", children: _jsx(LumenChoice, { label: "Reach the server through", value: draft.route, options: view.routes.map((r) => ({
126
171
  value: r.id,
127
172
  icon: r.id === 'public' ? GlobeIcon : CableIcon,
128
173
  title: (_jsxs("span", { className: "flex w-full items-center gap-2", children: [r.label, r.state && (_jsx("span", { className: "ml-auto font-normal text-gain text-xs", children: r.state }))] })),
@@ -143,7 +188,7 @@ function EmailForm({ adapter, view, inline, }) {
143
188
  } }) }), _jsxs(Step, { n: 2, title: "Email server", children: [_jsx(Labelled, { id: `${ids}-url`, label: _jsx(InfoLabel, { info: route?.urlInfo, children: "Server address" }), children: _jsx(Input, { id: `${ids}-url`, className: "font-code text-xs", value: draft.url, placeholder: route?.url ?? 'https://api.resend.com', onChange: (e) => set({ url: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-key`, label: "API key", children: replacing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-key`, type: "password", autoComplete: "off", className: "font-code text-xs", value: draft.apiKey ?? '', onChange: (e) => set({ apiKey: e.target.value }) }), view.current && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
144
189
  setReplacing(false);
145
190
  set({ apiKey: '' });
146
- }, children: "Keep" }))] })) : (_jsxs("div", { className: "flex h-9 items-center gap-3 rounded-md border px-3 text-sm", children: [_jsx("span", { className: "font-code text-muted-foreground text-xs", children: view.current?.keyHint }), _jsx("span", { className: "ml-auto text-gain text-xs", children: "sealed" }), _jsx(Button, { size: "xs", variant: "link", onClick: () => setReplacing(true), children: "Replace" })] })) })] }), _jsx(Step, { n: 3, title: "Sent from", children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row", children: [_jsx(Labelled, { id: `${ids}-name`, label: "Name", className: "sm:flex-1", children: _jsx(Input, { id: `${ids}-name`, value: draft.fromName, onChange: (e) => set({ fromName: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-from`, label: "Address", className: "sm:flex-[1.4]", children: _jsx(Input, { id: `${ids}-from`, type: "email", value: draft.from, placeholder: "hello@example.com", onChange: (e) => set({ from: e.target.value }) }) })] }) }), _jsxs(Step, { n: 4, title: "Test it", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('check'), children: [busy === 'check' ? _jsx(Spinner, {}) : _jsx(PlugZapIcon, {}), " Check connection"] }) }), _jsx(Labelled, { id: `${ids}-test-to`, label: "Send a test email to", children: _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-test-to`, type: "email", className: "text-sm", value: testTo, placeholder: view.me, onChange: (e) => setTestTo(e.target.value) }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy || !testTo.trim(), onClick: () => run('test'), children: [busy === 'test' ? _jsx(Spinner, {}) : _jsx(MailIcon, {}), " Send test"] })] }) }), result && same(result.for, draft) && _jsx(Steps, { check: result.check }), !result && view.lastTest && (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["Last ", view.lastTest.kind === 'test' ? 'test' : 'check', " ", when(view.lastTest.at), ":", ' ', view.lastTest.ok ? 'passed' : 'failed', "."] }))] }), inline ? (_jsx("div", { className: "border-t pt-3", children: footer })) : (_jsx(SettingsFooter, { children: footer }))] }));
191
+ }, children: "Keep" }))] })) : (_jsxs("div", { className: "flex h-9 items-center gap-3 rounded-md border px-3 text-sm", children: [_jsx("span", { className: "font-code text-muted-foreground text-xs", children: view.current?.keyHint }), _jsx("span", { className: "ml-auto text-gain text-xs", children: "sealed" }), _jsx(Button, { size: "xs", variant: "link", onClick: () => setReplacing(true), children: "Replace" })] })) })] }), _jsx(Step, { n: 3, title: "Sent from", children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row", children: [_jsx(Labelled, { id: `${ids}-name`, label: "Name", className: "sm:flex-1", children: _jsx(Input, { id: `${ids}-name`, value: draft.fromName, onChange: (e) => set({ fromName: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-from`, label: "Address", className: "sm:flex-[1.4]", children: _jsx(Input, { id: `${ids}-from`, type: "email", value: draft.from, placeholder: "hello@example.com", onChange: (e) => set({ from: e.target.value }) }) })] }) }), _jsxs(Step, { n: 4, title: "Test it", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('check'), children: [busy === 'check' ? _jsx(Spinner, {}) : _jsx(PlugZapIcon, {}), " Check connection"] }) }), _jsx(Labelled, { id: `${ids}-test-to`, label: "Send a test email to", children: _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-test-to`, type: "email", className: "text-sm", value: testTo, placeholder: view.me, onChange: (e) => setTestTo(e.target.value) }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy || !testTo.trim(), onClick: () => run('test'), children: [busy === 'test' ? _jsx(Spinner, {}) : _jsx(MailIcon, {}), " Send test"] })] }) }), result && same(result.for, draft) && _jsx(Steps, { check: result.check }), !result && view.lastTest && (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["Last ", view.lastTest.kind === 'test' ? 'test' : 'check', " ", when(view.lastTest.at), ":", ' ', view.lastTest.ok ? 'passed' : 'failed', "."] }))] }), _jsx("div", { className: "border-t pt-3", children: footer })] }));
147
192
  }
148
193
  function Step({ n, title, children }) {
149
194
  return (_jsxs("section", { className: "flex gap-4 border-t py-5 first-of-type:border-t-0", "aria-label": title, children: [_jsx("span", { className: "num flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-xs", children: n }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: [_jsx("h3", { className: "font-medium text-sm", children: title }), children] })] }));
package/dist/tasks.d.ts CHANGED
@@ -15,11 +15,18 @@ interface TasksState {
15
15
  /** Deletes one ended task, or every ended one; open tasks stay. */
16
16
  clear: (id?: string) => Promise<void>;
17
17
  open: (t: Task) => void;
18
+ /** Shows a task the app just started (its POST's answer) at once, before the next read, so its
19
+ * end toasts even when it finishes within one poll. */
20
+ track: (t: Task) => void;
18
21
  /** `TasksConfig.apps`. */
19
22
  apps?: Record<string, string>;
20
23
  }
21
24
  /** The Tasks list, or null where the app has none; `refresh()` after starting one shows it at once. */
22
25
  export declare function useTasks(): TasksState | null;
26
+ /** The newest task for one piece of work (`kind` + the engine's `ref`, such as a saved search's
27
+ * id), so the button that starts it can show it running (`Running 1/3`) and come back when it
28
+ * ends. Null when there is none or the app has no Tasks. */
29
+ export declare function useTaskFor(kind: string, ref: string | null | undefined): Task | null;
23
30
  export declare function TasksProvider({ config, children }: {
24
31
  config?: TasksConfig;
25
32
  children: ReactNode;
package/dist/tasks.js CHANGED
@@ -30,6 +30,15 @@ const TasksCtx = createContext(null);
30
30
  export function useTasks() {
31
31
  return useContext(TasksCtx);
32
32
  }
33
+ /** The newest task for one piece of work (`kind` + the engine's `ref`, such as a saved search's
34
+ * id), so the button that starts it can show it running (`Running 1/3`) and come back when it
35
+ * ends. Null when there is none or the app has no Tasks. */
36
+ export function useTaskFor(kind, ref) {
37
+ const tasks = useTasks();
38
+ if (!tasks?.list || !ref)
39
+ return null;
40
+ return tasks.list.tasks.find((t) => t.kind === kind && t.ref === ref) ?? null;
41
+ }
33
42
  const POLL_MS = 3000;
34
43
  export function TasksProvider({ config, children }) {
35
44
  if (!config)
@@ -84,6 +93,18 @@ function TasksLive({ api, apps, children, }) {
84
93
  activeIds.current = new Set(next.tasks.filter(isActive).map((t) => t.id));
85
94
  setList(next);
86
95
  }, [api, open, qc]);
96
+ const track = useCallback((t) => {
97
+ activeIds.current = new Set([...(activeIds.current ?? []), t.id]);
98
+ setList((l) => {
99
+ const tasks = [t, ...(l?.tasks ?? []).filter((x) => x.id !== t.id)];
100
+ return {
101
+ tasks,
102
+ active: tasks.filter(isActive).length,
103
+ unseen: tasks.filter((x) => x.finished_at && !x.seen_at).length,
104
+ };
105
+ });
106
+ void refresh();
107
+ }, [refresh]);
87
108
  const post = useCallback(async (path) => {
88
109
  await fetch(`${api}${path}`, {
89
110
  method: 'POST',
@@ -137,7 +158,7 @@ function TasksLive({ api, apps, children, }) {
137
158
  }, POLL_MS);
138
159
  return () => window.clearInterval(id);
139
160
  }, [active, refresh]);
140
- const value = useMemo(() => (gone ? null : { list, refresh, cancel, seen, clear, open, apps }), [gone, list, refresh, cancel, seen, clear, open, apps]);
161
+ const value = useMemo(() => (gone ? null : { list, refresh, cancel, seen, clear, open, track, apps }), [gone, list, refresh, cancel, seen, clear, open, track, apps]);
141
162
  return _jsx(TasksCtx.Provider, { value: value, children: children });
142
163
  }
143
164
  function endedToast(t, open) {
@@ -0,0 +1,5 @@
1
+ import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
2
+ import type * as React from 'react';
3
+ declare function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>): React.JSX.Element;
4
+ declare function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>): React.JSX.Element;
5
+ export { RadioGroup, RadioGroupItem };
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from '../lib/utils.js';
3
+ import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
4
+ function RadioGroup({ className, ...props }) {
5
+ return (_jsx(RadioGroupPrimitive.Root, { "data-slot": "radio-group", className: cn('grid gap-2', className), ...props }));
6
+ }
7
+ function RadioGroupItem({ className, ...props }) {
8
+ return (_jsx(RadioGroupPrimitive.Item, { "data-slot": "radio-group-item", className: cn('border-input dark:bg-input/30 data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive peer relative flex size-4 shrink-0 items-center justify-center rounded-full border outline-none transition-colors after:absolute after:-inset-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50', className), ...props, children: _jsx(RadioGroupPrimitive.Indicator, { "data-slot": "radio-group-indicator", className: "flex items-center justify-center", children: _jsx("span", { className: "size-2 rounded-full bg-primary" }) }) }));
9
+ }
10
+ export { RadioGroup, RadioGroupItem };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
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.91",
52
+ "@columenlabs/lumen-ui-types": "0.1.93",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",