@columenlabs/lumen-ui 0.1.77 → 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.
@@ -0,0 +1,40 @@
1
+ import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState } from '@columenlabs/lumen-ui-types/assistant';
2
+ type Test = NonNullable<KeyState['test']>;
3
+ export interface AssistantAdapter {
4
+ /** Names the cache its reads keep (two assistants on one page need two keys). */
5
+ key: string;
6
+ status(): Promise<AssistantStatus>;
7
+ settings(patch: AssistantSettingsPatch): Promise<AssistantStatus>;
8
+ /** Admin: what each person may spend a month on the app's keys. */
9
+ appCap(usd: number): Promise<AssistantStatus>;
10
+ /** Admin: the app's key of a provider (tested before it is kept); null removes it. */
11
+ appKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
12
+ testAppKey(provider: AssistantProvider): Promise<Test>;
13
+ /** The person's own key of a provider; null removes it. */
14
+ ownKey(provider: AssistantProvider, key: string | null): Promise<AssistantStatus>;
15
+ /** What the person may spend a month on their own keys. */
16
+ ownCap(usd: number): Promise<AssistantStatus>;
17
+ testOwnKey(provider: AssistantProvider): Promise<Test>;
18
+ /** Starts a plan sign-in (ChatGPT): the address to open and the one it ends on. */
19
+ startSignin(provider: AssistantProvider): Promise<{
20
+ url: string;
21
+ redirect: string;
22
+ }>;
23
+ /** Finishes it from the address the sign-in tab ended on. */
24
+ finishSignin(provider: AssistantProvider, url: string): Promise<AssistantStatus>;
25
+ /** Admin: ScrapeQL's Access service token; null removes it. */
26
+ scrapeql(token: {
27
+ client_id: string;
28
+ client_secret: string;
29
+ } | null): Promise<AssistantStatus>;
30
+ testScrapeql(): Promise<Test>;
31
+ chats(): Promise<AssistantChatRow[]>;
32
+ chat(id: string): Promise<AssistantChat>;
33
+ removeChat(id: string): Promise<void>;
34
+ /** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
35
+ send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
36
+ settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
37
+ }
38
+ /** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
39
+ export declare function assistantClient(base: string): AssistantAdapter;
40
+ export {};
@@ -0,0 +1,80 @@
1
+ import { ApiError, apiJson } from '../api-fetch.js';
2
+ /** Reads `event:` / `data:` blocks off a server-sent event stream. */
3
+ async function readEvents(body, on) {
4
+ const reader = body.getReader();
5
+ const dec = new TextDecoder();
6
+ let buf = '';
7
+ for (;;) {
8
+ const { done, value } = await reader.read();
9
+ if (done)
10
+ break;
11
+ buf += dec.decode(value, { stream: true });
12
+ let at = buf.indexOf('\n\n');
13
+ while (at >= 0) {
14
+ const block = buf.slice(0, at);
15
+ buf = buf.slice(at + 2);
16
+ let event = 'message';
17
+ let data = '';
18
+ for (const line of block.split('\n')) {
19
+ if (line.startsWith('event:'))
20
+ event = line.slice(6).trim();
21
+ else if (line.startsWith('data:'))
22
+ data += line.slice(5).trim();
23
+ }
24
+ if (data) {
25
+ try {
26
+ on({ event, data: JSON.parse(data) });
27
+ }
28
+ catch { }
29
+ }
30
+ at = buf.indexOf('\n\n');
31
+ }
32
+ }
33
+ }
34
+ /** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
35
+ export function assistantClient(base) {
36
+ const root = base.replace(/\/+$/, '');
37
+ const get = (path) => apiJson(`${root}${path}`, {}, 'assistant');
38
+ const send = (method, path, body = {}) => apiJson(`${root}${path}`, { method, body: JSON.stringify(body) }, 'assistant');
39
+ return {
40
+ key: `assistant:${root}`,
41
+ status: () => get('/status'),
42
+ settings: (patch) => send('PUT', '/settings', patch),
43
+ appCap: (usd) => send('PUT', '/app', { cap_usd: usd }),
44
+ appKey: (p, key) => key
45
+ ? send('PUT', `/key?provider=${p}`, { api_key: key })
46
+ : send('DELETE', `/key?provider=${p}`),
47
+ testAppKey: (p) => send('POST', `/key/test?provider=${p}`),
48
+ ownKey: (p, key) => key
49
+ ? send('PUT', `/my-key?provider=${p}`, { api_key: key })
50
+ : send('DELETE', `/my-key?provider=${p}`),
51
+ ownCap: (usd) => send('PUT', '/my-key/cap', { cap_usd: usd }),
52
+ testOwnKey: (p) => send('POST', `/my-key/test?provider=${p}`),
53
+ startSignin: (p) => send('POST', `/my-signin?provider=${p}`),
54
+ finishSignin: (p, url) => send('POST', `/my-signin/finish?provider=${p}`, { url }),
55
+ scrapeql: (token) => token ? send('PUT', '/web/scrapeql', token) : send('DELETE', '/web/scrapeql'),
56
+ testScrapeql: () => send('POST', '/web/test'),
57
+ chats: () => get('/chats'),
58
+ chat: (id) => get(`/chats/${encodeURIComponent(id)}`),
59
+ removeChat: async (id) => {
60
+ await send('DELETE', `/chats/${encodeURIComponent(id)}`);
61
+ },
62
+ settleDraft: (chatId, draftId, action) => send('POST', `/chats/${encodeURIComponent(chatId)}/drafts/${encodeURIComponent(draftId)}`, {
63
+ action,
64
+ }),
65
+ async send(msg, onEvent, signal) {
66
+ const url = `${root}/chat`;
67
+ const res = await fetch(url, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
70
+ body: JSON.stringify(msg),
71
+ ...(signal ? { signal } : {}),
72
+ });
73
+ if (!res.ok || !res.body) {
74
+ const b = (await res.json().catch(() => ({})));
75
+ throw new ApiError(b.error ?? `The answer failed (${res.status})`, 'POST', url, res.status, b.code);
76
+ }
77
+ await readEvents(res.body, onEvent);
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,4 @@
1
+ export { type AssistantAdapter, assistantClient } from './client.js';
2
+ export { LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
3
+ export { assistantSettingsSections } from './settings.js';
4
+ export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -0,0 +1,7 @@
1
+ // `@columenlabs/lumen-ui/assistant`: an assistant's chat panel and Settings sections, over the
2
+ // routes of `@columenlabs/lumen-ai/assistant`; the shapes both share are
3
+ // `@columenlabs/lumen-ui-types/assistant`.
4
+ export { assistantClient } from './client.js';
5
+ export { LumenAssistant, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
6
+ export { assistantSettingsSections } from './settings.js';
7
+ export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -0,0 +1,44 @@
1
+ import type { AssistantMessage, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
2
+ import type { AssistantAdapter } from './client.js';
3
+ import { type AssistantChatState } from './use-assistant-chat.js';
4
+ export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
5
+ /** The person's assistant status, shared by the panel and Settings. */
6
+ export declare function useAssistantStatus(client: AssistantAdapter): import("@tanstack/react-query").UseQueryResult<AssistantStatus, Error>;
7
+ export declare const usd: (n: number) => string;
8
+ export interface LumenAssistantProps {
9
+ client: AssistantAdapter;
10
+ /** The page the person is on, sent with each message. */
11
+ context?: AssistantMessage['context'];
12
+ /** Opens the app's Settings at the assistant (from a "not connected" line). */
13
+ onSettings?: () => void;
14
+ /** Days chats are kept, for the Chats menu's line. */
15
+ keepDays?: number;
16
+ /** Closes the panel (its ✕). */
17
+ onClose?: () => void;
18
+ /** An outside chat state (the dock shares one between the button and the panel). */
19
+ chat?: AssistantChatState;
20
+ }
21
+ /** The assistant's chat: header, messages and composer. */
22
+ export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, }: LumenAssistantProps): import("react").JSX.Element;
23
+ /** The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. */
24
+ export declare function LumenComposer({ name, busy, queued, disabled, onSend, onStop, }: {
25
+ name: string;
26
+ busy: boolean;
27
+ queued: string | null;
28
+ disabled?: boolean;
29
+ onSend: (text: string) => void;
30
+ onStop: () => void;
31
+ }): import("react").JSX.Element;
32
+ /**
33
+ * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
34
+ * Open or closed is this browser tab's own, and ⌘J toggles it.
35
+ */
36
+ export declare function useLumenAssistantDock(client: AssistantAdapter, o?: Omit<LumenAssistantProps, 'client' | 'chat' | 'onClose'> & {
37
+ name: string;
38
+ }): {
39
+ open: boolean;
40
+ setOpen: (v: boolean) => void;
41
+ chat: AssistantChatState;
42
+ button: import("react").JSX.Element;
43
+ panel: import("react").JSX.Element;
44
+ };
@@ -0,0 +1,209 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { ArrowUpIcon, CheckIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PlusIcon, SparklesIcon, SquareIcon, WrenchIcon, XIcon, } from 'lucide-react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import Markdown from 'react-markdown';
6
+ import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
7
+ import { IconButton } from '../icon-button.js';
8
+ import { errorText } from '../integrations.js';
9
+ import { cn } from '../lib/utils.js';
10
+ import { LumenSelector } from '../lumen-selector.js';
11
+ import { lumenToast } from '../lumen-toast.js';
12
+ import { Alert, AlertDescription } from '../ui/alert.js';
13
+ import { Button } from '../ui/button.js';
14
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
15
+ import { Spinner } from '../ui/spinner.js';
16
+ import { Textarea } from '../ui/textarea.js';
17
+ import { useAssistantChat } from './use-assistant-chat.js';
18
+ /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
19
+ const CONTEXT_MAX = 200_000;
20
+ const COMPACT_AT = 150_000;
21
+ export const assistantStatusKey = (client) => [
22
+ 'lumen-assistant',
23
+ client.key,
24
+ 'status',
25
+ ];
26
+ /** The person's assistant status, shared by the panel and Settings. */
27
+ export function useAssistantStatus(client) {
28
+ return useQuery({ queryKey: assistantStatusKey(client), queryFn: () => client.status() });
29
+ }
30
+ export const usd = (n) => n === 0 ? '$0' : n < 0.01 ? '<$0.01' : `$${n.toFixed(n < 10 ? 2 : 0)}`;
31
+ const tokens = (n) => n < 1000
32
+ ? String(n)
33
+ : n < 1_000_000
34
+ ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
35
+ : `${(n / 1e6).toFixed(1)}M`;
36
+ const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
37
+ /** The assistant's chat: header, messages and composer. */
38
+ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, }) {
39
+ const own = useAssistantChat(client);
40
+ const chat = outside ?? own;
41
+ const qc = useQueryClient();
42
+ const status = useAssistantStatus(client);
43
+ const chats = useQuery({
44
+ queryKey: ['lumen-assistant', client.key, 'chats'],
45
+ queryFn: () => client.chats(),
46
+ enabled: false,
47
+ });
48
+ const s = status.data;
49
+ // A finished answer changes the spend and the chats list.
50
+ useEffect(() => {
51
+ if (chat.busy)
52
+ return;
53
+ void qc.invalidateQueries({ queryKey: ['lumen-assistant', client.key] });
54
+ }, [chat.busy, qc, client.key]);
55
+ const setTier = async (tier) => {
56
+ try {
57
+ qc.setQueryData(assistantStatusKey(client), await client.settings({ tier }));
58
+ }
59
+ catch (e) {
60
+ lumenToast.error(errorText(e));
61
+ }
62
+ };
63
+ return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", children: [_jsxs("div", { className: "flex h-12 shrink-0 items-center gap-1 border-b px-3", children: [_jsx(SparklesIcon, { className: "size-4 text-edge" }), _jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: chat.title || s?.name || 'Assistant' }), s && s.tiers.length > 1 && (_jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Tier", value: s.tier, options: s.tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })), onChange: (t) => void setTier(t) })), _jsx(LumenAssistantChats, { chats: chats.data?.map((c) => ({
64
+ id: c.id,
65
+ title: c.title,
66
+ updated_at: new Date(c.at).toISOString(),
67
+ })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: PlusIcon, label: "New chat", disabled: !chat.items.length, onClick: chat.reset }), onClose && (_jsx(IconButton, { icon: XIcon, label: `Close ${s?.name ?? 'assistant'}`, onClick: onClose }))] }), _jsx(Messages, { chat: chat, status: s, onSettings: onSettings }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, disabled: !s || !!s.wait, onSend: (text) => chat.send(text, context ? { context } : undefined), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
68
+ }
69
+ function Messages({ chat, status, onSettings, }) {
70
+ const end = useRef(null);
71
+ const count = JSON.stringify(chat.items).length;
72
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll to the end as the chat grows
73
+ useEffect(() => {
74
+ end.current?.scrollIntoView({ block: 'end' });
75
+ }, [count]);
76
+ return (_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "Open Settings" }))] })] })), chat.loading ? (_jsxs("p", { className: "flex items-center gap-2 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Loading the chat\u2026"] })) : chat.items.length === 0 && !status?.wait ? (_jsxs("p", { className: "pt-8 text-center text-muted-foreground text-sm", children: ["Ask ", status?.name ?? 'the assistant', " anything."] })) : (_jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy })), chat.busy && !chat.items.some(isLive) && (_jsxs("p", { className: "flex items-center gap-2 py-1 text-muted-foreground text-sm", children: [_jsx(Spinner, {}), " Thinking\u2026"] })), _jsx("div", { ref: end })] }));
77
+ }
78
+ const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
79
+ const WAIT = {
80
+ not_connected: (admin, provider) => admin
81
+ ? `Add an ${provider} key in Settings ▸ Model providers, or pick another model in Models.`
82
+ : `No ${provider} key is set yet. Add your own in Settings, or pick another model in Models.`,
83
+ app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
84
+ own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
85
+ };
86
+ function Items({ items, onSettle, busy, }) {
87
+ return (_jsx("div", { className: "flex flex-col gap-3", children: items.map((item, i) => (_jsx(Item, { item: item, onSettle: onSettle, busy: busy }, i))) }));
88
+ }
89
+ function Item({ item, onSettle, busy, }) {
90
+ switch (item.kind) {
91
+ case 'user':
92
+ return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
93
+ case 'text':
94
+ return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(Markdown, { allowedElements: ['p', 'strong', 'em', 'code', 'ul', 'ol', 'li', 'a', 'pre', 'br'], unwrapDisallowed: true, children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
95
+ case 'step':
96
+ return _jsx(Step, { step: item, live: !!item.live });
97
+ case 'draft':
98
+ return _jsx(Draft, { draft: item, onSettle: onSettle, busy: busy });
99
+ case 'handoff':
100
+ return _jsx(HandOff, { item: item, onSettle: onSettle, busy: busy });
101
+ case 'advice':
102
+ return (_jsxs("p", { className: "flex items-center gap-1.5 text-muted-foreground text-xs", children: [item.live ? _jsx(Spinner, { className: "size-3" }) : _jsx(LightbulbIcon, { className: "size-3.5" }), item.live
103
+ ? `Asking ${item.label}…`
104
+ : item.error
105
+ ? `${item.label} could not advise (${item.error})`
106
+ : `Asked ${item.label}`] }));
107
+ case 'compacted':
108
+ return _jsx(Rule, { children: "Compacted: earlier messages are sent as a summary" });
109
+ case 'stopped':
110
+ return _jsx(Rule, { children: item.reason === 'cap' ? "Stopped at this month's spend cap" : 'Stopped' });
111
+ case 'error':
112
+ return (_jsxs("p", { role: "alert", className: "flex items-start gap-1.5 text-loss text-sm", children: [_jsx(CircleAlertIcon, { className: "mt-0.5 size-4 shrink-0" }), item.text] }));
113
+ }
114
+ }
115
+ const Rule = ({ children }) => (_jsx("p", { className: "flex items-center gap-2 text-muted-foreground text-xs before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border", children: children }));
116
+ function Step({ step, live }) {
117
+ return (_jsxs("p", { className: cn('flex min-w-0 items-center gap-1.5 text-xs', step.ok ? 'text-muted-foreground' : 'text-loss'), children: [live ? (_jsx(Spinner, { className: "size-3" })) : step.ok ? (_jsx(WrenchIcon, { className: "size-3.5 shrink-0" })) : (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })), _jsx("span", { className: "truncate", children: step.title }), !live && (_jsxs("span", { className: "shrink-0 tabular-nums", children: [step.rows !== undefined && `${step.rows} row${step.rows === 1 ? '' : 's'} · `, seconds(step.ms)] })), step.error && _jsxs("span", { className: "truncate", children: ["\u00B7 ", step.error] })] }));
118
+ }
119
+ /** The arguments as short lines: `name: value`. */
120
+ function argLines(args) {
121
+ if (!args || typeof args !== 'object')
122
+ return [];
123
+ return Object.entries(args).map(([k, v]) => [
124
+ k,
125
+ typeof v === 'string' ? v : JSON.stringify(v),
126
+ ]);
127
+ }
128
+ function Draft({ draft, onSettle, busy, }) {
129
+ const [pending, setPending] = useState(null);
130
+ const go = async (action) => {
131
+ setPending(action);
132
+ try {
133
+ await onSettle(draft, action);
134
+ }
135
+ catch (e) {
136
+ lumenToast.error(errorText(e));
137
+ }
138
+ finally {
139
+ setPending(null);
140
+ }
141
+ };
142
+ return (_jsxs("div", { "data-slot": "assistant-draft", className: "flex flex-col gap-2 rounded-lg border bg-card p-3 text-sm", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: draft.title }), draft.status === 'saved' ? (_jsxs("span", { className: "flex items-center gap-1 text-gain text-xs", children: [_jsx(CheckIcon, { className: "size-3.5" }), " Saved"] })) : draft.status === 'dismissed' ? (_jsx("span", { className: "text-muted-foreground text-xs", children: "Dismissed" })) : draft.status === 'failed' ? (_jsx("span", { className: "text-loss text-xs", children: "Failed" })) : null] }), _jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs", children: argLines(draft.args)
143
+ .slice(0, 8)
144
+ .map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "truncate font-code", children: v })] }, k))) }), draft.error && _jsx("p", { className: "text-loss text-xs", children: draft.error }), draft.status === 'open' && (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!pending || busy, onClick: () => void go('dismiss'), children: "Dismiss" }), _jsxs(Button, { size: "sm", disabled: !!pending || busy, onClick: () => void go('save'), children: [pending === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save"] })] }))] }));
145
+ }
146
+ const WHY = {
147
+ asked: 'you asked',
148
+ judgement: 'needs judgement',
149
+ stuck: 'stuck',
150
+ check_failed: 'a check failed',
151
+ };
152
+ function HandOff({ item, onSettle, busy, }) {
153
+ return (_jsxs("section", { className: "flex flex-col gap-2 border-l-2 border-primary/40 pl-3", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3.5 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: item.label }), _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 ", WHY[item.why]] }), item.usage && (_jsx("span", { className: "ml-auto text-muted-foreground tabular-nums", children: usd(item.usage.usd) }))] }), _jsx("p", { className: "text-muted-foreground text-xs", children: item.task }), _jsx(Items, { items: item.items, onSettle: onSettle, busy: busy })] }));
154
+ }
155
+ /** The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. */
156
+ export function LumenComposer({ name, busy, queued, disabled, onSend, onStop, }) {
157
+ const [text, setText] = useState('');
158
+ const send = () => {
159
+ if (!text.trim() || disabled)
160
+ return;
161
+ onSend(text);
162
+ setText('');
163
+ };
164
+ const slash = text.startsWith('/') && !text.includes(' ');
165
+ return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [queued && (_jsxs("p", { className: "mb-2 truncate text-muted-foreground text-xs", children: ["Queued: ", _jsx("span", { className: "text-foreground", children: queued })] })), slash && (_jsxs("button", { type: "button", className: "mb-2 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs hover:bg-muted", onClick: () => setText('/compact'), children: [_jsx("span", { className: "font-code", children: "/compact" }), _jsx("span", { className: "text-muted-foreground", children: "Summarise the chat so far" })] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { "aria-label": `Message ${name}`, placeholder: `Ask ${name}…`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => setText(e.target.value), onKeyDown: (e) => {
166
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
167
+ e.preventDefault();
168
+ send();
169
+ }
170
+ } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: busy ? 'Queue' : 'Send', variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: send })) })] })] }));
171
+ }
172
+ /** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
173
+ function UsageLine({ usage }) {
174
+ if (!usage || !usage.calls)
175
+ return null;
176
+ const ctx = usage.context ?? 0;
177
+ return (_jsxs("div", { className: "flex shrink-0 items-center gap-3 px-3 pb-2 text-muted-foreground text-xs", children: [_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "tabular-nums hover:text-foreground", children: [usd(usage.usd), " \u00B7 ", tokens(usage.in + usage.out), " tokens"] }) }), _jsxs(PopoverContent, { align: "start", className: "w-72 gap-1 p-2 text-xs", children: [usage.parts.map((p) => (_jsxs("div", { className: "flex justify-between gap-2 tabular-nums", children: [_jsx("span", { className: "truncate", children: p.label }), _jsxs("span", { children: [tokens(p.in), " in \u00B7 ", tokens(p.out), " out \u00B7 ", usd(p.usd)] })] }, p.label))), _jsxs("p", { className: "pt-1 text-muted-foreground", children: [tokens(usage.read), " of ", tokens(usage.in), " in read from cache"] })] })] }), ctx > 0 && (_jsxs("span", { className: "ml-auto flex items-center gap-1.5", title: `Context ${tokens(ctx)} of ${tokens(CONTEXT_MAX)}`, children: [_jsxs("span", { className: "relative h-1 w-16 overflow-hidden rounded-full bg-muted", children: [_jsx("span", { className: cn('absolute inset-y-0 left-0', ctx > COMPACT_AT ? 'bg-warning' : 'bg-primary/60'), style: { width: `${Math.min(100, (ctx / CONTEXT_MAX) * 100)}%` } }), _jsx("span", { className: "absolute inset-y-0 w-px bg-foreground/40", style: { left: `${(COMPACT_AT / CONTEXT_MAX) * 100}%` } })] }), _jsx("span", { className: "tabular-nums", children: tokens(ctx) })] }))] }));
178
+ }
179
+ /**
180
+ * The assistant docked in LumenShell: put `button` in `headerActions` and `panel` in `panel`.
181
+ * Open or closed is this browser tab's own, and ⌘J toggles it.
182
+ */
183
+ export function useLumenAssistantDock(client, o = {
184
+ name: 'Assistant',
185
+ }) {
186
+ const open = assistantOpen.use();
187
+ const chat = useAssistantChat(client);
188
+ const [unread, setUnread] = useState(false);
189
+ const wasBusy = useRef(false);
190
+ useEffect(() => {
191
+ if (wasBusy.current && !chat.busy && !assistantOpen.get())
192
+ setUnread(true);
193
+ wasBusy.current = chat.busy;
194
+ }, [chat.busy]);
195
+ const setOpen = (v) => {
196
+ assistantOpen.write(v);
197
+ if (v)
198
+ setUnread(false);
199
+ };
200
+ useAssistantHotkey(() => setOpen(!assistantOpen.get()));
201
+ const { name, ...props } = o;
202
+ return {
203
+ open,
204
+ setOpen,
205
+ chat,
206
+ button: (_jsx(LumenAssistantButton, { name: name, open: open, unread: unread, onOpen: () => setOpen(true) })),
207
+ panel: (_jsx(LumenAssistantPanel, { name: name, open: open, onOpenChange: setOpen, children: () => (_jsx(LumenAssistant, { client: client, chat: chat, onClose: () => setOpen(false), ...props })) })),
208
+ };
209
+ }
@@ -0,0 +1,13 @@
1
+ import type { SettingsSectionDef } from '../settings.js';
2
+ import type { AssistantAdapter } from './client.js';
3
+ /**
4
+ * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
5
+ * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
6
+ */
7
+ export declare function assistantSettingsSections({ client, name, web, }: {
8
+ client: AssistantAdapter;
9
+ /** The assistant's name, the parent section's title. */
10
+ name: string;
11
+ /** Show the Web section (the app set up `web` on the server). */
12
+ web?: boolean;
13
+ }): SettingsSectionDef[];
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -49,6 +49,7 @@ export { type Crumb, IconButton, type IconButtonProps, Page, PageBody, PageHeade
49
49
  export { DEFAULT_PREFS, deviceTimeZone, type Prefs, TIME_ZONES, type Theme, type TimeZoneId, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
50
50
  export { KIT, type KitConfig, configureLumen } from './kit-config.js';
51
51
  export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestResult, TestBadge, errorText, useClock, } from './integrations.js';
52
+ export { LumenExpandList, LumenExpandRow } from './expand-list.js';
52
53
  export { LumenPager } from './pager.js';
53
54
  export { createDeskQueryClient } from './query-client.js';
54
55
  export { refreshHeaders, withRefreshToken } from './refresh.js';
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ export { IconButton, Page, PageBody, PageHeader, } from './page.js';
56
56
  export { DEFAULT_PREFS, deviceTimeZone, TIME_ZONES, getPrefs, resolvedTheme, setPref, timeZoneLabel, usePrefs, ASSISTANT_WIDTH, } from './prefs.js';
57
57
  export { KIT, configureLumen } from './kit-config.js';
58
58
  export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, errorText, useClock, } from './integrations.js';
