@columenlabs/lumen-ui 0.1.78 → 0.1.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AssistantChat, AssistantChatRow, AssistantEvent, AssistantMessage, AssistantProvider, AssistantSettingsPatch, AssistantStatus, DraftItem, KeyState, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  type Test = NonNullable<KeyState['test']>;
3
3
  export interface AssistantAdapter {
4
4
  /** Names the cache its reads keep (two assistants on one page need two keys). */
@@ -34,6 +34,8 @@ export interface AssistantAdapter {
34
34
  /** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
35
35
  send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
36
36
  settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
37
+ /** Takes back a change the assistant made at once. */
38
+ undoWrite(chatId: string, stepId: string): Promise<StepItem>;
37
39
  }
38
40
  /** The adapter over `assistantRoutes` mounted at `base` (`/api/assistant`). */
39
41
  export declare function assistantClient(base: string): AssistantAdapter;
@@ -62,6 +62,7 @@ export function assistantClient(base) {
62
62
  settleDraft: (chatId, draftId, action) => send('POST', `/chats/${encodeURIComponent(chatId)}/drafts/${encodeURIComponent(draftId)}`, {
63
63
  action,
64
64
  }),
65
+ undoWrite: (chatId, stepId) => send('POST', `/chats/${encodeURIComponent(chatId)}/writes/${encodeURIComponent(stepId)}/undo`),
65
66
  async send(msg, onEvent, signal) {
66
67
  const url = `${root}/chat`;
67
68
  const res = await fetch(url, {
@@ -1,4 +1,5 @@
1
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';
2
+ export { type AssistantCommand, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
3
+ export { assistantAdminSections, assistantSettingsSections } from './settings.js';
4
+ export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
4
5
  export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -3,5 +3,6 @@
3
3
  // `@columenlabs/lumen-ui-types/assistant`.
4
4
  export { assistantClient } from './client.js';
5
5
  export { LumenAssistant, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
6
- export { assistantSettingsSections } from './settings.js';
6
+ export { assistantAdminSections, assistantSettingsSections } from './settings.js';
7
+ export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
7
8
  export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -1,5 +1,7 @@
1
- import type { AssistantMessage, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AssistantMessage, AssistantRefNote, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
2
+ import { type LumenOption } from '../lumen-selector.js';
2
3
  import type { AssistantAdapter } from './client.js';
4
+ import { type AssistantSource } from './sources.js';
3
5
  import { type AssistantChatState } from './use-assistant-chat.js';
4
6
  export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
5
7
  /** The person's assistant status, shared by the panel and Settings. */
@@ -17,16 +19,54 @@ export interface LumenAssistantProps {
17
19
  onClose?: () => void;
18
20
  /** An outside chat state (the dock shares one between the button and the panel). */
19
21
  chat?: AssistantChatState;
22
+ /**
23
+ * The app's own slash commands, listed before the server's (`createAssistant` `commands`) and the
24
+ * kit's: /compact, /clear, /model and /escalate.
25
+ */
26
+ commands?: AssistantCommand[];
27
+ /** What `#` lists everywhere; a page adds its own with `useAssistantSource`. */
28
+ sources?: readonly AssistantSource[];
29
+ }
30
+ /** A slash command: typing `/` in the composer lists them. */
31
+ export interface AssistantCommand {
32
+ /** Typed after the slash: `story`. */
33
+ name: string;
34
+ /** One line in the list. */
35
+ about: string;
36
+ /** What follows the name, shown in the list (`<words>`). Without it, picking it runs it. */
37
+ args?: string;
38
+ /** Choices for the words after it, listed as they are typed (a tier, a saved search). */
39
+ options?: () => LumenOption[];
40
+ /** Runs it: a string is sent as the message; nothing when the command acted itself. */
41
+ run: (args: string) => string | undefined | Promise<string | undefined>;
20
42
  }
21
43
  /** 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, }: {
44
+ export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, commands, sources, }: LumenAssistantProps): import("react").JSX.Element;
45
+ /** What the composer sends with the text: the tier picked with `@` and the things pointed at with `#`. */
46
+ export interface ComposerExtra {
47
+ tier?: string;
48
+ refs?: AssistantRefNote[];
49
+ }
50
+ /**
51
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
52
+ * triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
53
+ * this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
54
+ */
55
+ export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, onSend, onStop, }: {
25
56
  name: string;
26
57
  busy: boolean;
27
58
  queued: string | null;
28
59
  disabled?: boolean;
29
- onSend: (text: string) => void;
60
+ commands?: AssistantCommand[];
61
+ /** What `@` lists. */
62
+ tiers?: {
63
+ id: string;
64
+ label: string;
65
+ model_label?: string;
66
+ }[];
67
+ /** What `#` lists, a group each. */
68
+ sources?: readonly AssistantSource[];
69
+ onSend: (text: string, extra: ComposerExtra) => void;
30
70
  onStop: () => void;
31
71
  }): import("react").JSX.Element;
32
72
  /**
@@ -1,19 +1,21 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { ArrowUpIcon, CheckIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PlusIcon, SparklesIcon, SquareIcon, WrenchIcon, XIcon, } from 'lucide-react';
4
- import { useEffect, useRef, useState } from 'react';
3
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
4
+ import { createContext, useContext, useEffect, useRef, useState } from 'react';
5
5
  import Markdown from 'react-markdown';
6
6
  import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
7
7
  import { IconButton } from '../icon-button.js';
8
8
  import { errorText } from '../integrations.js';
9
9
  import { cn } from '../lib/utils.js';
10
- import { LumenSelector } from '../lumen-selector.js';
10
+ import { LumenSelector, LumenSelectorList, } from '../lumen-selector.js';
11
11
  import { lumenToast } from '../lumen-toast.js';
12
12
  import { Alert, AlertDescription } from '../ui/alert.js';
13
13
  import { Button } from '../ui/button.js';
14
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
14
15
  import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
15
16
  import { Spinner } from '../ui/spinner.js';
16
17
  import { Textarea } from '../ui/textarea.js';
18
+ import { selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
17
19
  import { useAssistantChat } from './use-assistant-chat.js';
18
20
  /** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
19
21
  const CONTEXT_MAX = 200_000;
@@ -35,7 +37,7 @@ const tokens = (n) => n < 1000
35
37
  : `${(n / 1e6).toFixed(1)}M`;
36
38
  const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
37
39
  /** The assistant's chat: header, messages and composer. */
38
- export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, }) {
40
+ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, commands = [], sources = [], }) {
39
41
  const own = useAssistantChat(client);
40
42
  const chat = outside ?? own;
41
43
  const qc = useQueryClient();
@@ -60,11 +62,71 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
60
62
  lumenToast.error(errorText(e));
61
63
  }
62
64
  };
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) => ({
65
+ const tiers = s?.tiers ?? [];
66
+ const above = tiers.slice(tiers.findIndex((t) => t.id === s?.tier) + 1);
67
+ // The kit's commands; the app's own (`commands`, then the server's) come first and win a name.
68
+ const builtIns = [
69
+ {
70
+ name: 'compact',
71
+ about: 'Summarise the chat so far to free up context',
72
+ run: () => '/compact',
73
+ },
74
+ {
75
+ name: 'clear',
76
+ about: 'Start a new chat',
77
+ run: () => {
78
+ chat.reset();
79
+ return undefined;
80
+ },
81
+ },
82
+ ...(tiers.length > 1
83
+ ? [
84
+ {
85
+ name: 'model',
86
+ about: 'Answer on another tier from now on',
87
+ args: '<tier>',
88
+ options: () => tiers.map((t) => ({ value: t.id, label: t.label, detail: t.model_label })),
89
+ run: (arg) => {
90
+ const t = tiers.find((x) => x.id === arg || x.label.toLowerCase() === arg.toLowerCase());
91
+ if (t)
92
+ void setTier(t.id);
93
+ else
94
+ lumenToast.error(`No tier is called ${arg || 'that'}.`);
95
+ return undefined;
96
+ },
97
+ },
98
+ ]
99
+ : []),
100
+ ...(above[0]
101
+ ? [
102
+ {
103
+ name: 'escalate',
104
+ about: `Hand a task to ${above[0].label}`,
105
+ args: '<task>',
106
+ run: (task) => {
107
+ if (task.trim())
108
+ chat.send(task, { ...(context ? { context } : {}), tier: above[0]?.id });
109
+ return undefined;
110
+ },
111
+ },
112
+ ]
113
+ : []),
114
+ ];
115
+ const served = (s?.commands ?? []).map((c) => ({
116
+ ...c,
117
+ run: (args) => `/${c.name}${args ? ` ${args}` : ''}`,
118
+ }));
119
+ const allCommands = [];
120
+ for (const c of [...commands, ...served, ...builtIns])
121
+ if (!allCommands.some((x) => x.name === c.name))
122
+ allCommands.push(c);
123
+ const shown = useShownAssistantSources();
124
+ const allSources = [...shown, ...sources.filter((x) => !shown.some((y) => y.id === x.id))];
125
+ return (_jsxs("div", { "data-assistant": "", 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
126
  id: c.id,
65
127
  title: c.title,
66
128
  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 })] }));
