@columenlabs/lumen-ui 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant/client.d.ts +40 -0
- package/dist/assistant/client.js +80 -0
- package/dist/assistant/index.d.ts +4 -0
- package/dist/assistant/index.js +7 -0
- package/dist/assistant/lumen-assistant.d.ts +44 -0
- package/dist/assistant/lumen-assistant.js +209 -0
- package/dist/assistant/settings.d.ts +13 -0
- package/dist/assistant/settings.js +321 -0
- package/dist/assistant/use-assistant-chat.d.ts +26 -0
- package/dist/assistant/use-assistant-chat.js +230 -0
- package/dist/expand-list.d.ts +21 -0
- package/dist/expand-list.js +33 -0
- package/dist/hooks/use-back-close.d.ts +19 -0
- package/dist/hooks/use-back-close.js +53 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrations.d.ts +3 -9
- package/dist/integrations.js +3 -4
- package/dist/lumen-drawer.js +1 -1
- package/dist/lumen-filter.js +1 -1
- package/dist/lumen-selector.js +2 -2
- package/dist/tasks.js +1 -1
- package/dist/ui/alert-dialog.d.ts +1 -1
- package/dist/ui/alert-dialog.js +5 -2
- package/dist/ui/dialog.d.ts +1 -1
- package/dist/ui/dialog.js +9 -4
- package/dist/ui/sheet.d.ts +3 -2
- package/dist/ui/sheet.js +85 -14
- package/package.json +6 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CheckIcon, ExternalLinkIcon, GlobeIcon, KeyRoundIcon, LayersIcon, ReceiptIcon, SparklesIcon, Trash2Icon, WrenchIcon, } from 'lucide-react';
|
|
4
|
+
import { useId, useState } from 'react';
|
|
5
|
+
import { LumenExpandList, LumenExpandRow } from '../expand-list.js';
|
|
6
|
+
import { SaveButton } from '../form-issues.js';
|
|
7
|
+
import { IconButton } from '../icon-button.js';
|
|
8
|
+
import { InfoLabel } from '../info-tip.js';
|
|
9
|
+
import { KeyTestLine, TestBadge, errorText } from '../integrations.js';
|
|
10
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
11
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
12
|
+
import { Badge } from '../ui/badge.js';
|
|
13
|
+
import { Input } from '../ui/input.js';
|
|
14
|
+
import { Label } from '../ui/label.js';
|
|
15
|
+
import { Switch } from '../ui/switch.js';
|
|
16
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
17
|
+
import { assistantStatusKey, usd, useAssistantStatus } from './lumen-assistant.js';
|
|
18
|
+
const EFFORTS = [
|
|
19
|
+
{ value: 'default', label: 'Model default' },
|
|
20
|
+
{ value: 'low', label: 'Low' },
|
|
21
|
+
{ value: 'medium', label: 'Medium' },
|
|
22
|
+
{ value: 'high', label: 'High' },
|
|
23
|
+
{ value: 'max', label: 'Max' },
|
|
24
|
+
];
|
|
25
|
+
const HAND_OFF = [
|
|
26
|
+
{ value: 'next', label: 'Next tier up' },
|
|
27
|
+
{ value: 'any', label: 'Any higher tier' },
|
|
28
|
+
{ value: 'ask', label: 'Only when I ask' },
|
|
29
|
+
];
|
|
30
|
+
const ACCESS = [
|
|
31
|
+
{ value: 'off', label: 'Off' },
|
|
32
|
+
{ value: 'read', label: 'Read' },
|
|
33
|
+
{ value: 'draft', label: 'Read + draft' },
|
|
34
|
+
];
|
|
35
|
+
/** Runs a change and keeps the status it returns, or says why it failed. */
|
|
36
|
+
function useApply(client) {
|
|
37
|
+
const qc = useQueryClient();
|
|
38
|
+
const [busy, setBusy] = useState(null);
|
|
39
|
+
const apply = async (what, run, done) => {
|
|
40
|
+
setBusy(what);
|
|
41
|
+
try {
|
|
42
|
+
qc.setQueryData(assistantStatusKey(client), await run());
|
|
43
|
+
if (done)
|
|
44
|
+
lumenToast.success(done);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
lumenToast.error(errorText(e));
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
setBusy(null);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const refresh = () => qc.invalidateQueries({ queryKey: assistantStatusKey(client) });
|
|
56
|
+
return { apply, busy, refresh };
|
|
57
|
+
}
|
|
58
|
+
/** The section's body once the status loads. */
|
|
59
|
+
function WithStatus({ client, children, }) {
|
|
60
|
+
const q = useAssistantStatus(client);
|
|
61
|
+
if (!q.data)
|
|
62
|
+
return (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isError ? errorText(q.error) : 'Loading…' }));
|
|
63
|
+
return _jsx(_Fragment, { children: children(q.data) });
|
|
64
|
+
}
|
|
65
|
+
/** One setting: its label (with an (i) when `info` says more) and its control. */
|
|
66
|
+
function Row({ label, info, children }) {
|
|
67
|
+
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 })] }));
|
|
68
|
+
}
|
|
69
|
+
const testOf = (k) => k.test
|
|
70
|
+
? {
|
|
71
|
+
at: new Date(k.test.at).toISOString(),
|
|
72
|
+
ok: k.test.ok,
|
|
73
|
+
detail: k.test.detail,
|
|
74
|
+
error: k.test.ok ? null : k.test.detail,
|
|
75
|
+
}
|
|
76
|
+
: null;
|
|
77
|
+
// ---------------------------------------------------------------- the assistant
|
|
78
|
+
function General({ client }) {
|
|
79
|
+
const { apply } = useApply(client);
|
|
80
|
+
const set = (patch) => apply('settings', () => client.settings(patch));
|
|
81
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
82
|
+
const above = s.tiers.slice(s.tiers.findIndex((t) => t.id === s.tier) + 1);
|
|
83
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsx(Row, { label: "Tier", children: _jsx(LumenSelector, { label: "Tier", size: "sm", className: "w-44", value: s.tier, options: s.tiers.map((t) => ({
|
|
84
|
+
value: t.id,
|
|
85
|
+
label: t.label,
|
|
86
|
+
detail: t.model_label,
|
|
87
|
+
})), onChange: (tier) => void set({ tier }) }) }), s.tiers.length > 1 && (_jsx(Row, { label: "Hand off the work", info: "On its own, a higher tier takes one task in its own section of the chat and sends back a memo. It costs more.", children: _jsx(LumenSelector, { label: "Hand off the work", size: "sm", className: "w-44", value: s.hand_off, options: HAND_OFF, onChange: (hand_off) => void set({ hand_off }) }) })), s.tiers.length > 1 && (_jsx(Row, { label: "Ask for advice", info: "A bigger tier reads the chat and sends a plan before hard questions. It runs no tools, at most twice an answer.", children: _jsx(LumenSelector, { label: "Ask for advice", size: "sm", className: "w-44", value: s.advice, options: [
|
|
88
|
+
{ value: 'off', label: 'Off' },
|
|
89
|
+
...(above.length ? above : s.tiers).map((t) => ({
|
|
90
|
+
value: t.id,
|
|
91
|
+
label: t.label,
|
|
92
|
+
})),
|
|
93
|
+
], onChange: (advice) => void set({ advice }) }) }))] }));
|
|
94
|
+
} }));
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------- model providers
|
|
97
|
+
/** Paste a key, Save tests it first; then its hint, Test and Remove. */
|
|
98
|
+
function KeyBlock({ label, info, prefix, provider, state, canEdit, onSave, onRemove, onTest, keysUrl, }) {
|
|
99
|
+
const id = useId();
|
|
100
|
+
const [key, setKey] = useState('');
|
|
101
|
+
const [replacing, setReplacing] = useState(false);
|
|
102
|
+
const [busy, setBusy] = useState(false);
|
|
103
|
+
const editing = canEdit && (!state.set || replacing);
|
|
104
|
+
const issues = [];
|
|
105
|
+
if (editing && key.trim() && !key.trim().startsWith(prefix))
|
|
106
|
+
issues.push({ where: label, message: `An ${provider} key starts with ${prefix}`, target: id });
|
|
107
|
+
const save = async () => {
|
|
108
|
+
setBusy(true);
|
|
109
|
+
if (await onSave(key.trim())) {
|
|
110
|
+
setKey('');
|
|
111
|
+
setReplacing(false);
|
|
112
|
+
}
|
|
113
|
+
setBusy(false);
|
|
114
|
+
};
|
|
115
|
+
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 &&
|
|
116
|
+
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) })] })] })))] }));
|
|
117
|
+
}
|
|
118
|
+
/** A dollar cap a month, saved on Enter or leaving the field. */
|
|
119
|
+
function CapInput({ label, value, onSave, }) {
|
|
120
|
+
const [text, setText] = useState(String(value));
|
|
121
|
+
const commit = () => {
|
|
122
|
+
const n = Number(text);
|
|
123
|
+
if (Number.isFinite(n) && n >= 0 && n !== value)
|
|
124
|
+
onSave(Math.round(n * 100) / 100);
|
|
125
|
+
else
|
|
126
|
+
setText(String(value));
|
|
127
|
+
};
|
|
128
|
+
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" })] }));
|
|
129
|
+
}
|
|
130
|
+
/** A person's ChatGPT plan sign-in: opens in a new tab, finished by pasting back where it ended. */
|
|
131
|
+
function ChatgptSignin({ client, p }) {
|
|
132
|
+
const { apply, refresh } = useApply(client);
|
|
133
|
+
const id = useId();
|
|
134
|
+
const [started, setStarted] = useState(null);
|
|
135
|
+
const [pasted, setPasted] = useState('');
|
|
136
|
+
const [busy, setBusy] = useState(false);
|
|
137
|
+
const signedIn = p.own.via === 'chatgpt';
|
|
138
|
+
const start = async () => {
|
|
139
|
+
// Open the tab inside the click, or the browser blocks it; point it once the address is known.
|
|
140
|
+
const tab = window.open('about:blank', '_blank');
|
|
141
|
+
if (tab)
|
|
142
|
+
tab.opener = null;
|
|
143
|
+
try {
|
|
144
|
+
const { url, redirect } = await client.startSignin(p.id);
|
|
145
|
+
if (tab)
|
|
146
|
+
tab.location.href = url;
|
|
147
|
+
else
|
|
148
|
+
window.open(url, '_blank', 'noopener');
|
|
149
|
+
setStarted(redirect);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
tab?.close();
|
|
153
|
+
lumenToast.error(errorText(e));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const finish = async () => {
|
|
157
|
+
setBusy(true);
|
|
158
|
+
if (await apply(`signin:${p.id}`, () => client.finishSignin(p.id, pasted.trim()), 'Signed in')) {
|
|
159
|
+
setPasted('');
|
|
160
|
+
setStarted(null);
|
|
161
|
+
}
|
|
162
|
+
setBusy(false);
|
|
163
|
+
};
|
|
164
|
+
const issues = [];
|
|
165
|
+
if (pasted.trim() && started && !pasted.trim().startsWith(started.split('/auth')[0] ?? ''))
|
|
166
|
+
issues.push({ where: 'Address', message: `It starts with ${started}`, target: id });
|
|
167
|
+
if (signedIn)
|
|
168
|
+
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: "Your ChatGPT plan" }), _jsx("span", { className: "text-muted-foreground text-xs", children: p.own.hint }), p.own.test && _jsx(TestBadge, { test: p.own.test })] }), _jsx(KeyTestLine, { test: testOf(p.own), run: () => client.testOwnKey(p.id).finally(refresh) }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(IconButton, { icon: Trash2Icon, label: "Sign out of ChatGPT", variant: "outline", onClick: () => void apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Signed out') }), _jsx(SaveButton, { issues: [], icon: KeyRoundIcon, label: "Sign in again", onSave: start })] }), started && (_jsx(SigninPaste, { id: id, redirect: started, pasted: pasted, setPasted: setPasted, issues: issues, busy: busy, finish: finish }))] }));
|
|
169
|
+
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: "Run OpenAI models on your own ChatGPT plan instead of an API key.", children: "Your ChatGPT plan" }), _jsx(Badge, { variant: "outline", children: "Not signed in" })] }), _jsx("div", { className: "flex justify-end", children: _jsx(SaveButton, { issues: [], icon: ExternalLinkIcon, label: started ? 'Open the sign-in again' : 'Sign in with ChatGPT', onSave: start }) }), started && (_jsx(SigninPaste, { id: id, redirect: started, pasted: pasted, setPasted: setPasted, issues: issues, busy: busy, finish: finish }))] }));
|
|
170
|
+
}
|
|
171
|
+
/** Where the sign-in tab ends, and the box its address is pasted into. */
|
|
172
|
+
function SigninPaste({ id, redirect, pasted, setPasted, issues, busy, finish, }) {
|
|
173
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: id, children: _jsx(InfoLabel, { info: `The sign-in ends on ${redirect}, a page that does not load. That is expected: copy the whole address from that tab's address bar.`, children: "Paste the address the sign-in ended on" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, autoComplete: "off", placeholder: `${redirect}?code=…`, className: "font-code text-xs", value: pasted, onChange: (e) => setPasted(e.target.value) }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !pasted.trim(), label: "Finish", onSave: finish })] })] }));
|
|
174
|
+
}
|
|
175
|
+
/** One provider, collapsed to a row: where to make a key (a new tab), the app's key and the person's. */
|
|
176
|
+
function ProviderKeys({ client, p, admin, }) {
|
|
177
|
+
const { apply, refresh } = useApply(client);
|
|
178
|
+
const appOn = p.app.set || !!p.app.from_env;
|
|
179
|
+
const plan = p.own.via === 'chatgpt';
|
|
180
|
+
const meta = [
|
|
181
|
+
appOn ? 'Shared key set' : 'No shared key',
|
|
182
|
+
plan ? 'your ChatGPT plan' : p.own.set ? 'your own key' : null,
|
|
183
|
+
]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join(' · ');
|
|
186
|
+
const failed = [p.app, p.own].some((k) => k.set && k.test && !k.test.ok);
|
|
187
|
+
const badge = failed ? (_jsx(TestBadge, { test: { ok: false } })) : appOn || p.own.set ? (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] })) : (_jsx(Badge, { variant: "outline", children: "Not set" }));
|
|
188
|
+
const ownKey = (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your own API key' : 'Your own 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) }));
|
|
189
|
+
return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: meta, badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [_jsx(KeyBlock, { label: "Shared key", info: "An admin sets it once; everyone uses it, within each person's monthly cap.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: admin, 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) }), p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && ownKey] }) }));
|
|
190
|
+
}
|
|
191
|
+
function Providers({ client }) {
|
|
192
|
+
const { apply } = useApply(client);
|
|
193
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
194
|
+
const ownSet = s.providers.some((p) => p.own.set);
|
|
195
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(ProviderKeys, { client: client, p: p, admin: s.admin }, p.id))) }), s.admin && (_jsx(Row, { label: "Each person may spend", info: "On the shared keys, every provider together.", children: _jsx(CapInput, { label: "Cap per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })), ownSet && (_jsx(Row, { label: "Your keys may spend", info: "On your own keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) })), _jsx(Row, { label: "Pay with first", info: "When it reaches its cap this month, or has no key for the tier's provider, the other pays.", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: s.pay, "aria-label": "Pay with", onValueChange: (v) => v && void apply('pay', () => client.settings({ pay: v })), children: [_jsx(ToggleGroupItem, { value: "app", children: "Shared keys" }), _jsx(ToggleGroupItem, { value: "own", disabled: !ownSet, children: "Your keys" })] }) })] }));
|
|
196
|
+
} }));
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------- models
|
|
199
|
+
function Models({ client }) {
|
|
200
|
+
const { apply } = useApply(client);
|
|
201
|
+
const set = (patch) => void apply('settings', () => client.settings(patch));
|
|
202
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
203
|
+
const models = s.models.map((m) => ({ value: m.id, label: m.label, group: m.group }));
|
|
204
|
+
const tiers = s.tiers.map((t) => ({ value: t.id, label: t.label }));
|
|
205
|
+
return (_jsxs("div", { className: "flex flex-col", children: [s.tiers.map((t) => {
|
|
206
|
+
const p = s.providers.find((x) => x.id === t.provider);
|
|
207
|
+
const keyless = p && !p.app.set && !p.app.from_env && !p.own.set;
|
|
208
|
+
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));
|
|
209
|
+
}), s.tasks.length > 1 &&
|
|
210
|
+
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)))] }));
|
|
211
|
+
} }));
|
|
212
|
+
}
|
|
213
|
+
// ---------------------------------------------------------------- tools
|
|
214
|
+
function Tools({ client }) {
|
|
215
|
+
const { apply } = useApply(client);
|
|
216
|
+
return (_jsx(WithStatus, { client: client, children: (s) => s.groups.length ? (_jsx("div", { className: "flex flex-col", children: s.groups.map((g) => (_jsx(Row, { label: g.label, info: g.about, children: _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: g.access, "aria-label": g.label, onValueChange: (v) => v &&
|
|
217
|
+
void apply('access', () => client.settings({ access: { [g.id]: v } })), children: ACCESS.filter((a) => a.value !== 'draft' || g.drafts).map((a) => (_jsx(ToggleGroupItem, { value: a.value, children: a.label }, a.value))) }) }, g.id))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "This app gives the assistant no tools yet." })) }));
|
|
218
|
+
}
|
|
219
|
+
// ---------------------------------------------------------------- web
|
|
220
|
+
function ScrapeqlBlock({ client, s }) {
|
|
221
|
+
const { apply, refresh } = useApply(client);
|
|
222
|
+
const ids = useId();
|
|
223
|
+
const [id, setId] = useState('');
|
|
224
|
+
const [secret, setSecret] = useState('');
|
|
225
|
+
const [replacing, setReplacing] = useState(false);
|
|
226
|
+
const [busy, setBusy] = useState(false);
|
|
227
|
+
const state = s.web.scrapeql;
|
|
228
|
+
if (!state)
|
|
229
|
+
return null;
|
|
230
|
+
const editing = s.admin && (!state.set || replacing);
|
|
231
|
+
const issues = [];
|
|
232
|
+
if (editing && id && !secret)
|
|
233
|
+
issues.push({
|
|
234
|
+
where: 'Client Secret',
|
|
235
|
+
message: 'Paste the secret too',
|
|
236
|
+
target: `${ids}-secret`,
|
|
237
|
+
});
|
|
238
|
+
const save = async () => {
|
|
239
|
+
setBusy(true);
|
|
240
|
+
if (await apply('scrapeql', () => client.scrapeql({ client_id: id.trim(), client_secret: secret.trim() }), 'Token saved')) {
|
|
241
|
+
setId('');
|
|
242
|
+
setSecret('');
|
|
243
|
+
setReplacing(false);
|
|
244
|
+
}
|
|
245
|
+
setBusy(false);
|
|
246
|
+
};
|
|
247
|
+
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 &&
|
|
248
|
+
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) })] })] })))] }));
|
|
249
|
+
}
|
|
250
|
+
function Web({ client }) {
|
|
251
|
+
const { apply } = useApply(client);
|
|
252
|
+
return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(Row, { label: "Read the web", info: "A plain fetch of public pages and APIs from the server, never the app's own pages.", children: _jsx(Switch, { "aria-label": "Read the web", checked: s.web.fetch, onCheckedChange: (fetch) => void apply('web', () => client.settings({ web: { fetch } })) }) }), _jsx(ScrapeqlBlock, { client: client, s: s })] })) }));
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------- spend
|
|
255
|
+
function Spend({ client }) {
|
|
256
|
+
return (_jsx(WithStatus, { client: client, children: (s) => {
|
|
257
|
+
const sp = s.spend;
|
|
258
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "font-semibold text-2xl tabular-nums", children: usd(sp.usd) }), _jsxs("span", { className: "text-muted-foreground text-sm", children: [sp.month, sp.cap != null &&
|
|
259
|
+
sp.payer &&
|
|
260
|
+
` · ${usd(sp.cap)} cap on ${sp.payer === 'app' ? 'the app key' : 'your key'}`] })] }), sp.rows.length ? (_jsx("div", { className: "flex flex-col text-sm", children: sp.rows.map((r) => (_jsxs("div", { className: "flex items-center gap-2 border-b py-2 last:border-b-0", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: r.label }), _jsx("span", { className: "text-muted-foreground text-xs", children: r.payer === 'app' ? 'Shared key' : 'Your key' }), _jsxs("span", { className: "w-16 text-right text-muted-foreground text-xs tabular-nums", children: [r.calls, " calls"] }), _jsx("span", { className: "w-16 text-right tabular-nums", children: usd(r.usd) })] }, `${r.label}:${r.payer}`))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing spent this month." }))] }));
|
|
261
|
+
} }));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
|
|
265
|
+
* Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
|
|
266
|
+
*/
|
|
267
|
+
export function assistantSettingsSections({ client, name, web = true, }) {
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
id: 'assistant',
|
|
271
|
+
title: name,
|
|
272
|
+
icon: SparklesIcon,
|
|
273
|
+
description: 'Which tier answers, and when it asks a bigger one',
|
|
274
|
+
content: _jsx(General, { client: client }),
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'assistant-providers',
|
|
278
|
+
title: 'Model providers',
|
|
279
|
+
icon: KeyRoundIcon,
|
|
280
|
+
sub: true,
|
|
281
|
+
description: 'Keys for each provider, and who pays',
|
|
282
|
+
content: _jsx(Providers, { client: client }),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'assistant-models',
|
|
286
|
+
title: 'Models',
|
|
287
|
+
icon: LayersIcon,
|
|
288
|
+
sub: true,
|
|
289
|
+
description: "Each tier's model and effort",
|
|
290
|
+
content: _jsx(Models, { client: client }),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'assistant-tools',
|
|
294
|
+
title: 'Tools',
|
|
295
|
+
icon: WrenchIcon,
|
|
296
|
+
sub: true,
|
|
297
|
+
description: 'What the assistant may read and draft',
|
|
298
|
+
content: _jsx(Tools, { client: client }),
|
|
299
|
+
},
|
|
300
|
+
...(web
|
|
301
|
+
? [
|
|
302
|
+
{
|
|
303
|
+
id: 'assistant-web',
|
|
304
|
+
title: 'Web',
|
|
305
|
+
icon: GlobeIcon,
|
|
306
|
+
sub: true,
|
|
307
|
+
description: 'Reading public pages and search',
|
|
308
|
+
content: _jsx(Web, { client: client }),
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
: []),
|
|
312
|
+
{
|
|
313
|
+
id: 'assistant-spend',
|
|
314
|
+
title: 'Spend',
|
|
315
|
+
icon: ReceiptIcon,
|
|
316
|
+
sub: true,
|
|
317
|
+
description: 'What answers cost this month',
|
|
318
|
+
content: _jsx(Spend, { client: client }),
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
import type { AssistantAdapter } from './client.js';
|
|
3
|
+
/** An item as the panel draws it: a text still streaming, a step still running. */
|
|
4
|
+
export type LiveItem = ChatItem & {
|
|
5
|
+
live?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export interface AssistantChatState {
|
|
8
|
+
chatId: string | null;
|
|
9
|
+
title: string;
|
|
10
|
+
items: LiveItem[];
|
|
11
|
+
usage: ChatUsage | null;
|
|
12
|
+
/** An answer is streaming. */
|
|
13
|
+
busy: boolean;
|
|
14
|
+
/** A message waiting for the answer to end. */
|
|
15
|
+
queued: string | null;
|
|
16
|
+
/** The chat is loading. */
|
|
17
|
+
loading: boolean;
|
|
18
|
+
send: (message: string, extra?: Omit<AssistantMessage, 'message' | 'chat_id'>) => void;
|
|
19
|
+
stop: () => void;
|
|
20
|
+
open: (id: string) => Promise<void>;
|
|
21
|
+
reset: () => void;
|
|
22
|
+
settle: (draft: DraftItem, action: 'save' | 'dismiss') => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** A copy of the items with one event applied (hand-off sections copied too). */
|
|
25
|
+
export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
|
|
26
|
+
export declare function useAssistantChat(client: AssistantAdapter): AssistantChatState;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { errorText } from '../integrations.js';
|
|
3
|
+
/** Where an event lands: the chat, or the last hand-off section when it came from inside one. */
|
|
4
|
+
function sinkOf(items, handoff) {
|
|
5
|
+
if (!handoff)
|
|
6
|
+
return items;
|
|
7
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
8
|
+
const it = items[i];
|
|
9
|
+
if (it?.kind === 'handoff')
|
|
10
|
+
return it.items;
|
|
11
|
+
}
|
|
12
|
+
return items;
|
|
13
|
+
}
|
|
14
|
+
/** A copy of the items with one event applied (hand-off sections copied too). */
|
|
15
|
+
export function applyEvent(prev, e) {
|
|
16
|
+
const items = prev.map((i) => i.kind === 'handoff' ? { ...i, items: [...i.items] } : i);
|
|
17
|
+
const inside = 'handoff' in e.data && e.data.handoff === true;
|
|
18
|
+
const sink = sinkOf(items, inside);
|
|
19
|
+
const last = sink[sink.length - 1];
|
|
20
|
+
switch (e.event) {
|
|
21
|
+
case 'text':
|
|
22
|
+
if (last?.kind === 'text' && last.live)
|
|
23
|
+
sink[sink.length - 1] = { ...last, text: last.text + e.data.delta };
|
|
24
|
+
else
|
|
25
|
+
sink.push({ kind: 'text', text: e.data.delta, live: true });
|
|
26
|
+
break;
|
|
27
|
+
case 'text_end':
|
|
28
|
+
if (last?.kind === 'text')
|
|
29
|
+
sink[sink.length - 1] = { kind: 'text', text: last.text };
|
|
30
|
+
break;
|
|
31
|
+
case 'step_start':
|
|
32
|
+
sink.push({
|
|
33
|
+
kind: 'step',
|
|
34
|
+
id: e.data.id,
|
|
35
|
+
tool: e.data.tool,
|
|
36
|
+
title: e.data.title,
|
|
37
|
+
ok: true,
|
|
38
|
+
ms: 0,
|
|
39
|
+
live: true,
|
|
40
|
+
});
|
|
41
|
+
break;
|
|
42
|
+
case 'step': {
|
|
43
|
+
const { handoff: _h, ...step } = e.data;
|
|
44
|
+
const at = sink.findIndex((i) => i.kind === 'step' && i.id === step.id);
|
|
45
|
+
if (at >= 0)
|
|
46
|
+
sink[at] = step;
|
|
47
|
+
else
|
|
48
|
+
sink.push(step);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'draft': {
|
|
52
|
+
const { handoff: _h, ...draft } = e.data;
|
|
53
|
+
sink.push(draft);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'handoff':
|
|
57
|
+
items.push({ ...e.data, items: [] });
|
|
58
|
+
break;
|
|
59
|
+
case 'handoff_done': {
|
|
60
|
+
const h = [...items].reverse().find((i) => i.kind === 'handoff');
|
|
61
|
+
if (h)
|
|
62
|
+
h.usage = e.data.usage;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case 'advice':
|
|
66
|
+
items.push({ kind: 'advice', tier: e.data.tier, label: e.data.label, live: true });
|
|
67
|
+
break;
|
|
68
|
+
case 'advice_done': {
|
|
69
|
+
const at = items.findLastIndex((i) => i.kind === 'advice');
|
|
70
|
+
const a = items[at];
|
|
71
|
+
if (a?.kind === 'advice')
|
|
72
|
+
items[at] = {
|
|
73
|
+
kind: 'advice',
|
|
74
|
+
tier: a.tier,
|
|
75
|
+
label: a.label,
|
|
76
|
+
...(e.data.error ? { error: e.data.error } : {}),
|
|
77
|
+
};
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'compacted':
|
|
81
|
+
items.push({ kind: 'compacted' });
|
|
82
|
+
break;
|
|
83
|
+
case 'stopped':
|
|
84
|
+
items.push({ kind: 'stopped', ...(e.data.reason ? { reason: e.data.reason } : {}) });
|
|
85
|
+
break;
|
|
86
|
+
case 'error':
|
|
87
|
+
items.push({
|
|
88
|
+
kind: 'error',
|
|
89
|
+
text: e.data.message,
|
|
90
|
+
...(e.data.code ? { code: e.data.code } : {}),
|
|
91
|
+
});
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
/** Nothing streams any more: a text or step cut by Stop ends where it was. */
|
|
97
|
+
const settled = (items) => items.map((i) => {
|
|
98
|
+
if (i.kind === 'handoff')
|
|
99
|
+
return { ...i, items: settled(i.items) };
|
|
100
|
+
if (!i.live)
|
|
101
|
+
return i;
|
|
102
|
+
const { live: _l, ...rest } = i;
|
|
103
|
+
return rest;
|
|
104
|
+
});
|
|
105
|
+
export function useAssistantChat(client) {
|
|
106
|
+
const [chatId, setChatId] = useState(null);
|
|
107
|
+
const [title, setTitle] = useState('');
|
|
108
|
+
const [items, setItems] = useState([]);
|
|
109
|
+
const [usage, setUsage] = useState(null);
|
|
110
|
+
const [busy, setBusy] = useState(false);
|
|
111
|
+
const [loading, setLoading] = useState(false);
|
|
112
|
+
const [queued, setQueued] = useState(null);
|
|
113
|
+
const abort = useRef(null);
|
|
114
|
+
const idRef = useRef(null);
|
|
115
|
+
idRef.current = chatId;
|
|
116
|
+
const run = useCallback(async (message, extra) => {
|
|
117
|
+
const ctl = new AbortController();
|
|
118
|
+
abort.current = ctl;
|
|
119
|
+
setBusy(true);
|
|
120
|
+
if (message !== '/compact')
|
|
121
|
+
setItems((p) => [
|
|
122
|
+
...p,
|
|
123
|
+
{
|
|
124
|
+
kind: 'user',
|
|
125
|
+
text: message,
|
|
126
|
+
at: Date.now(),
|
|
127
|
+
...(extra?.context ? { context: extra.context.label } : {}),
|
|
128
|
+
...(extra?.refs?.length ? { refs: extra.refs } : {}),
|
|
129
|
+
...(extra?.tier ? { tier: extra.tier } : {}),
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
try {
|
|
133
|
+
await client.send({ message, ...(idRef.current ? { chat_id: idRef.current } : {}), ...extra }, (e) => {
|
|
134
|
+
if (e.event === 'chat') {
|
|
135
|
+
idRef.current = e.data.id;
|
|
136
|
+
setChatId(e.data.id);
|
|
137
|
+
setTitle(e.data.title);
|
|
138
|
+
}
|
|
139
|
+
else if (e.event === 'usage')
|
|
140
|
+
setUsage(e.data);
|
|
141
|
+
else if (e.event === 'done')
|
|
142
|
+
setUsage(e.data.usage);
|
|
143
|
+
else
|
|
144
|
+
setItems((p) => applyEvent(p, e));
|
|
145
|
+
}, ctl.signal);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (ctl.signal.aborted)
|
|
149
|
+
setItems((p) => [...p, { kind: 'stopped' }]);
|
|
150
|
+
else
|
|
151
|
+
setItems((p) => [...p, { kind: 'error', text: errorText(err) }]);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
setItems(settled);
|
|
155
|
+
abort.current = null;
|
|
156
|
+
setBusy(false);
|
|
157
|
+
}
|
|
158
|
+
}, [client]);
|
|
159
|
+
// A queued message goes once the answer ends.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (busy || !queued)
|
|
162
|
+
return;
|
|
163
|
+
setQueued(null);
|
|
164
|
+
void run(queued.message, queued.extra);
|
|
165
|
+
}, [busy, queued, run]);
|
|
166
|
+
const send = useCallback((message, extra) => {
|
|
167
|
+
const text = message.trim();
|
|
168
|
+
if (!text)
|
|
169
|
+
return;
|
|
170
|
+
if (abort.current)
|
|
171
|
+
setQueued({ message: text, ...(extra ? { extra } : {}) });
|
|
172
|
+
else
|
|
173
|
+
void run(text, extra);
|
|
174
|
+
}, [run]);
|
|
175
|
+
const stop = useCallback(() => abort.current?.abort(), []);
|
|
176
|
+
const reset = useCallback(() => {
|
|
177
|
+
abort.current?.abort();
|
|
178
|
+
setQueued(null);
|
|
179
|
+
setChatId(null);
|
|
180
|
+
idRef.current = null;
|
|
181
|
+
setTitle('');
|
|
182
|
+
setItems([]);
|
|
183
|
+
setUsage(null);
|
|
184
|
+
}, []);
|
|
185
|
+
const open = useCallback(async (id) => {
|
|
186
|
+
abort.current?.abort();
|
|
187
|
+
setQueued(null);
|
|
188
|
+
setLoading(true);
|
|
189
|
+
try {
|
|
190
|
+
const c = await client.chat(id);
|
|
191
|
+
idRef.current = c.id;
|
|
192
|
+
setChatId(c.id);
|
|
193
|
+
setTitle(c.title);
|
|
194
|
+
setItems(c.items);
|
|
195
|
+
setUsage(c.usage ?? null);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
setItems([{ kind: 'error', text: errorText(err) }]);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
setLoading(false);
|
|
202
|
+
}
|
|
203
|
+
}, [client]);
|
|
204
|
+
const settle = useCallback(async (draft, action) => {
|
|
205
|
+
const id = idRef.current;
|
|
206
|
+
if (!id)
|
|
207
|
+
return;
|
|
208
|
+
const next = await client.settleDraft(id, draft.id, action);
|
|
209
|
+
const swap = (list) => list.map((i) => i.kind === 'draft' && i.id === next.id
|
|
210
|
+
? next
|
|
211
|
+
: i.kind === 'handoff'
|
|
212
|
+
? { ...i, items: swap(i.items) }
|
|
213
|
+
: i);
|
|
214
|
+
setItems(swap);
|
|
215
|
+
}, [client]);
|
|
216
|
+
return {
|
|
217
|
+
chatId,
|
|
218
|
+
title,
|
|
219
|
+
items,
|
|
220
|
+
usage,
|
|
221
|
+
busy,
|
|
222
|
+
queued: queued?.message ?? null,
|
|
223
|
+
loading,
|
|
224
|
+
send,
|
|
225
|
+
stop,
|
|
226
|
+
open,
|
|
227
|
+
reset,
|
|
228
|
+
settle,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** Rows that start collapsed; opening one closes the others. `defaultOpen` names a row open at first. */
|
|
3
|
+
export declare function LumenExpandList({ defaultOpen, className, children, }: {
|
|
4
|
+
defaultOpen?: string | null;
|
|
5
|
+
className?: string;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
/**
|
|
9
|
+
* One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
|
|
10
|
+
* `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenChange, defaultOpen, children, }: {
|
|
13
|
+
id?: string;
|
|
14
|
+
name: ReactNode;
|
|
15
|
+
meta?: ReactNode;
|
|
16
|
+
badge?: ReactNode;
|
|
17
|
+
open?: boolean;
|
|
18
|
+
onOpenChange?: (open: boolean) => void;
|
|
19
|
+
defaultOpen?: boolean;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A stack of rows that start collapsed and expand in place: a settings page's providers, channels or
|
|
3
|
+
// accounts, each row's name, a line about it and a badge, its own settings inside. One row is open
|
|
4
|
+
// at a time within a LumenExpandList.
|
|
5
|
+
import { ChevronRightIcon } from 'lucide-react';
|
|
6
|
+
import { createContext, useContext, useState } from 'react';
|
|
7
|
+
import { cn } from './lib/utils.js';
|
|
8
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
|
|
9
|
+
const ListCtx = createContext(null);
|
|
10
|
+
/** Rows that start collapsed; opening one closes the others. `defaultOpen` names a row open at first. */
|
|
11
|
+
export function LumenExpandList({ defaultOpen = null, className, children, }) {
|
|
12
|
+
const [open, set] = useState(defaultOpen);
|
|
13
|
+
return (_jsx(ListCtx.Provider, { value: { open, set }, children: _jsx("div", { className: cn('grid grid-cols-1', className), children: children }) }));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One row that expands in place. Inside a LumenExpandList it opens by `id`; `open` and
|
|
17
|
+
* `onOpenChange` make it controlled; alone it keeps its own state (`defaultOpen`).
|
|
18
|
+
*/
|
|
19
|
+
export function LumenExpandRow({ id, name, meta, badge, open: openProp, onOpenChange, defaultOpen = false, children, }) {
|
|
20
|
+
const list = useContext(ListCtx);
|
|
21
|
+
const [own, setOwn] = useState(defaultOpen);
|
|
22
|
+
const inList = list && id !== undefined;
|
|
23
|
+
const open = openProp ?? (inList ? list.open === id : own);
|
|
24
|
+
const change = (o) => {
|
|
25
|
+
if (onOpenChange)
|
|
26
|
+
onOpenChange(o);
|
|
27
|
+
else if (inList)
|
|
28
|
+
list.set(o ? id : null);
|
|
29
|
+
else
|
|
30
|
+
setOwn(o);
|
|
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 font-medium", children: name }), meta && _jsx("span", { className: "block truncate text-xs text-muted-foreground", 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
|
+
}
|