@columenlabs/lumen-ui 0.1.210 → 0.1.212
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
|
|
1
|
+
import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantRefNote, type AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
import { type LumenOption } from '../lumen-selector.js';
|
|
3
3
|
import type { AssistantAdapter } from './client.js';
|
|
4
4
|
import { type AssistantSource } from './sources.js';
|
|
@@ -27,6 +27,11 @@ export interface LumenAssistantProps {
|
|
|
27
27
|
commands?: AssistantCommand[];
|
|
28
28
|
/** What `#` lists everywhere; a page adds its own with `useAssistantSource`. */
|
|
29
29
|
sources?: readonly AssistantSource[];
|
|
30
|
+
/**
|
|
31
|
+
* Images can go with a message: pasted (Cmd+V / Ctrl+V) or dropped on the box, shown as chips
|
|
32
|
+
* until it is sent. Turn it on when the app's chat route passes `images` on to the agent.
|
|
33
|
+
*/
|
|
34
|
+
images?: boolean;
|
|
30
35
|
}
|
|
31
36
|
/** A slash command: typing `/` in the composer lists them. */
|
|
32
37
|
export interface AssistantCommand {
|
|
@@ -42,21 +47,24 @@ export interface AssistantCommand {
|
|
|
42
47
|
run: (args: string) => string | undefined | Promise<string | undefined>;
|
|
43
48
|
}
|
|
44
49
|
/** The assistant's chat: header, messages and composer. */
|
|
45
|
-
export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, commands, sources, }: LumenAssistantProps): import("react").JSX.Element;
|
|
50
|
+
export declare function LumenAssistant({ client, context, onSettings, keepDays, onClose, chat: outside, commands, sources, images, }: LumenAssistantProps): import("react").JSX.Element;
|
|
46
51
|
/** An allowance in its unit: `$4.20`, or `350 credits`. */
|
|
47
52
|
export declare const allowanceAmount: (u: AllowanceUnit, n: number) => string;
|
|
48
53
|
/** What the composer sends with the text: the tier picked with `@` and the things pointed at with `#`. */
|
|
49
54
|
export interface ComposerExtra {
|
|
50
55
|
tier?: string;
|
|
51
56
|
refs?: AssistantRefNote[];
|
|
57
|
+
images?: AssistantImage[];
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
|
|
55
61
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
56
62
|
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
57
63
|
*/
|
|
58
|
-
export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, onSend, onStop, }: {
|
|
64
|
+
export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
|
|
59
65
|
name: string;
|
|
66
|
+
/** Images can be pasted or dropped on the box (`LumenAssistant` `images`). */
|
|
67
|
+
images?: boolean;
|
|
60
68
|
/** The page's name for `#` This page; the document's title by default. */
|
|
61
69
|
page?: string;
|
|
62
70
|
busy: boolean;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// LumenAssistant: the chat that fills LumenAssistantPanel, for any app's assistant served by
|
|
3
|
+
// `@columenlabs/lumen-ai/assistant`. A header with the tier, Chats and New chat; the chat itself
|
|
4
|
+
// (the person's messages, the answer as it streams, each tool call as a line, drafted changes as
|
|
5
|
+
// cards to Save or Dismiss, work handed to a higher tier in its own section); and the composer,
|
|
6
|
+
// with Stop, a queued message and the chat's cost and context under it.
|
|
7
|
+
import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
8
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
9
|
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, LightbulbIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
|
|
4
10
|
import { Fragment, createContext, useContext, useEffect, useRef, useState, } from 'react';
|
|
@@ -42,7 +48,7 @@ export function useAssistantStatus(client) {
|
|
|
42
48
|
export { usd };
|
|
43
49
|
const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
|
|
44
50
|
/** The assistant's chat: header, messages and composer. */
|
|
45
|
-
export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, commands = [], sources = [], }) {
|
|
51
|
+
export function LumenAssistant({ client, context, onSettings, keepDays = 30, onClose, chat: outside, commands = [], sources = [], images = false, }) {
|
|
46
52
|
const own = useAssistantChat(client);
|
|
47
53
|
const chat = outside ?? own;
|
|
48
54
|
const qc = useQueryClient();
|
|
@@ -179,7 +185,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
|
|
|
179
185
|
id: c.id,
|
|
180
186
|
title: c.title,
|
|
181
187
|
updated_at: new Date(c.at).toISOString(),
|
|
182
|
-
})) ?? (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: 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, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, onSend: (text, more) => chat.send(text, { ...extra(), ...more }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
|
|
188
|
+
})) ?? (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: 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, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more) => chat.send(text, { ...extra(), ...more }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
|
|
183
189
|
}
|
|
184
190
|
function Messages({ chat, status, onSettings, }) {
|
|
185
191
|
const end = useRef(null);
|
|
@@ -253,7 +259,7 @@ function Item({ item, onSettle, busy, }) {
|
|
|
253
259
|
const tierName = useContext(TierNames);
|
|
254
260
|
switch (item.kind) {
|
|
255
261
|
case 'user':
|
|
256
|
-
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] }))] }));
|
|
262
|
+
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.images?.length ? (_jsx("span", { className: "mb-1 flex flex-wrap gap-1.5", children: item.images.map((src, i) => (_jsx("img", { src: src, alt: `Pasted ${i + 1}`, className: "max-h-32 max-w-40 rounded-md border object-cover" }, `${i}:${src.length}`))) })) : null, item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
|
|
257
263
|
case 'text':
|
|
258
264
|
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" }))] }));
|
|
259
265
|
case 'step':
|
|
@@ -420,17 +426,49 @@ function HandOff({ item, onSettle, busy, }) {
|
|
|
420
426
|
function ComposerChip({ sign, label, onRemove, }) {
|
|
421
427
|
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" }) }))] }));
|
|
422
428
|
}
|
|
429
|
+
/** The longest edge an image is sent at (what the models read best; larger only costs more). */
|
|
430
|
+
const IMAGE_EDGE = 1568;
|
|
431
|
+
/**
|
|
432
|
+
* A pasted or dropped image, ready to send: scaled to `IMAGE_EDGE` on a canvas and saved as JPEG
|
|
433
|
+
* (PNG when it has few pixels to lose), as base64. `null` when the browser cannot read it.
|
|
434
|
+
*/
|
|
435
|
+
async function readImage(file) {
|
|
436
|
+
try {
|
|
437
|
+
const bmp = await createImageBitmap(file);
|
|
438
|
+
const k = Math.min(1, IMAGE_EDGE / Math.max(bmp.width, bmp.height));
|
|
439
|
+
const canvas = document.createElement('canvas');
|
|
440
|
+
canvas.width = Math.max(1, Math.round(bmp.width * k));
|
|
441
|
+
canvas.height = Math.max(1, Math.round(bmp.height * k));
|
|
442
|
+
const g = canvas.getContext('2d');
|
|
443
|
+
if (!g)
|
|
444
|
+
return null;
|
|
445
|
+
g.fillStyle = '#fff';
|
|
446
|
+
g.fillRect(0, 0, canvas.width, canvas.height);
|
|
447
|
+
g.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
|
448
|
+
bmp.close();
|
|
449
|
+
const png = canvas.toDataURL('image/png');
|
|
450
|
+
const url = png.length < 400_000 ? png : canvas.toDataURL('image/jpeg', 0.85);
|
|
451
|
+
const [head, data] = url.split(',');
|
|
452
|
+
const media_type = head?.includes('png') ? 'image/png' : 'image/jpeg';
|
|
453
|
+
return data ? { media_type, data } : null;
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
|
|
423
460
|
/**
|
|
424
461
|
* The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
|
|
425
462
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
426
463
|
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
427
464
|
*/
|
|
428
|
-
export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, onSend, onStop, }) {
|
|
465
|
+
export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
|
|
429
466
|
const [text, setText] = useState('');
|
|
430
467
|
const [caret, setCaret] = useState(0);
|
|
431
468
|
const [closed, setClosed] = useState(null);
|
|
432
469
|
const [tier, setTier] = useState(null);
|
|
433
470
|
const [refs, setRefs] = useState([]);
|
|
471
|
+
const [images, setImages] = useState([]);
|
|
434
472
|
const list = useRef(null);
|
|
435
473
|
const box = useRef(null);
|
|
436
474
|
const selection = usePageSelection();
|
|
@@ -464,12 +502,40 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
464
502
|
const extra = () => ({
|
|
465
503
|
...(tier ? { tier } : {}),
|
|
466
504
|
...(refs.length ? { refs } : {}),
|
|
505
|
+
...(images.length ? { images } : {}),
|
|
467
506
|
});
|
|
468
507
|
const clear = () => {
|
|
469
508
|
setText('');
|
|
470
509
|
setCaret(0);
|
|
471
510
|
setTier(null);
|
|
472
511
|
setRefs([]);
|
|
512
|
+
setImages([]);
|
|
513
|
+
};
|
|
514
|
+
/** Adds the image files among `files`; says so when one is left out. Returns whether any was an image. */
|
|
515
|
+
const attach = (files) => {
|
|
516
|
+
const picked = [...files].filter((f) => f.type.startsWith('image/'));
|
|
517
|
+
if (!canAttach || !picked.length)
|
|
518
|
+
return false;
|
|
519
|
+
void (async () => {
|
|
520
|
+
const read = (await Promise.all(picked.map(readImage))).filter((x) => !!x);
|
|
521
|
+
if (read.length < picked.length)
|
|
522
|
+
lumenToast.error("That image couldn't be read. Try a PNG or JPEG.");
|
|
523
|
+
setImages((p) => {
|
|
524
|
+
const next = [...p];
|
|
525
|
+
let chars = next.reduce((n, x) => n + x.data.length, 0);
|
|
526
|
+
for (const img of read) {
|
|
527
|
+
if (next.length >= ASSISTANT_IMAGES_MAX ||
|
|
528
|
+
chars + img.data.length > ASSISTANT_IMAGE_CHARS) {
|
|
529
|
+
lumenToast.error(`Up to ${ASSISTANT_IMAGES_MAX} images go with one message.`);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
next.push(img);
|
|
533
|
+
chars += img.data.length;
|
|
534
|
+
}
|
|
535
|
+
return next;
|
|
536
|
+
});
|
|
537
|
+
})();
|
|
538
|
+
return true;
|
|
473
539
|
};
|
|
474
540
|
const run = async (c, args) => {
|
|
475
541
|
const keep = extra();
|
|
@@ -654,10 +720,20 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
654
720
|
const r = sourceItems.get(o.value)?.();
|
|
655
721
|
if (r)
|
|
656
722
|
setRefs((p) => [...p, r]);
|
|
657
|
-
}, 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) => {
|
|
723
|
+
}, onClose: () => setClosed(`${text}|${caret}`) })), images.length > 0 && (_jsx("div", { className: "mb-2 flex flex-wrap gap-1.5", "aria-label": "Images going with your message", children: images.map((img, i) => (_jsxs("span", { className: "relative", children: [_jsx("img", { src: imageUrl(img), alt: `Pasted ${i + 1}`, className: "size-12 rounded-md border object-cover" }), _jsx("button", { type: "button", "aria-label": `Remove image ${i + 1}`, className: "absolute -top-1.5 -right-1.5 grid size-4 place-items-center rounded-full border bg-background text-muted-foreground hover:text-foreground", onClick: () => setImages((p) => p.filter((_, j) => j !== i)), children: _jsx(XIcon, { className: "size-3" }) })] }, `${i}:${img.data.length}`))) })), (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) => {
|
|
658
724
|
setText(e.target.value);
|
|
659
725
|
setCaret(e.target.selectionStart);
|
|
660
|
-
}, onSelect: (e) => setCaret(e.currentTarget.selectionStart),
|
|
726
|
+
}, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onPaste: (e) => {
|
|
727
|
+
// An image on the clipboard becomes a chip; text pastes as usual.
|
|
728
|
+
if (!e.clipboardData.getData('text/plain') && attach(e.clipboardData.files))
|
|
729
|
+
e.preventDefault();
|
|
730
|
+
}, onDragOver: (e) => {
|
|
731
|
+
if (canAttach && [...e.dataTransfer.items].some((x) => x.type.startsWith('image/')))
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
}, onDrop: (e) => {
|
|
734
|
+
if (attach(e.dataTransfer.files))
|
|
735
|
+
e.preventDefault();
|
|
736
|
+
}, onKeyDown: (e) => {
|
|
661
737
|
if (picking && list.current?.key(e))
|
|
662
738
|
return;
|
|
663
739
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
@@ -168,6 +168,9 @@ export function useAssistantChat(client) {
|
|
|
168
168
|
...(extra?.context ? { context: extra.context.label } : {}),
|
|
169
169
|
...(extra?.refs?.length ? { refs: extra.refs } : {}),
|
|
170
170
|
...(extra?.tier ? { tier: extra.tier } : {}),
|
|
171
|
+
...(extra?.images?.length
|
|
172
|
+
? { images: extra.images.map((i) => `data:${i.media_type};base64,${i.data}`) }
|
|
173
|
+
: {}),
|
|
171
174
|
},
|
|
172
175
|
]);
|
|
173
176
|
// Every screen outcome not yet sent goes with this message; kept again if it fails.
|
package/dist/lumen-selector.js
CHANGED
|
@@ -130,7 +130,7 @@ function useDuplicateWarning(all) {
|
|
|
130
130
|
}
|
|
131
131
|
}, [all]);
|
|
132
132
|
}
|
|
133
|
-
const ROW = 'relative flex w-full cursor-default items-center gap-2 rounded-md px-2 text-sm outline-hidden select-none h-8 max-md:h-11 max-md:px-4 max-md:text-[15px] data-highlighted:bg-accent data-highlighted:text-accent-foreground data-selected:bg-muted data-disabled:pointer-events-none
|
|
133
|
+
const ROW = 'relative flex w-full cursor-default items-center gap-2 rounded-md px-2 text-sm outline-hidden select-none h-8 max-md:h-11 max-md:px-4 max-md:text-[15px] data-highlighted:bg-accent data-highlighted:text-accent-foreground data-selected:bg-muted data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4';
|
|
134
134
|
/** A row with a description or thumbnail grows to fit it. */
|
|
135
135
|
const TALL = 'h-auto min-h-8 py-1.5 max-md:h-auto';
|
|
136
136
|
const GROUP_LABEL = 'sticky top-0 z-10 bg-popover px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground max-md:px-4';
|
|
@@ -138,12 +138,14 @@ const MORE = 'flex h-7 w-full items-center gap-1.5 rounded-md pr-2 pl-8 text-lef
|
|
|
138
138
|
/** One row's inside: box or icon, label, detail, and the check when picked. */
|
|
139
139
|
export function SelectorRowContent({ option, selected, multiple, trailing, }) {
|
|
140
140
|
const Icon = option.icon;
|
|
141
|
-
|
|
141
|
+
const off = !!option.disabled;
|
|
142
|
+
const reason = typeof option.disabled === 'string' ? option.disabled : null;
|
|
143
|
+
return (_jsxs(_Fragment, { children: [option.indent && _jsx("span", { "aria-hidden": true, className: "w-4 shrink-0" }), multiple && (_jsx("span", { "aria-hidden": true, className: cn('flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input', selected && 'border-primary bg-primary text-primary-foreground'), children: selected && _jsx(CheckIcon, { className: "size-3" }) })), option.thumb ? (_jsx("span", { "aria-hidden": true, className: "shrink-0", children: option.thumb })) : (Icon && _jsx(Icon, { className: cn('text-muted-foreground', off && 'opacity-60') })), option.description ? (_jsxs("span", { className: cn('flex min-w-0 flex-1 flex-col', off && 'text-muted-foreground'), children: [_jsx("span", { className: "truncate", children: option.content ?? option.label }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: option.description })] })) : (_jsx("span", { className: cn('min-w-0 truncate', option.detail || reason ? 'max-w-2/3 shrink-0' : 'flex-1', off && 'text-muted-foreground'), children: option.content ?? option.label })), reason ? (_jsxs("span", { className: "ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-px text-xs text-foreground/80", children: [_jsx(CheckIcon, { className: "size-3! text-emerald-600 dark:text-emerald-400" }), reason] })) : (option.detail && (_jsx("span", { className: "min-w-0 flex-1 truncate text-right text-xs text-muted-foreground", title: option.detail, children: option.detail }))), trailing] }));
|
|
142
144
|
}
|
|
143
145
|
function Rows({ shown, picked, multiple, full, expand, }) {
|
|
144
146
|
return shown.map((g) => (_jsxs(Base.Group, { items: g.rows.map((o) => o.value), children: [g.label && _jsx(Base.GroupLabel, { className: GROUP_LABEL, children: g.label }), g.rows.map((o) => {
|
|
145
147
|
const row = (_jsx(Base.Item, { value: o.value, disabled: !!o.disabled || (full && !picked.has(o.value)), className: cn(ROW, (o.description || o.thumb) && TALL), children: _jsx(SelectorRowContent, { option: o, selected: picked.has(o.value), multiple: multiple && o.value !== CREATE }) }, o.value));
|
|
146
|
-
return
|
|
148
|
+
return row;
|
|
147
149
|
}), g.more > 0 && (_jsxs("button", { type: "button", className: MORE, onMouseDown: (e) => e.preventDefault(), onClick: () => expand(g.label), children: [_jsx(ChevronDownIcon, { className: "size-3.5" }), g.more, " more"] }))] }, g.label || '\u0000')));
|
|
148
150
|
}
|
|
149
151
|
const FIELD = "flex w-fit min-w-0 items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
@@ -283,12 +285,13 @@ export function LumenSelector(props) {
|
|
|
283
285
|
export function LumenSelectorList({ options, groups, query, onPick, onClose, empty, header, className, handle, label, fuzzy, }) {
|
|
284
286
|
const none = useMemo(() => new Set(), []);
|
|
285
287
|
const { shown, flat, expand } = useSelectorRows(options, groups, query, none, fuzzy);
|
|
286
|
-
const
|
|
288
|
+
const first = flat.findIndex((o) => !o.disabled);
|
|
289
|
+
const [at, setAt] = useState(first);
|
|
287
290
|
const listId = useId();
|
|
288
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: a new query starts at the first row.
|
|
289
|
-
useEffect(() => setAt(
|
|
290
|
-
const index =
|
|
291
|
-
const active = flat[index];
|
|
291
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a new query starts at the first pickable row.
|
|
292
|
+
useEffect(() => setAt(first), [query]);
|
|
293
|
+
const index = flat[at] && !flat[at].disabled ? at : first;
|
|
294
|
+
const active = index < 0 ? undefined : flat[index];
|
|
292
295
|
useEffect(() => {
|
|
293
296
|
spotlight(active?.target, active?.targetName ?? active?.label);
|
|
294
297
|
}, [active]);
|
|
@@ -300,7 +303,13 @@ export function LumenSelectorList({ options, groups, query, onPick, onClose, emp
|
|
|
300
303
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
301
304
|
e.preventDefault();
|
|
302
305
|
const step = e.key === 'ArrowDown' ? 1 : -1;
|
|
303
|
-
|
|
306
|
+
if (index < 0)
|
|
307
|
+
return true;
|
|
308
|
+
let i = index;
|
|
309
|
+
do
|
|
310
|
+
i = (i + step + flat.length) % flat.length;
|
|
311
|
+
while (flat[i]?.disabled && i !== index);
|
|
312
|
+
setAt(i);
|
|
304
313
|
return true;
|
|
305
314
|
}
|
|
306
315
|
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
|
|
@@ -317,9 +326,9 @@ export function LumenSelectorList({ options, groups, query, onPick, onClose, emp
|
|
|
317
326
|
}
|
|
318
327
|
return false;
|
|
319
328
|
},
|
|
320
|
-
}), [flat, active, onPick, onClose]);
|
|
329
|
+
}), [flat, index, active, onPick, onClose]);
|
|
321
330
|
return (_jsxs("div", { "data-slot": "lumen-selector-list", className: cn('flex max-h-80 flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10', className), children: [header, _jsxs("div", { id: listId, "aria-label": label, className: "min-h-0 overflow-y-auto overscroll-contain p-1", children: [!flat.length && (_jsx("p", { className: "px-2 py-3 text-center text-sm text-muted-foreground", children: empty ?? 'Nothing matches' })), shown.map((g) => (_jsxs("section", { "aria-label": g.label || undefined, children: [g.label && _jsx("div", { className: GROUP_LABEL, children: g.label }), g.rows.map((o) => {
|
|
322
331
|
const i = flat.indexOf(o);
|
|
323
|
-
return (_jsx("button", { type: "button", tabIndex: -1, "aria-current": i === index || undefined, disabled: !!o.disabled, "data-highlighted": i === index ? '' : undefined, className: cn(ROW, 'text-left'), onMouseMove: () => i !== index && setAt(i), onMouseDown: (e) => e.preventDefault(), onClick: () => !o.disabled && onPick(o), children: _jsx(SelectorRowContent, { option: o, trailing: i ===
|
|
332
|
+
return (_jsx("button", { type: "button", tabIndex: -1, "aria-current": i === index || undefined, disabled: !!o.disabled, "data-disabled": o.disabled ? '' : undefined, "data-highlighted": i === index ? '' : undefined, className: cn(ROW, 'text-left'), onMouseMove: () => i !== index && setAt(i), onMouseDown: (e) => e.preventDefault(), onClick: () => !o.disabled && onPick(o), children: _jsx(SelectorRowContent, { option: o, trailing: i === index ? (_jsx("kbd", { className: "rounded border px-1 font-mono text-[11px] text-muted-foreground", children: "Tab" })) : undefined }) }, o.value));
|
|
324
333
|
}), g.more > 0 && (_jsxs("button", { type: "button", className: MORE, onMouseDown: (e) => e.preventDefault(), onClick: () => expand(g.label), children: [_jsx(ChevronDownIcon, { className: "size-3.5" }), g.more, " more ", g.label.toLowerCase()] }))] }, g.label || '\u0000')))] })] }));
|
|
325
334
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.212",
|
|
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.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.212",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|