129
+ })) ?? (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, commands: allCommands, tiers: tiers, sources: allSources, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
68
130
  }
69
131
  function Messages({ chat, status, onSettings, }) {
70
132
  const end = useRef(null);
@@ -73,7 +135,7 @@ function Messages({ chat, status, onSettings, }) {
73
135
  useEffect(() => {
74
136
  end.current?.scrollIntoView({ block: 'end' });
75
137
  }, [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 })] }));
138
+ 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(TierNames.Provider, { value: (id) => status?.tiers.find((t) => t.id === id)?.label ?? id, children: _jsx(UndoWrite.Provider, { value: chat.undo, children: _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
139
  }
78
140
  const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
79
141
  const WAIT = {
@@ -83,13 +145,32 @@ const WAIT = {
83
145
  app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
84
146
  own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
85
147
  };
148
+ /** A tier's name for a message's `@` chip. */
149
+ const TierNames = createContext((id) => id);
150
+ /** Takes back a change made at once (Undo on its line). */
151
+ const UndoWrite = createContext(null);
86
152
  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))) }));
153
+ // Cards drafted one after another sit in one stack, so many of them skim as lines.
154
+ const rows = [];
155
+ items.forEach((item, at) => {
156
+ const last = rows[rows.length - 1];
157
+ const prev = items[at - 1];
158
+ if (item.kind === 'draft' && prev?.kind === 'draft' && last) {
159
+ if ('drafts' in last)
160
+ last.drafts.push(item);
161
+ else
162
+ rows[rows.length - 1] = { at: last.at, drafts: [prev, item] };
163
+ }
164
+ else
165
+ rows.push({ at, item: item });
166
+ });
167
+ return (_jsx("div", { className: "flex flex-col gap-3", children: rows.map((r) => 'drafts' in r ? (_jsx(DraftStack, { drafts: r.drafts, onSettle: onSettle, busy: busy }, r.at)) : (_jsx(Item, { item: r.item, onSettle: onSettle, busy: busy }, r.at))) }));
88
168
  }
