@columenlabs/lumen-ui 0.1.218 → 0.1.220

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.
@@ -6,7 +6,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  // with Stop, a queued message and the chat's cost and context under it.
7
7
  import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, } from '@columenlabs/lumen-ui-types/assistant';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
- import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
9
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, RefreshCwIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
10
10
  import { Fragment, createContext, useContext, useEffect, useRef, useState, } from 'react';
11
11
  import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
12
12
  import { screenMapFor, useAssistantScreen } from '../assistant-screen.js';
@@ -190,9 +190,9 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
190
190
  title: c.title,
191
191
  updated_at: new Date(c.at).toISOString(),
192
192
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
193
- })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: CopyIcon, label: "Copy chat", disabled: !chat.items.length, onClick: () => copyMarkdown(chatMarkdown(chat.title, chat.items, name), 'Chat') }), _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(AssistantName.Provider, { value: name, children: _jsx(Screen.Provider, { value: controls, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, queue: chat.queue, canSteer: chat.canSteer, onSteerQueued: chat.steerQueued, onUnqueue: chat.unqueue, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
193
+ })) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, toggle: chatScope && { label: chatScope.label, on: allScopes, onChange: setAllScopes }, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _jsx(IconButton, { icon: CopyIcon, label: "Copy chat", disabled: !chat.items.length, onClick: () => copyMarkdown(chatMarkdown(chat.title, chat.items, name), 'Chat') }), _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(AssistantName.Provider, { value: name, children: _jsx(Screen.Provider, { value: controls, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()) }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, queue: chat.queue, canSteer: chat.canSteer, onSteerQueued: chat.steerQueued, onUnqueue: chat.unqueue, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
194
194
  }
195
- function Messages({ chat, status, onSettings, }) {
195
+ function Messages({ chat, status, onSettings, onRetry, }) {
196
196
  const end = useRef(null);
197
197
  const count = JSON.stringify(chat.items).length;
198
198
  // biome-ignore lint/correctness/useExhaustiveDependencies: scroll to the end as the chat grows
@@ -210,7 +210,7 @@ function Messages({ chat, status, onSettings, }) {
210
210
  status.allowance &&
211
211
  status.allowance.request?.status !== 'open'
212
212
  ? 'Request more'
213
- : '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, turns: true }) }) })), 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 })] }));
213
+ : '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(RetryTurn.Provider, { value: chat.busy || status?.wait ? null : onRetry, children: _jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy, turns: true }) }) }) })), 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 })] }));
214
214
  }
215
215
  const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
216
216
  const WAIT = {
@@ -237,12 +237,27 @@ const AssistantName = createContext('Assistant');
237
237
  const TierNames = createContext((id) => id);
238
238
  /** Takes back a change made at once (Undo on its line). */
239
239
  const UndoWrite = createContext(null);
240
+ /** Sends a failed turn again; null while it can't (an answer runs, nobody can pay). */
241
+ const RetryTurn = createContext(null);
242
+ /** Where a turn's unretried error sits in the chat, when the turn starts with a message. */
243
+ function failedAt(t) {
244
+ if (t.items[0]?.kind !== 'user')
245
+ return null;
246
+ for (let i = t.items.length - 1; i > 0; i--) {
247
+ const it = t.items[i];
248
+ if (it?.kind === 'error')
249
+ return it.retried ? null : t.from + i;
250
+ }
251
+ return null;
252
+ }
240
253
  function Items({ items, onSettle, busy, turns, }) {
241
254
  const name = useContext(AssistantName);
255
+ const retry = useContext(RetryTurn);
242
256
  if (turns)
243
257
  return (_jsx("div", { className: "flex flex-col gap-3", children: chatTurns(items).map((t) => {
244
258
  const open = busy && t.from + t.items.length === items.length;
245
- return (_jsxs("div", { "data-slot": "assistant-turn", className: "group/turn flex flex-col gap-3", children: [_jsx(Items, { items: t.items, onSettle: onSettle, busy: busy }), !open && _jsx(WebSources, { items: t.items }), !open && (_jsx("div", { className: "-mt-2 flex justify-end opacity-0 transition-opacity group-hover/turn:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100", children: _jsx(IconButton, { icon: CopyIcon, label: "Copy this turn", className: "size-6 text-muted-foreground", onClick: () => copyMarkdown(turnMarkdown(t.items, name), 'Turn') }) }))] }, t.from));
259
+ const failed = retry && !open ? failedAt(t) : null;
260
+ return (_jsxs("div", { "data-slot": "assistant-turn", className: "group/turn flex flex-col gap-3", children: [_jsx(Items, { items: t.items, onSettle: onSettle, busy: busy }), !open && _jsx(WebSources, { items: t.items }), !open && (_jsxs("div", { className: "-mt-2 flex justify-end gap-0.5", children: [retry && failed !== null && (_jsx(IconButton, { icon: RefreshCwIcon, label: "Retry", className: "size-6", onClick: () => retry(failed, t.items[0]) })), _jsx("div", { className: "flex opacity-0 transition-opacity group-hover/turn:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100", children: _jsx(IconButton, { icon: CopyIcon, label: "Copy this turn", className: "size-6 text-muted-foreground", onClick: () => copyMarkdown(turnMarkdown(t.items, name), 'Turn') }) })] }))] }, t.from));
246
261
  }) }));
247
262
  // Cards drafted one after another sit in one stack, so many of them skim as lines.
248
263
  const rows = [];
@@ -263,10 +278,14 @@ function Items({ items, onSettle, busy, turns, }) {
263
278
  function Item({ item, onSettle, busy, }) {
264
279
  const tierName = useContext(TierNames);
265
280
  switch (item.kind) {
266
- case 'user':
281
+ case 'user': {
267
282
  if (item.steered)
268
283
  return (_jsxs("div", { className: "ml-8 flex flex-col items-end gap-0.5 self-end", children: [_jsxs("span", { className: "flex items-center gap-1 text-primary text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3" }), "Steered"] }), _jsx("div", { className: "rounded-lg border border-primary/40 bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words", children: item.text })] }));
269
- return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap has-[[data-slot=chat-image]]:ml-0 has-[[data-slot=chat-image]]:w-full", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: TIER, label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.images?.length ? (_jsx("span", { className: "mb-1 flex flex-wrap gap-1.5", children: item.images.map((src, i) => (_jsx(ChatImage, { src: src, alt: `Pasted ${i + 1}` }, `${i}:${src.length}`))) })) : null, item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
284
+ const bubble = (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap has-[[data-slot=chat-image]]:ml-0 has-[[data-slot=chat-image]]:w-full", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: TIER, label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.images?.length ? (_jsx("span", { className: "mb-1 flex flex-wrap gap-1.5", children: item.images.map((src, i) => (_jsx(ChatImage, { src: src, alt: `Pasted ${i + 1}` }, `${i}:${src.length}`))) })) : null, item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
285
+ if (!item.retried)
286
+ return bubble;
287
+ return (_jsxs("div", { className: "flex flex-col items-end gap-0.5 self-end", children: [_jsxs("span", { className: "flex items-center gap-1 text-primary text-xs", children: [_jsx(RefreshCwIcon, { className: "size-3" }), "Retry"] }), bubble] }));
288
+ }
270
289
  case 'text':
271
290
  return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(ChatMarkdown, { children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
272
291
  case 'step':
@@ -288,7 +307,7 @@ function Item({ item, onSettle, busy, }) {
288
307
  case 'stopped':
289
308
  return _jsx(Rule, { children: item.reason === 'cap' ? "Stopped at this month's spend cap" : 'Stopped' });
290
309
  case 'error':
291
- 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] }));
310
+ return (_jsxs("p", { role: item.retried ? undefined : 'alert', className: cn('flex items-start gap-1.5 text-sm', item.retried ? 'text-muted-foreground' : 'text-loss'), children: [_jsx(CircleAlertIcon, { className: "mt-0.5 size-4 shrink-0" }), item.text] }));
292
311
  }
293
312
  }
294
313
  /** Every screen call in the chat, hand-off sections included. */
@@ -33,6 +33,13 @@ export interface AssistantChatState {
33
33
  * next step, else queued for after it.
34
34
  */
35
35
  send: (message: string, extra?: Extra, how?: 'steer' | 'queue') => void;
36
+ /**
37
+ * Sends a failed turn again (Retry): `user` is the turn's message, `at` the error it ended in, which
38
+ * then stops offering Retry. Its words, parts, tier and images go again, with `extra` from now.
39
+ */
40
+ retry: (at: number, user: Extract<ChatItem, {
41
+ kind: 'user';
42
+ }>, extra?: Extra) => void;
36
43
  /** A queued message steered into the running answer now. */
37
44
  steerQueued: (id: string) => void;
38
45
  /** Takes a queued message out; its words and extras come back (Edit puts them in the box). */
@@ -179,7 +179,7 @@ export function useAssistantChat(client) {
179
179
  return back.some((x, i) => x !== q[i]) ? back : q;
180
180
  });
181
181
  }, []);
182
- const run = useCallback(async (message, extra) => {
182
+ const run = useCallback(async (message, extra, retried) => {
183
183
  const ctl = new AbortController();
184
184
  abort.current = ctl;
185
185
  setBusy(true);
@@ -193,6 +193,7 @@ export function useAssistantChat(client) {
193
193
  ...(extra?.context ? { context: extra.context.label } : {}),
194
194
  ...(extra?.refs?.length ? { refs: extra.refs } : {}),
195
195
  ...(extra?.tier ? { tier: extra.tier } : {}),
196
+ ...(retried ? { retried: true } : {}),
196
197
  ...(extra?.images?.length
197
198
  ? { images: extra.images.map((i) => `data:${i.media_type};base64,${i.data}`) }
198
199
  : {}),
@@ -299,6 +300,21 @@ export function useAssistantChat(client) {
299
300
  if (steer)
300
301
  void steerIn(m);
301
302
  }, [run, canSteer, steerIn]);
303
+ const retry = useCallback((at, user, extra) => {
304
+ if (abort.current)
305
+ return;
306
+ setItems((p) => p.map((x, i) => (i === at && x.kind === 'error' ? { ...x, retried: true } : x)));
307
+ const images = (user.images ?? []).flatMap((src) => {
308
+ const m = /^data:([^;]+);base64,(.*)$/.exec(src);
309
+ return m ? [{ media_type: m[1], data: m[2] }] : [];
310
+ });
311
+ void run(user.text, {
312
+ ...extra,
313
+ ...(user.refs?.length ? { refs: user.refs } : {}),
314
+ ...(user.tier ? { tier: user.tier } : {}),
315
+ ...(images.length ? { images } : {}),
316
+ }, true);
317
+ }, [run]);
302
318
  const steerQueued = useCallback((id) => {
303
319
  const m = queueRef.current.find((x) => x.id === id && !x.steering);
304
320
  if (!m || !abort.current)
@@ -397,6 +413,7 @@ export function useAssistantChat(client) {
397
413
  canSteer,
398
414
  loading,
399
415
  send,
416
+ retry,
400
417
  steerQueued,
401
418
  unqueue,
402
419
  stop,
package/dist/settings.js CHANGED
@@ -172,7 +172,7 @@ export function LumenSettings({ open, section, onOpen, onClose, appLabel, profil
172
172
  ...(sections.length ? [{ label: appLabel, items: sections.filter(shown) }] : []),
173
173
  ].filter((g) => g.items.length);
174
174
  const search = (_jsxs(InputGroup, { className: "bg-background", children: [_jsx(InputGroupAddon, { children: _jsx(SearchIcon, {}) }), _jsx(InputGroupInput, { type: "search", className: "[&::-webkit-search-cancel-button]:hidden", "aria-label": "Search settings", placeholder: "Search", value: query, onChange: (e) => setQuery(e.target.value) }), query && (_jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": "Clear search", onClick: () => setQuery(''), children: _jsx(XIcon, {}) }) }))] }));
175
- const header = (s, slot) => (_jsxs("div", { className: "flex items-end gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsxs("h3", { id: `settings-${s.id}-title`, className: "flex items-center gap-2 text-base font-semibold", children: [_jsx(s.icon, { className: "size-4 text-muted-foreground" }), s.title, s.health && _jsx(SettingStatus, { health: s.health, title: s.healthTitle })] }), s.description && (_jsx("p", { className: "truncate text-sm text-muted-foreground", title: typeof s.description === 'string' ? s.description : undefined, children: s.description }))] }), _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-2 empty:hidden" })] }));
175
+ const header = (s, slot) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-3", children: [_jsxs("h3", { id: `settings-${s.id}-title`, className: "flex shrink-0 items-center gap-2 text-base font-semibold", children: [_jsx(s.icon, { className: "size-4 text-muted-foreground" }), s.title, s.health && _jsx(SettingStatus, { health: s.health, title: s.healthTitle })] }), s.description && (_jsx("p", { className: "min-w-0 truncate text-sm text-muted-foreground sm:border-l sm:pl-3", title: typeof s.description === 'string' ? s.description : undefined, children: s.description }))] }), _jsx("div", { ref: slot, className: "flex shrink-0 items-center gap-2 empty:hidden" })] }));
176
176
  return (_jsx(Dialog, { open: open, onOpenChange: (o) => {
177
177
  if (o)
178
178
  onOpen(section);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.218",
3
+ "version": "0.1.220",
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.218",
52
+ "@columenlabs/lumen-ui-types": "0.1.220",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",