59
+ export { LumenExpandList, LumenExpandRow } from './expand-list.js';
59
60
  export { LumenPager } from './pager.js';
60
61
  export { createDeskQueryClient } from './query-client.js';
61
62
  export { refreshHeaders, withRefreshToken } from './refresh.js';
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { LumenExpandRow } from './expand-list.js';
2
3
  /** A key's last test: when, whether it worked, and what it read or why it failed. */
3
4
  export interface KeyTestResult {
4
5
  at: string;
@@ -13,15 +14,8 @@ export declare const errorText: (e: unknown) => string;
13
14
  export declare function ErrorLine({ error }: {
14
15
  error: unknown;
15
16
  }): import("react").JSX.Element | null;
16
- /** One integration: a row that expands in place. */
17
- export declare function IntegrationRow({ name, meta, badge, open, onOpenChange, children, }: {
18
- name: string;
19
- meta: string;
20
- badge: ReactNode;
21
- open: boolean;
22
- onOpenChange: (open: boolean) => void;
23
- children: ReactNode;
24
- }): import("react").JSX.Element;
17
+ /** One integration: a row that expands in place (LumenExpandRow, kept under its old name). */
18
+ export declare const IntegrationRow: typeof LumenExpandRow;
25
19
  /**
26
20
  * One linked connection (a bank, a broker) and its accounts (an app banks and
27
21
  * An app a broker link brokers alike). Collapsed by default, showing its name, `meta` and status; a
@@ -4,6 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // the Test line that runs it again. A saved key says nothing; each row shows whether it works.
5
5
  import { AlertTriangleIcon, CheckIcon, ChevronRightIcon, RefreshCwIcon } from 'lucide-react';
6
6
  import { useState } from 'react';
7
+ import { LumenExpandRow } from './expand-list.js';
7
8
  import { IconButton } from './icon-button.js';
8
9
  import { cn } from './lib/utils.js';
9
10
  import { usePrefs } from './prefs.js';
@@ -32,10 +33,8 @@ export function ErrorLine({ error }) {
32
33
  return null;
33
34
  return (_jsx("p", { role: "alert", className: "text-xs text-loss", children: errorText(error) }));
34
35
  }
35
- /** One integration: a row that expands in place. */
36
- export function IntegrationRow({ name, meta, badge, open, onOpenChange, children, }) {
37
- return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange, 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 }), _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 })] }));
38
- }
36
+ /** One integration: a row that expands in place (LumenExpandRow, kept under its old name). */
37
+ export const IntegrationRow = LumenExpandRow;
39
38
  /**
40
39
  * One linked connection (a bank, a broker) and its accounts (an app banks and
41
40
  * An app a broker link brokers alike). Collapsed by default, showing its name, `meta` and status; a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/people/index.d.ts",
20
20
  "default": "./dist/people/index.js"
21
21
  },
22
+ "./assistant": {
23
+ "types": "./dist/assistant/index.d.ts",
24
+ "default": "./dist/assistant/index.js"
25
+ },
22
26
  "./theme.css": "./dist/theme.css"
23
27
  },
24
28
  "files": [
@@ -45,7 +49,7 @@
45
49
  "@codemirror/lang-javascript": "6.2.5",
46
50
  "@codemirror/lang-json": "6.0.2",
47
51
  "@codemirror/lang-sql": "6.10.0",
48
- "@columenlabs/lumen-ui-types": "0.1.77",
52
+ "@columenlabs/lumen-ui-types": "0.1.78",
49
53
  "@dnd-kit/core": "6.3.1",
50
54
  "@dnd-kit/sortable": "10.0.0",
51
55
  "@dnd-kit/utilities": "3.2.2",