89
169
  function Item({ item, onSettle, busy, }) {
170
+ const tierName = useContext(TierNames);
90
171
  switch (item.kind) {
91
172
  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] }))] }));
173
+ return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: "@", label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
93
174
  case 'text':
94
175
  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
176
  case 'step':
@@ -114,7 +195,25 @@ function Item({ item, onSettle, busy, }) {
114
195
  }
115
196
  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
197
  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] })] }));
198
+ const undo = useContext(UndoWrite);
199
+ const [undoing, setUndoing] = useState(false);
200
+ const w = step.write;
201
+ const take = async () => {
202
+ if (!undo)
203
+ return;
204
+ setUndoing(true);
205
+ try {
206
+ await undo(step);
207
+ }
208
+ catch (e) {
209
+ lumenToast.error(errorText(e));
210
+ }
211
+ finally {
212
+ setUndoing(false);
213
+ }
214
+ };
215
+ 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" })) : w ? (_jsx(PencilIcon, { className: "size-3.5 shrink-0" })) : 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] }), w?.status === 'undone' ? (_jsx("span", { className: "shrink-0", children: "\u00B7 Undone" })) : (w?.undo &&
216
+ undo && (_jsxs(Button, { size: "sm", variant: "ghost", className: "ml-auto h-6 shrink-0 px-2 text-xs", disabled: undoing, onClick: () => void take(), children: [undoing ? _jsx(Spinner, {}) : _jsx(Undo2Icon, {}), "Undo"] })))] }));
118
217
  }
119
218
  /** The arguments as short lines: `name: value`. */
120
219
  function argLines(args) {
@@ -125,9 +224,18 @@ function argLines(args) {
125
224
  typeof v === 'string' ? v : JSON.stringify(v),
126
225
  ]);
127
226
  }
128
- function Draft({ draft, onSettle, busy, }) {
227
+ /** One line that says what a card would change: its first few values. */
228
+ const argSummary = (args) => argLines(args)
229
+ .slice(0, 3)
230
+ .map(([, v]) => v)
231
+ .join(' · ');
232
+ function DraftStatus({ draft }) {
233
+ return draft.status === 'saved' ? (_jsxs("span", { className: "flex shrink-0 items-center gap-1 text-gain text-xs", children: [_jsx(CheckIcon, { className: "size-3.5" }), " Saved"] })) : draft.status === 'dismissed' ? (_jsx("span", { className: "shrink-0 text-muted-foreground text-xs", children: "Dismissed" })) : draft.status === 'failed' ? (_jsx("span", { className: "shrink-0 text-loss text-xs", children: "Failed" })) : null;
234
+ }
235
+ /** Saves or dismisses a card, a toast on failure. */
236
+ function useSettle(onSettle) {
129
237
  const [pending, setPending] = useState(null);
130
- const go = async (action) => {
238
+ const go = async (draft, action) => {
131
239
  setPending(action);
132
240
  try {
133
241
  await onSettle(draft, action);
@@ -139,9 +247,46 @@ function Draft({ draft, onSettle, busy, }) {
139
247
  setPending(null);
140
248
  }
141
249
  };
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"] })] }))] }));
250
+ return { pending, go };
251
+ }
252
+ /**
253
+ * A drafted change. Open, it shows what it would run with and Save or Dismiss; settled, or as a
254
+ * row in a stack of cards, it is one line that opens in place.
255
+ */
256
+ function Draft({ draft, onSettle, busy, row, }) {
257
+ const { pending, go } = useSettle(onSettle);
258
+ const [open, setOpen] = useState(!row && draft.status === 'open');
259
+ const lines = argLines(draft.args);
260
+ const actions = draft.status === 'open' && (_jsxs("div", { className: "flex shrink-0 justify-end gap-1", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss'), children: "Dismiss" }), _jsxs(Button, { size: "sm", disabled: !!pending || busy, onClick: () => void go(draft, 'save'), children: [pending === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save"] })] }));
261
+ return (_jsxs(Collapsible, { open: open, onOpenChange: setOpen, "data-slot": "assistant-draft", className: cn('flex flex-col text-sm', row ? 'border-b px-1 py-1.5 last:border-b-0' : 'gap-2 rounded-lg border bg-card p-3'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": `${open ? 'Hide' : 'Show'} ${draft.title}`, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronRightIcon, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90') }), _jsx("span", { className: "shrink-0 font-medium", children: draft.title }), !open && (_jsx("span", { className: "min-w-0 truncate text-muted-foreground text-xs", children: argSummary(draft.args) }))] }) }), _jsx(DraftStatus, { draft: draft }), row && draft.status === 'open' && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: XIcon, label: `Dismiss ${draft.title}`, size: "icon-xs", disabled: !!pending || busy, onClick: () => void go(draft, 'dismiss') }), _jsx(IconButton, { icon: pending === 'save' ? Spinner : CheckIcon, label: `Save ${draft.title}`, size: "icon-xs", variant: "outline", disabled: !!pending || busy, onClick: () => void go(draft, 'save') })] }))] }), _jsxs(CollapsibleContent, { className: cn('flex flex-col gap-2', row && 'pt-1.5 pl-5'), children: [_jsx("dl", { className: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs", children: lines.slice(0, 12).map(([k, v]) => (_jsxs("div", { className: "contents", children: [_jsx("dt", { className: "text-muted-foreground", children: k }), _jsx("dd", { className: "font-code break-words", children: v })] }, k))) }), !row && actions] }), draft.error && _jsx("p", { className: "text-loss text-xs", children: draft.error })] }));
262
+ }
263
+ /** Two or more cards in a row: one stack with a count, Save all and Dismiss all, a line each. */
264
+ function DraftStack({ drafts, onSettle, busy, }) {
265
+ const [all, setAll] = useState(null);
266
+ const open = drafts.filter((d) => d.status === 'open');
267
+ const saved = drafts.filter((d) => d.status === 'saved').length;
268
+ const failed = drafts.filter((d) => d.status === 'failed').length;
269
+ // One at a time, in order: a later change may build on an earlier one.
270
+ const settleAll = async (action) => {
271
+ setAll(action);
272
+ try {
273
+ for (const d of open)
274
+ await onSettle(d, action);
275
+ }
276
+ catch (e) {
277
+ lumenToast.error(errorText(e));
278
+ }
279
+ finally {
280
+ setAll(null);
281
+ }
282
+ };
283
+ return (_jsxs("div", { "data-slot": "assistant-draft-stack", className: "flex flex-col rounded-lg border bg-card p-2 text-sm", children: [_jsxs("div", { className: "flex items-center gap-2 px-1 pb-1.5", children: [_jsxs("span", { className: "min-w-0 flex-1 text-xs", children: [_jsxs("span", { className: "font-medium", children: [drafts.length, " changes"] }), _jsxs("span", { className: "text-muted-foreground", children: [' · ', [
284
+ open.length && `${open.length} to review`,
285
+ saved && `${saved} saved`,
286
+ failed && `${failed} failed`,
287
+ ]
288
+ .filter(Boolean)
289
+ .join(' · ') || 'all settled'] })] }), open.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: !!all || busy, onClick: () => void settleAll('dismiss'), children: "Dismiss all" }), _jsxs(Button, { size: "sm", disabled: !!all || busy, onClick: () => void settleAll('save'), children: [all === 'save' ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), "Save all"] })] }))] }), _jsx("div", { className: "flex flex-col border-t", children: drafts.map((d) => (_jsx(Draft, { draft: d, onSettle: onSettle, busy: busy || !!all, row: true }, d.id))) })] }));
145
290
  }
146
291
  const WHY = {
147
292
  asked: 'you asked',
@@ -152,23 +297,182 @@ const WHY = {
152
297
  function HandOff({ item, onSettle, busy, }) {
153
298
  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
299
  }
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, }) {
300
+ /** A chip over the message box: a tier picked with `@` or a thing pointed at with `#`. */
301
+ function ComposerChip({ sign, label, onRemove, }) {
302
+ return (_jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: sign }), _jsx("span", { className: "truncate", children: label }), onRemove && (_jsx("button", { type: "button", "aria-label": `Remove ${label}`, className: "text-muted-foreground hover:text-foreground", onClick: onRemove, children: _jsx(XIcon, { className: "size-3" }) }))] }));
303
+ }
304
+ /**
305
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
306
+ * triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
307
+ * this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
308
+ */
309
+ export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], onSend, onStop, }) {
157
310
  const [text, setText] = useState('');
311
+ const [caret, setCaret] = useState(0);
312
+ const [closed, setClosed] = useState(null);
313
+ const [tier, setTier] = useState(null);
314
+ const [refs, setRefs] = useState([]);
315
+ const list = useRef(null);
316
+ const box = useRef(null);
317
+ const selection = usePageSelection();
318
+ // An app's compose request (`composeAssistant`): its chips and text, ready to finish.
319
+ const [request, take] = useComposeRequest();
320
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only a new request fills the box.
321
+ useEffect(() => {
322
+ if (!request)
323
+ return;
324
+ take();
325
+ if (request.refs?.length)
326
+ setRefs((p) => [...p, ...(request.refs ?? [])]);
327
+ if (request.tier)
328
+ setTier(request.tier);
329
+ if (request.text !== undefined) {
330
+ setText(request.text);
331
+ setCaret(request.text.length);
332
+ }
333
+ requestAnimationFrame(() => box.current?.focus());
334
+ }, [request]);
335
+ const m = /^\/(\S*)(?:\s(.*))?$/s.exec(text);
336
+ const cmd = m && m[2] !== undefined ? commands.find((c) => c.name === m[1]) : undefined;
337
+ // `@` or `#` as the word the caret is in: `pick #sto|` lists the sources' items matching "sto".
338
+ const word = /(^|\s)([@#])([^\s@#]*)$/.exec(text.slice(0, caret));
339
+ const trigger = word ? word[2] : null;
340
+ const asked = word?.[3] ?? '';
341
+ const open = closed !== `${text}|${caret}`;
342
+ const slash = m && commands.length ? (m[2] === undefined ? 'command' : cmd?.options ? 'arg' : null) : null;
343
+ const at = trigger === '@' && tiers.length > 1
344
+ ? '@'
345
+ : trigger === '#' && (sources.length || selection)
346
+ ? '#'
347
+ : null;
348
+ const picking = open ? (slash ?? at) : null;
349
+ const extra = () => ({
350
+ ...(tier ? { tier } : {}),
351
+ ...(refs.length ? { refs } : {}),
352
+ });
353
+ const clear = () => {
354
+ setText('');
355
+ setCaret(0);
356
+ setTier(null);
357
+ setRefs([]);
358
+ };
359
+ const run = async (c, args) => {
360
+ const keep = extra();
361
+ clear();
362
+ try {
363
+ const out = await c.run(args.trim());
364
+ if (out)
365
+ onSend(out, keep);
366
+ }
367
+ catch (e) {
368
+ lumenToast.error(errorText(e));
369
+ }
370
+ };
158
371
  const send = () => {
159
372
  if (!text.trim() || disabled)
160
373
  return;
161
- onSend(text);
162
- setText('');
374
+ const c = m ? commands.find((x) => x.name === m[1]) : undefined;
375
+ if (c) {
376
+ void run(c, m?.[2] ?? '');
377
+ return;
378
+ }
379
+ onSend(text, extra());
380
+ clear();
163
381
  };
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) => {
382
+ const pickCommand = (o) => {
383
+ const c = commands.find((x) => x.name === o.value);
384
+ if (!c)
385
+ return;
386
+ if (c.args) {
387
+ setText(`/${c.name} `);
388
+ setCaret(c.name.length + 2);
389
+ box.current?.focus();
390
+ }
391
+ else
392
+ void run(c, '');
393
+ };
394
+ // Takes the `@word` or `#word` out of the text once its pick is a chip.
395
+ const dropWord = () => {
396
+ if (!word)
397
+ return;
398
+ const start = caret - 1 - asked.length;
399
+ const next = text.slice(0, start) + text.slice(caret).replace(/^ /, '');
400
+ setText(next);
401
+ setCaret(start);
402
+ requestAnimationFrame(() => {
403
+ box.current?.focus();
404
+ box.current?.setSelectionRange(start, start);
405
+ });
406
+ };
407
+ const sourceItems = new Map();
408
+ const picked = selection ? selectionRef(selection) : null;
409
+ if (picked)
410
+ sourceItems.set('selection', picked);
411
+ const sourceOptions = picking === '#'
412
+ ? [
413
+ ...(picked
414
+ ? [
415
+ {
416
+ value: 'selection',
417
+ label: picked.label,
418
+ group: 'Selected text',
419
+ ...(selection?.where ? { detail: selection.where } : {}),
420
+ ...(refs.some((r) => sameRef(r, picked)) ? { disabled: 'Added' } : {}),
421
+ },
422
+ ]
423
+ : []),
424
+ ...sources.flatMap((s) => s.items(asked).map((i) => {
425
+ const value = `${s.id}:${i.id}`;
426
+ sourceItems.set(value, i.ref);
427
+ return {
428
+ value,
429
+ label: i.label,
430
+ group: s.label,
431
+ ...(i.detail ? { detail: i.detail } : {}),
432
+ ...(s.icon ? { icon: s.icon } : {}),
433
+ ...(i.keywords ? { keywords: i.keywords } : {}),
434
+ ...(i.target ? { target: i.target } : {}),
435
+ ...(refs.some((r) => sameRef(r, i.ref)) ? { disabled: 'Added' } : {}),
436
+ };
437
+ })),
438
+ ]
439
+ : [];
440
+ const tierLabel = tiers.find((t) => t.id === tier)?.label;
441
+ const hints = [
442
+ commands.length && '/',
443
+ tiers.length > 1 && '@',
444
+ (sources.length || selection) && '#',
445
+ ].filter(Boolean);
446
+ 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 })] })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
447
+ value: c.name,
448
+ label: `/${c.name}`,
449
+ ...(c.args ? { detail: c.args } : {}),
450
+ description: c.about,
451
+ })), empty: "No command matches", onPick: pickCommand, onClose: () => setClosed(`${text}|${caret}`) })), picking === 'arg' && cmd?.options && (_jsx(LumenSelectorList, { handle: list, label: `/${cmd.name}`, className: "mb-2", fuzzy: true, query: m?.[2] ?? '', options: cmd.options(), onPick: (o) => void run(cmd, o.value), onClose: () => setClosed(`${text}|${caret}`) })), picking === '@' && (_jsx(LumenSelectorList, { handle: list, label: "Answer this message on", className: "mb-2", fuzzy: true, query: asked, options: tiers.map((t) => ({
452
+ value: t.id,
453
+ label: t.label,
454
+ ...(t.model_label ? { detail: t.model_label } : {}),
455
+ })), empty: "No tier matches", onPick: (o) => {
456
+ setTier(o.value);
457
+ dropWord();
458
+ }, onClose: () => setClosed(`${text}|${caret}`) })), picking === '#' && (_jsx(LumenSelectorList, { handle: list, label: "Point at", className: "mb-2", query: asked, options: sourceOptions, empty: "Nothing matches", onPick: (o) => {
459
+ const r = sourceItems.get(o.value);
460
+ if (r)
461
+ setRefs((p) => [...p, r]);
462
+ dropWord();
463
+ }, onClose: () => setClosed(`${text}|${caret}`) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && _jsx(ComposerChip, { sign: "@", label: tierLabel, onRemove: () => setTier(null) }), refs.map((r, i) => (_jsx(ComposerChip, { sign: "#", label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => {
464
+ setText(e.target.value);
465
+ setCaret(e.target.selectionStart);
466
+ }, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onKeyDown: (e) => {
467
+ if (picking && list.current?.key(e))
468
+ return;
166
469
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
167
470
  e.preventDefault();
168
471
  send();
169
472
  }
170
473
  } }), _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
474
  }
475
+ const sameRef = (a, b) => a.kind === b.kind && JSON.stringify(a) === JSON.stringify(b);
172
476
  /** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
173
477
  function UsageLine({ usage }) {
174
478
  if (!usage || !usage.calls)
@@ -185,6 +489,11 @@ export function useLumenAssistantDock(client, o = {
185
489
  }) {
186
490
  const open = assistantOpen.use();
187
491
  const chat = useAssistantChat(client);
492
+ const [request] = useComposeRequest();
493
+ useEffect(() => {
494
+ if (request && !assistantOpen.get())
495
+ assistantOpen.write(true);
496
+ }, [request]);
188
497
  const [unread, setUnread] = useState(false);
189
498
  const wasBusy = useRef(false);
190
499
  useEffect(() => {
@@ -1,5 +1,12 @@
1
1
  import type { SettingsSectionDef } from '../settings.js';
2
2
  import type { AssistantAdapter } from './client.js';
3
+ /**
4
+ * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
5
+ * where the app keeps its admin settings, shown only to admins.
6
+ */
7
+ export declare function assistantAdminSections({ client, }: {
8
+ client: AssistantAdapter;
9
+ }): SettingsSectionDef[];
3
10
  /**
4
11
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
5
12
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
@@ -31,6 +31,7 @@ const ACCESS = [
31
31
  { value: 'off', label: 'Off' },
32
32
  { value: 'read', label: 'Read' },
33
33
  { value: 'draft', label: 'Read + draft' },
34
+ { value: 'write', label: 'Read + write' },
34
35
  ];
35
36
  /** Runs a change and keeps the status it returns, or says why it failed. */
36
37
  function useApply(client) {
@@ -172,28 +173,31 @@ function ChatgptSignin({ client, p }) {
172
173
  function SigninPaste({ id, redirect, pasted, setPasted, issues, busy, finish, }) {
173
174
  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
  }
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, }) {
176
+ const ready = (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] }));
177
+ const notSet = _jsx(Badge, { variant: "outline", children: "Not set" });
178
+ /** One provider, collapsed to a row: the person's own key (or their ChatGPT plan sign-in). */
179
+ function OwnProviderKey({ client, p }) {
177
180
  const { apply, refresh } = useApply(client);
178
- const appOn = p.app.set || !!p.app.from_env;
179
181
  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] }) }));
182
+ const badge = p.own.set && p.own.test && !p.own.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : p.own.set ? (ready) : (notSet);
183
+ return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: plan ? 'Your ChatGPT plan' : p.own.set ? 'Your key' : 'No key', badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your API key' : 'Your key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }))] }) }));
190
184
  }
185
+ /** A person's own keys, one row a provider, and what they may spend on them. */
191
186
  function Providers({ client }) {
192
187
  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
- } }));
188
+ return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
189
+ }
190
+ /** One provider's shared key, which an admin sets for everyone. */
191
+ function SharedProviderKey({ client, p }) {
192
+ const { apply, refresh } = useApply(client);
193
+ const on = p.app.set || !!p.app.from_env;
194
+ const badge = p.app.set && p.app.test && !p.app.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : on ? (ready) : (notSet);
195
+ return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: p.app.from_env ? 'From the server' : on ? 'Shared key' : 'No key', badge: badge, children: _jsx(KeyBlock, { label: "Shared key", info: "Everyone's answers run on it, within the monthly allowance below.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }) }));
196
+ }
197
+ /** Admins only: the app's shared keys and what each person may spend on them. */
198
+ function SharedKeys({ client }) {
199
+ const { apply } = useApply(client);
200
+ return (_jsx(WithStatus, { client: client, children: (s) => s.admin ? (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(SharedProviderKey, { client: client, p: p }, p.id))) }), _jsx(Row, { label: "Each person may spend", info: "A month on the shared keys, every provider together. Past it, a person's own key pays.", children: _jsx(CapInput, { label: "Allowance per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })] })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Only an admin can change these." })) }));
197
201
  }
198
202
  // ---------------------------------------------------------------- models
199
203
  function Models({ client }) {
@@ -214,7 +218,7 @@ function Models({ client }) {
214
218
  function Tools({ client }) {
215
219
  const { apply } = useApply(client);
216
220
  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." })) }));
221
+ void apply('access', () => client.settings({ access: { [g.id]: v } })), children: ACCESS.filter((a) => (a.value !== 'draft' || g.drafts) && (a.value !== 'write' || g.writes)).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
222
  }
219
223
  // ---------------------------------------------------------------- web
220
224
  function ScrapeqlBlock({ client, s }) {
@@ -260,6 +264,21 @@ function Spend({ client }) {
260
264
  ` · ${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
265
  } }));
262
266
  }
267
+ /**
268
+ * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
269
+ * where the app keeps its admin settings, shown only to admins.
270
+ */
271
+ export function assistantAdminSections({ client, }) {
272
+ return [
273
+ {
274
+ id: 'assistant-shared-keys',
275
+ title: 'Shared model keys',
276
+ icon: KeyRoundIcon,
277
+ description: "Keys everyone's answers run on, and the allowance",
278
+ content: _jsx(SharedKeys, { client: client }),
279
+ },
280
+ ];
281
+ }
263
282
  /**
264
283
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
265
284
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
@@ -278,7 +297,7 @@ export function assistantSettingsSections({ client, name, web = true, }) {
278
297
  title: 'Model providers',
279
298
  icon: KeyRoundIcon,
280
299
  sub: true,
281
- description: 'Keys for each provider, and who pays',
300
+ description: 'Your own key for each provider',
282
301
  content: _jsx(Providers, { client: client }),
283
302
  },
284
303
  {
@@ -0,0 +1,54 @@
1
+ import type { AssistantRefNote } from '@columenlabs/lumen-ui-types/assistant';
2
+ import { type ComponentType } from 'react';
3
+ /** One thing a source holds: a row in the `#` list, sent with the message as `ref`. */
4
+ export interface AssistantSourceItem {
5
+ /** Unique within its source. */
6
+ id: string;
7
+ label: string;
8
+ /** The list's right column: a date, a count, where it lives. */
9
+ detail?: string;
10
+ /** Extra words search matches. */
11
+ keywords?: string[];
12
+ /** What it stands for on screen, lit while hovered in the list (`card:<id>` or an app path). */
13
+ target?: string;
14
+ /** What goes to the model: its kind, its label and whatever finds it (an id, a path). */
15
+ ref: AssistantRefNote;
16
+ }
17
+ /** A group of things in the `#` list. */
18
+ export interface AssistantSource {
19
+ /** Unique: a page's source replaces an app source of the same id. */
20
+ id: string;
21
+ /** The group heading: "On this page", "Stories". */
22
+ label: string;
23
+ icon?: ComponentType<{
24
+ className?: string;
25
+ }>;
26
+ /** What it holds now; `query` is what follows the `#`, for a source that searches. */
27
+ items: (query: string) => readonly AssistantSourceItem[];
28
+ }
29
+ /** Adds a source to the `#` list while the calling page is shown. */
30
+ export declare function useAssistantSource(source: AssistantSource | null | undefined): void;
31
+ /** The sources pages have added, newest page last. */
32
+ export declare function useShownAssistantSources(): readonly AssistantSource[];
33
+ /** What an app puts in the composer: text, chips to point at, a tier for the message. */
34
+ export interface ComposeRequest {
35
+ text?: string;
36
+ refs?: AssistantRefNote[];
37
+ tier?: string;
38
+ }
39
+ /**
40
+ * Opens the assistant with `refs` as chips in its composer and `text` typed, focused for the person
41
+ * to finish: an Ask button beside a highlighted part of the page calls it.
42
+ */
43
+ export declare function composeAssistant(request: ComposeRequest): void;
44
+ /** The newest compose request and a way to mark it taken. */
45
+ export declare function useComposeRequest(): readonly [ComposeRequest | null, () => void];
46
+ /** Text the person highlighted on the page, and where it sits (`data-assistant-where`). */
47
+ export interface PageSelection {
48
+ text: string;
49
+ where?: string;
50
+ }
51
+ /** What the person last highlighted on the page outside the assistant, or null. */
52
+ export declare function usePageSelection(): PageSelection | null;
53
+ /** A highlight as a `#` chip: its first words, the whole text and where it sits go to the model. */
54
+ export declare const selectionRef: (s: PageSelection) => AssistantRefNote;
@@ -0,0 +1,114 @@
1
+ import { useEffect, useRef, useSyncExternalStore } from 'react';
2
+ const shown = new Map();
3
+ const listeners = new Set();
4
+ let snapshot = [];
5
+ const changed = () => {
6
+ snapshot = [...shown.values()];
7
+ for (const l of listeners)
8
+ l();
9
+ };
10
+ const subscribe = (l) => {
11
+ listeners.add(l);
12
+ return () => listeners.delete(l);
13
+ };
14
+ /** Adds a source to the `#` list while the calling page is shown. */
15
+ export function useAssistantSource(source) {
16
+ const latest = useRef(source);
17
+ latest.current = source;
18
+ const id = source?.id;
19
+ useEffect(() => {
20
+ if (!id)
21
+ return;
22
+ // The list reads the newest items, so a source registered once follows the page's data.
23
+ shown.set(id, {
24
+ get id() {
25
+ return id;
26
+ },
27
+ get label() {
28
+ return latest.current?.label ?? id;
29
+ },
30
+ get icon() {
31
+ return latest.current?.icon;
32
+ },
33
+ items: (q) => latest.current?.items(q) ?? [],
34
+ });
35
+ changed();
36
+ return () => {
37
+ shown.delete(id);
38
+ changed();
39
+ };
40
+ }, [id]);
41
+ }
42
+ /** The sources pages have added, newest page last. */
43
+ export function useShownAssistantSources() {
44
+ return useSyncExternalStore(subscribe, () => snapshot, () => snapshot);
45
+ }
46
+ let composing = null;
47
+ const composeListeners = new Set();
48
+ /**
49
+ * Opens the assistant with `refs` as chips in its composer and `text` typed, focused for the person
50
+ * to finish: an Ask button beside a highlighted part of the page calls it.
51
+ */
52
+ export function composeAssistant(request) {
53
+ composing = request;
54
+ for (const l of composeListeners)
55
+ l();
56
+ }
57
+ /** The newest compose request and a way to mark it taken. */
58
+ export function useComposeRequest() {
59
+ const request = useSyncExternalStore((l) => {
60
+ composeListeners.add(l);
61
+ return () => composeListeners.delete(l);
62
+ }, () => composing, () => composing);
63
+ const take = () => {
64
+ composing = null;
65
+ for (const l of composeListeners)
66
+ l();
67
+ };
68
+ return [request, take];
69
+ }
70
+ let selected = null;
71
+ const selectionListeners = new Set();
72
+ let watching = 0;
73
+ // The last highlight outside the assistant, kept while the person moves into the composer.
74
+ const onSelection = () => {
75
+ const sel = document.getSelection();
76
+ const node = sel?.anchorNode;
77
+ const el = node instanceof Element ? node : node?.parentElement;
78
+ if (!el || el.closest('[data-assistant], input, textarea, [contenteditable="true"]'))
79
+ return;
80
+ const text = sel?.toString().trim() ?? '';
81
+ const next = text
82
+ ? {
83
+ text: text.slice(0, 4000),
84
+ ...(el.closest('[data-assistant-where]')?.dataset.assistantWhere
85
+ ? { where: el.closest('[data-assistant-where]')?.dataset.assistantWhere }
86
+ : {}),
87
+ }
88
+ : null;
89
+ if (next?.text === selected?.text && next?.where === selected?.where)
90
+ return;
91
+ selected = next;
92
+ for (const l of selectionListeners)
93
+ l();
94
+ };
95
+ /** What the person last highlighted on the page outside the assistant, or null. */
96
+ export function usePageSelection() {
97
+ return useSyncExternalStore((l) => {
98
+ selectionListeners.add(l);
99
+ if (watching++ === 0)
100
+ document.addEventListener('selectionchange', onSelection);
101
+ return () => {
102
+ selectionListeners.delete(l);
103
+ if (--watching === 0)
104
+ document.removeEventListener('selectionchange', onSelection);
105
+ };
106
+ }, () => selected, () => null);
107
+ }
108
+ /** A highlight as a `#` chip: its first words, the whole text and where it sits go to the model. */
109
+ export const selectionRef = (s) => ({
110
+ kind: 'selection',
111
+ label: `“${s.text.length > 40 ? `${s.text.slice(0, 40)}…` : s.text}”`,
112
+ text: s.text,
113
+ ...(s.where ? { where: s.where } : {}),
114
+ });
@@ -1,4 +1,4 @@
1
- import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AssistantEvent, AssistantMessage, ChatItem, ChatUsage, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import type { AssistantAdapter } from './client.js';
3
3
  /** An item as the panel draws it: a text still streaming, a step still running. */
4
4
  export type LiveItem = ChatItem & {
@@ -20,6 +20,8 @@ export interface AssistantChatState {
20
20
  open: (id: string) => Promise<void>;
21
21
  reset: () => void;
22
22
  settle: (draft: DraftItem, action: 'save' | 'dismiss') => Promise<void>;
23
+ /** Takes back a change the assistant made at once (Undo on its line). */
24
+ undo: (step: StepItem) => Promise<void>;
23
25
  }
24
26
  /** A copy of the items with one event applied (hand-off sections copied too). */
25
27
  export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
@@ -201,18 +201,27 @@ export function useAssistantChat(client) {
201
201
  setLoading(false);
202
202
  }
203
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
204
+ // Puts a settled card or an undone line back in place, in a hand-off section too.
205
+ const replace = useCallback((next) => {
206
+ const swap = (list) => list.map((i) => i.kind === next.kind && i.id === next.id
210
207
  ? next
211
208
  : i.kind === 'handoff'
212
209
  ? { ...i, items: swap(i.items) }
213
210
  : i);
214
211
  setItems(swap);
215
- }, [client]);
212
+ }, []);
213
+ const settle = useCallback(async (draft, action) => {
214
+ const id = idRef.current;
215
+ if (!id)
216
+ return;
217
+ replace(await client.settleDraft(id, draft.id, action));
218
+ }, [client, replace]);
219
+ const undo = useCallback(async (step) => {
220
+ const id = idRef.current;
221
+ if (!id)
222
+ return;
223
+ replace(await client.undoWrite(id, step.id));
224
+ }, [client, replace]);
216
225
  return {
217
226
  chatId,
218
227
  title,
@@ -226,5 +235,6 @@ export function useAssistantChat(client) {
226
235
  open,
227
236
  reset,
228
237
  settle,
238
+ undo,
229
239
  };
230
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.78",
3
+ "version": "0.1.80",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.78",
52
+ "@columenlabs/lumen-ui-types": "0.1.80",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",