@columenlabs/lumen-ui 0.1.309 → 0.1.311

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.
@@ -110,7 +110,7 @@ export declare const allowanceAmount: (u: AllowanceUnit, n: number) => string;
110
110
  * and a chevron that shows its steps (`children`). The chat draws each task this way; an app draws
111
111
  * its own work the same way (an edit waiting for review) so every piece of work looks alike.
112
112
  */
113
- export declare function WorkCard({ view, live, state, actions, onStop, steps, open: shownAt, children, }: {
113
+ export declare function WorkCard({ view, live, state, actions, onStop, steps, open: shownAt, rows, children, }: {
114
114
  view: AssistantTaskView;
115
115
  live?: boolean;
116
116
  /** The task's own state, for `data-state`. */
@@ -121,6 +121,8 @@ export declare function WorkCard({ view, live, state, actions, onStop, steps, op
121
121
  steps?: number;
122
122
  /** Starts open; by default it is open while the work runs. */
123
123
  open?: boolean;
124
+ /** `children` are rows with their own padding and lines (a task's steps), drawn edge to edge. */
125
+ rows?: boolean;
124
126
  children?: ReactNode;
125
127
  }): import("react").JSX.Element;
126
128
  /**
@@ -153,7 +155,13 @@ export declare function readAssistantImage(file: File): Promise<AssistantImage |
153
155
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
154
156
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
155
157
  */
156
- export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, voice, }: {
158
+ export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, modes, onSend, onStop, meter, voice, queueShown, editQueued, }: {
159
+ /** The queue is drawn under the running task's card instead of above the box. */
160
+ queueShown?: boolean;
161
+ /** Gets this box's Edit, so a queued message shown elsewhere comes back here to edit. */
162
+ editQueued?: {
163
+ current: ((id: string) => void) | null;
164
+ };
157
165
  /** A mic beside Send that types what the person says (`VoiceButton`); hidden where the browser can't. */
158
166
  voice?: boolean;
159
167
  /** Drawn in the icon row after the paperclip and the modes: `LumenAssistant` puts its context donut here. */
@@ -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, COMPACT_AT, stepLineParts, stepLineText, } from '@columenlabs/lumen-ui-types/assistant';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
- import { ArrowUpIcon, CheckCheckIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CircleAlertIcon, CircleCheckIcon, CircleXIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, HandIcon, LightbulbIcon, ListPlusIcon, LoaderCircleIcon, MonitorIcon, PaperclipIcon, PencilIcon, PlusIcon, RefreshCwIcon, ReplyIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
9
+ import { ArrowUpIcon, CheckCheckIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CircleAlertIcon, CircleCheckIcon, CircleHelpIcon, CircleXIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, HandIcon, LightbulbIcon, ListPlusIcon, LoaderCircleIcon, MessageSquareTextIcon, MonitorIcon, PaperclipIcon, PencilIcon, PlusIcon, RefreshCwIcon, ReplyIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, } 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';
@@ -52,6 +52,10 @@ const seconds = (ms) => !ms ? '' : ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFix
52
52
  export function LumenAssistant({ client, context, onSettings, keepDays = 30, chatScope, onClose, chat: outside, commands = [], sources = [], images = false, modes = [], imageTool, compactAt = COMPACT_AT, taskActions, taskState, taskView, }) {
53
53
  const own = useAssistantChat(client);
54
54
  const chat = outside ?? own;
55
+ // While the chat ends in an open task, its waiting messages sit under its card, not above the box.
56
+ const tail = chat.items[chat.items.length - 1];
57
+ const queueUnder = tail?.kind === 'task' && (taskState?.(tail) ?? tail.state) === 'open' && chat.queue.length > 0;
58
+ const editQueued = useRef(null);
55
59
  const qc = useQueryClient();
56
60
  const status = useAssistantStatus(client);
57
61
  const [allScopes, setAllScopes] = useAllScopes(client.key);
@@ -195,7 +199,15 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
195
199
  title: c.title,
196
200
  updated_at: new Date(c.at).toISOString(),
197
201
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
198
- })) ?? (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(ImageTool.Provider, { value: imageTool ?? null, children: _jsx(TaskSlots.Provider, { value: { actions: taskActions, state: taskState, view: taskView, stop: chat.stop }, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()), onFresh: (choice) => chat.fresh(choice, extra()), onAnswer: (text) => chat.send(text, 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, modes: modes, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop, meter: _jsx(ContextDonut, { usage: chat.usage, compactAt: s?.compact_at ?? compactAt, items: chat.items }) })] }));
202
+ })) ?? (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(ImageTool.Provider, { value: imageTool ?? null, children: _jsx(TaskSlots.Provider, { value: { actions: taskActions, state: taskState, view: taskView, stop: chat.stop }, children: _jsx(TaskAnswer.Provider, { value: s?.wait ? null : (text) => chat.send(text, extra()), children: _jsx(TaskQueue.Provider, { value: queueUnder
203
+ ? {
204
+ queue: chat.queue,
205
+ canSteer: chat.busy && chat.canSteer,
206
+ onSteer: chat.steerQueued,
207
+ onEdit: (id) => editQueued.current?.(id),
208
+ onRemove: (id) => void chat.unqueue(id),
209
+ }
210
+ : null, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()), onFresh: (choice) => chat.fresh(choice, extra()), onAnswer: (text) => chat.send(text, 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, queueShown: queueUnder, editQueued: editQueued, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, modes: modes, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop, meter: _jsx(ContextDonut, { usage: chat.usage, compactAt: s?.compact_at ?? compactAt, items: chat.items }) })] }));
199
211
  }
200
212
  /** How near the end of the chat (px) still counts as at the end, so it keeps following. */
201
213
  const FOLLOW_PX = 48;
@@ -239,7 +251,8 @@ function Messages({ chat, status, onSettings, onRetry, onFresh, onAnswer, }) {
239
251
  : '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(FreshChoice.Provider, { value: chat.busy ? null : onFresh, children: _jsx(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy, turns: true }) }) }) }) })), !chat.busy &&
240
252
  !status?.wait &&
241
253
  (() => {
242
- const c = lastChoices(chat.items);
254
+ // A task's question shows its answers in its card (its Follow-up step).
255
+ const c = chat.items[chat.items.length - 1]?.kind === 'task' ? null : lastChoices(chat.items);
243
256
  return c ? (_jsx("div", { className: "mt-2", children: _jsx(QuickReplies, { choices: c, onSend: onAnswer }, chat.items.length) })) : null;
244
257
  })(), 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 })] }));
245
258
  }
@@ -571,6 +584,10 @@ function HandOff({ item, onSettle, busy, }) {
571
584
  }
572
585
  /** The app's parts of a task's card (`taskActions`, `taskState`, `taskView`) and the chat's Stop. */
573
586
  const TaskSlots = createContext({});
587
+ /** Sends a quick reply or an answer in the person's words (null while the chat cannot take one). */
588
+ const TaskAnswer = createContext(null);
589
+ /** Messages waiting for the running task, drawn under its card: Steer now, Edit, Remove. */
590
+ const TaskQueue = createContext(null);
574
591
  /** The words a card says without the app's own view. */
575
592
  const TASK_STATE = {
576
593
  open: 'Open',
@@ -625,22 +642,138 @@ function cardAt(items) {
625
642
  * and a chevron that shows its steps (`children`). The chat draws each task this way; an app draws
626
643
  * its own work the same way (an edit waiting for review) so every piece of work looks alike.
627
644
  */
628
- export function WorkCard({ view, live = false, state, actions, onStop, steps = 0, open: shownAt, children, }) {
645
+ export function WorkCard({ view, live = false, state, actions, onStop, steps = 0, open: shownAt, rows = false, children, }) {
629
646
  const tone = TONE[view.tone];
630
647
  const [open, setOpen] = useState(null);
631
648
  const shown = (open ?? (shownAt || live)) && steps > 0;
632
649
  const Icon = tone.icon;
633
- return (_jsxs("section", { "data-slot": "chat-task", "data-state": state, "data-tone": view.tone, "aria-label": view.title, className: cn('flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card', view.tone === 'live' && 'border-primary/45', view.tone === 'ready' && 'border-gain/45', view.tone === 'failed' && 'border-loss/45', view.tone === 'ask' && 'border-warning/45'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5 py-2 pr-1.5 pl-2.5", children: [_jsx("span", { className: cn('grid size-7 shrink-0 place-items-center rounded-md', tone.box), children: _jsx(Icon, { className: cn('size-4', tone.spin && 'animate-spin'), "aria-hidden": true }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate font-medium text-sm", title: view.title, children: view.title }), (view.detail || view.state || (live && view.since)) && (_jsxs("span", { className: "flex min-w-0 gap-1 text-muted-foreground text-xs", children: [view.detail && (_jsx("span", { className: "min-w-0 truncate", title: view.detail, children: view.detail })), view.state && (_jsxs("span", { className: cn('shrink-0', tone.word), children: [view.detail ? '· ' : '', view.state] })), live && view.since ? (_jsxs("span", { className: "shrink-0 tabular-nums", children: ["\u00B7 ", _jsx(Clock, { since: view.since })] })) : null] }))] }), _jsxs("span", { className: "flex shrink-0 items-center gap-1", children: [actions, onStop && (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-sm", onClick: onStop })), steps > 0 && (_jsx(IconButton, { icon: shown ? ChevronUpIcon : ChevronDownIcon, label: shown ? 'Hide steps' : 'Show steps', size: "icon-sm", "aria-expanded": shown, onClick: () => setOpen(!shown) }))] })] }), live && (_jsx("div", { className: "h-0.5 w-full overflow-hidden bg-primary/15", children: _jsx("div", { className: "h-full w-1/3 animate-[lumen-progress_1.4s_ease-in-out_infinite] bg-primary" }) })), shown && _jsx("div", { className: "border-t px-3 py-2", children: children })] }));
650
+ return (_jsxs("section", { "data-slot": "chat-task", "data-state": state, "data-tone": view.tone, "aria-label": view.title, className: cn('flex min-w-0 flex-col overflow-clip rounded-lg border bg-card', view.tone === 'live' && 'border-primary/45', view.tone === 'ready' && 'border-gain/45', view.tone === 'failed' && 'border-loss/45', view.tone === 'ask' && 'border-warning/45'), children: [_jsxs("div", { "data-slot": "chat-task-head", className: "sticky -top-3 z-10 flex min-w-0 items-center gap-2.5 rounded-t-lg bg-card py-2 pr-1.5 pl-2.5", children: [_jsx("span", { className: cn('grid size-7 shrink-0 place-items-center rounded-md', tone.box), children: _jsx(Icon, { className: cn('size-4', tone.spin && 'animate-spin'), "aria-hidden": true }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate font-medium text-sm", title: view.title, children: view.title }), (view.detail || view.state || (live && view.since)) && (_jsxs("span", { className: "flex min-w-0 gap-1 text-muted-foreground text-xs", children: [view.detail && (_jsx("span", { className: "min-w-0 truncate", title: view.detail, children: view.detail })), view.state && (_jsxs("span", { className: cn('shrink-0', tone.word), children: [view.detail ? '· ' : '', view.state] })), live && view.since ? (_jsxs("span", { className: "shrink-0 tabular-nums", children: ["\u00B7 ", _jsx(Clock, { since: view.since })] })) : null] }))] }), _jsxs("span", { className: "flex shrink-0 items-center gap-1", children: [actions, onStop && (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-sm", onClick: onStop })), steps > 0 && (_jsx(IconButton, { icon: shown ? ChevronUpIcon : ChevronDownIcon, label: shown ? 'Hide steps' : 'Show steps', size: "icon-sm", "aria-expanded": shown, onClick: () => setOpen(!shown) }))] })] }), live && (_jsx("div", { className: "h-0.5 w-full overflow-hidden bg-primary/15", children: _jsx("div", { className: "h-full w-1/3 animate-[lumen-progress_1.4s_ease-in-out_infinite] bg-primary" }) })), shown && _jsx("div", { className: cn('border-t', !rows && 'px-3 py-2'), children: children })] }));
651
+ }
652
+ /** A line of the assistant's words as a row's title: its first sentence, without Markdown marks. */
653
+ function firstLine(text) {
654
+ const plain = text
655
+ .replace(/```[\s\S]*?```/g, ' ')
656
+ .replace(/[*_`#>[\]]|\(https?:[^)]*\)/g, '')
657
+ .replace(/\s+/g, ' ')
658
+ .trim();
659
+ const m = plain.match(/^(.{12,}?[.!?:])(\s|$)/);
660
+ return m?.[1] ?? plain;
661
+ }
662
+ /** A step that only looked something up: several in a row fold into one line. */
663
+ const lookup = (i) => i.kind === 'step' && i.ok !== false && !i.write && !i.choices && !i.live && !i.image;
664
+ /**
665
+ * A task's items as the rows of its card. Each turn's words are a step of their own: before its
666
+ * tools they are its Plan, after them its Result, and the last words of a turn that asks are the
667
+ * Follow-up, holding the question's answers. Lookups in a row fold into one line; the person's
668
+ * later messages (answers, steering) are rows too.
669
+ */
670
+ function taskRows(items, answerable) {
671
+ const rows = [];
672
+ let looked = [];
673
+ let turnHasTools = false;
674
+ let turnStart = 0;
675
+ const flush = () => {
676
+ if (looked.length === 1)
677
+ rows.push({ key: `l${rows.length}`, kind: 'line', item: looked[0] });
678
+ else if (looked.length)
679
+ rows.push({ key: `k${rows.length}`, kind: 'lookups', steps: looked });
680
+ looked = [];
681
+ };
682
+ items.forEach((it, n) => {
683
+ if (it.kind === 'user') {
684
+ flush();
685
+ turnHasTools = false;
686
+ turnStart = rows.length + 1;
687
+ rows.push({ key: `u${n}`, kind: 'you', text: it.text, steered: !!it.steered });
688
+ return;
689
+ }
690
+ if (it.kind === 'step' && it.choices)
691
+ return;
692
+ if (lookup(it)) {
693
+ looked.push(it);
694
+ turnHasTools = true;
695
+ return;
696
+ }
697
+ flush();
698
+ if (it.kind === 'text') {
699
+ if (!it.text.trim())
700
+ return;
701
+ const later = items.slice(n + 1);
702
+ const toolsAfter = later.findIndex((x) => x.kind === 'user');
703
+ const rest = toolsAfter < 0 ? later : later.slice(0, toolsAfter);
704
+ const tag = rest.some((x) => x.kind === 'step' && !x.choices)
705
+ ? 'Plan'
706
+ : turnHasTools || rows.slice(turnStart).some((r) => r.kind !== 'words')
707
+ ? 'Result'
708
+ : 'Reply';
709
+ rows.push({ key: `t${n}`, kind: 'words', tag, text: it.text });
710
+ return;
711
+ }
712
+ if (it.kind === 'step' || it.kind === 'draft')
713
+ turnHasTools = true;
714
+ rows.push({ key: `i${n}`, kind: 'line', item: it });
715
+ });
716
+ flush();
717
+ // The last words of the chat, while its question waits: the Follow-up, with its answers.
718
+ if (answerable) {
719
+ const last = rows.findLastIndex((r) => r.kind === 'words');
720
+ const r = rows[last];
721
+ if (r?.kind === 'words' && last >= rows.findLastIndex((x) => x.kind === 'you'))
722
+ rows[last] = { ...r, tag: 'Follow-up', choices: answerable };
723
+ }
724
+ return rows;
725
+ }
726
+ const ROW_ICON = {
727
+ line: WrenchIcon,
728
+ lookups: SearchIcon,
729
+ words: LightbulbIcon,
730
+ you: CornerDownRightIcon,
731
+ Plan: LightbulbIcon,
732
+ Result: CircleCheckIcon,
733
+ 'Follow-up': CircleHelpIcon,
734
+ Reply: MessageSquareTextIcon,
735
+ };
736
+ /** The person's answer in their own words, under a Follow-up's quick replies. */
737
+ function OwnAnswer({ onSend }) {
738
+ const [text, setText] = useState('');
739
+ const send = () => {
740
+ if (!text.trim())
741
+ return;
742
+ onSend(text.trim());
743
+ setText('');
744
+ };
745
+ return (_jsxs("form", { className: "flex min-w-0 items-center gap-1 rounded-lg border bg-card py-1 pr-1 pl-2.5", onSubmit: (e) => {
746
+ e.preventDefault();
747
+ send();
748
+ }, children: [_jsx("input", { value: text, onChange: (e) => setText(e.target.value), placeholder: "Something else\u2026", "aria-label": "Answer in your own words", className: "min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" }), _jsx(IconButton, { icon: ArrowUpIcon, label: "Send", size: "icon-xs", type: "submit", disabled: !text.trim() })] }));
749
+ }
750
+ /** One row of a task's card: a line that opens to its whole text (one open at a time). */
751
+ function TaskRowView({ row, open, onToggle, onSettle, busy, }) {
752
+ const answer = useContext(TaskAnswer);
753
+ if (row.kind === 'line')
754
+ return (_jsx("div", { className: "border-t px-3 py-1.5 first:border-t-0", children: _jsx(Item, { item: row.item, onSettle: onSettle, busy: busy }) }));
755
+ const tag = row.kind === 'words' ? row.tag : row.kind === 'you' ? (row.steered ? 'Steer' : 'You') : null;
756
+ const Icon = ROW_ICON[row.kind === 'words' ? row.tag : row.kind];
757
+ const now = row.kind === 'words' && row.tag === 'Follow-up';
758
+ const title = row.kind === 'lookups'
759
+ ? row.steps.map((x) => x.title).join(' · ')
760
+ : row.kind === 'words'
761
+ ? firstLine(row.text)
762
+ : row.text;
763
+ return (_jsxs("div", { "data-slot": "chat-task-row", "data-open": open || undefined, className: "border-t first:border-t-0", children: [_jsxs("button", { type: "button", "aria-expanded": open, onClick: onToggle, className: cn('flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', now && 'bg-primary/5'), children: [_jsx(Icon, { className: cn('size-3.5 shrink-0', now ? 'text-primary' : 'text-muted-foreground'), "aria-hidden": true }), tag && (_jsx("span", { className: cn('shrink-0 text-xs uppercase tracking-wide', now ? 'text-primary' : 'text-muted-foreground'), children: tag })), _jsx("span", { className: cn('min-w-0 flex-1 truncate', !open && !now && 'text-muted-foreground'), title: title, children: title }), row.kind === 'lookups' && (_jsx("span", { className: "shrink-0 text-muted-foreground text-xs tabular-nums", children: row.steps.length })), open ? (_jsx(ChevronDownIcon, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true })) : (_jsx(ChevronRightIcon, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true }))] }), open && (_jsxs("div", { className: "flex min-w-0 flex-col gap-2 px-3 pt-0.5 pb-2.5", children: [row.kind === 'lookups' ? (_jsx(Items, { items: row.steps, onSettle: onSettle, busy: busy })) : row.kind === 'words' ? (_jsx(Item, { item: { kind: 'text', text: row.text }, onSettle: onSettle, busy: busy })) : (_jsx("p", { className: "text-sm whitespace-pre-wrap [overflow-wrap:anywhere]", children: row.text })), row.kind === 'words' && row.choices && answer && (_jsxs(_Fragment, { children: [_jsx(QuickReplies, { choices: row.choices, onSend: answer }, row.key), _jsx(OwnAnswer, { onSend: answer })] }))] }))] }));
634
764
  }
635
765
  /**
636
766
  * A task's turns in the chat (`TaskItem`) as one card: a tinted icon for where it stands, its title
637
767
  * and a line under it (the app's `taskView`), the app's buttons (`taskActions`), Stop while it runs
638
- * and a chevron that shows the steps. The person's messages and the assistant's words stay in the
639
- * chat around it, so a conversation about the work reads as one; the card sits at the newest turn
640
- * that ran tools, holding that turn's steps, and earlier turns' steps stay as lines where they ran.
768
+ * and a chevron that folds its steps. The person's first message sits above it; everything after,
769
+ * the assistant's words included, is a step inside it (`taskRows`). Only the newest step is open:
770
+ * when the next one starts the one before folds, and any step opens again with a tap. Messages
771
+ * queued while it runs wait under it (`TaskQueue`).
641
772
  */
642
773
  function TaskBlock({ item, onSettle, busy, }) {
643
774
  const slots = useContext(TaskSlots);
775
+ const answer = useContext(TaskAnswer);
776
+ const queue = useContext(TaskQueue);
644
777
  const state = slots.state?.(item) ?? item.state;
645
778
  const items = item.items;
646
779
  const live = items.some(isLive);
@@ -649,11 +782,19 @@ function TaskBlock({ item, onSettle, busy, }) {
649
782
  title: item.label,
650
783
  state: live ? 'Working' : TASK_STATE[state],
651
784
  };
652
- const at = cardAt(items);
653
- const steps = items.slice(at).filter((i) => i.kind === 'step' || i.kind === 'draft');
654
- const after = items.slice(at).filter((i) => i.kind !== 'step' && i.kind !== 'draft');
655
- const card = (_jsx(WorkCard, { view: view, live: live, state: state, actions: slots.actions?.(item, live), onStop: live ? slots.stop : undefined, steps: steps.length, open: steps.some((x) => x.kind === 'step' && (!x.ok || x.write?.status === 'failed')), children: _jsx(Items, { items: steps, onSettle: onSettle, busy: busy }) }));
656
- return (_jsxs("div", { "data-slot": "chat-task-turns", className: "flex min-w-0 flex-col gap-3", children: [at > 0 && _jsx(Items, { items: items.slice(0, at), onSettle: onSettle, busy: busy }), card, after.length > 0 && _jsx(Items, { items: after, onSettle: onSettle, busy: busy })] }));
785
+ const first = items.findIndex((i) => i.kind === 'user');
786
+ const head = items.slice(0, first + 1);
787
+ const inner = items.slice(first + 1);
788
+ const answerable = answer && !busy ? lastChoices(items) : null;
789
+ const rows = taskRows(inner, answerable);
790
+ // The newest row is open unless the person picked another since the rows last changed.
791
+ const [picked, setPicked] = useState(null);
792
+ const newest = rows.length - 1;
793
+ const auto = rows[newest]?.kind === 'line' ? null : newest;
794
+ const open = picked && picked.at === rows.length ? picked.open : auto;
795
+ const failed = inner.some((x) => x.kind === 'step' && (!x.ok || x.write?.status === 'failed'));
796
+ const card = (_jsx(WorkCard, { view: view, live: live, state: state, actions: slots.actions?.(item, live), onStop: live ? slots.stop : undefined, steps: rows.length, open: failed || !!answerable || state === 'open', rows: true, children: rows.map((r, n) => (_jsx(TaskRowView, { row: r, open: open === n, onToggle: () => setPicked({ at: rows.length, open: open === n ? null : n }), onSettle: onSettle, busy: busy }, r.key))) }));
797
+ return (_jsxs("div", { "data-slot": "chat-task-turns", className: "flex min-w-0 flex-col gap-3", children: [head.length > 0 && _jsx(Items, { items: head, onSettle: onSettle, busy: busy }), card, queue && state === 'open' && queue.queue.length > 0 && (_jsx(ComposerQueue, { queue: queue.queue, canSteer: queue.canSteer, onSteer: queue.onSteer, onEdit: queue.onEdit, onRemove: queue.onRemove }))] }));
657
798
  }
658
799
  /** The quick replies the last turn offers (`StepItem.choices`), unless the person answered since. */
659
800
  function lastChoices(items) {
@@ -767,7 +908,7 @@ const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
767
908
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
768
909
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
769
910
  */
770
- export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, voice = true, }) {
911
+ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, modes = [], onSend, onStop, meter, voice = true, queueShown = false, editQueued, }) {
771
912
  const [text, setText] = useState('');
772
913
  const [caret, setCaret] = useState(0);
773
914
  const [closed, setClosed] = useState(null);
@@ -905,7 +1046,9 @@ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onS
905
1046
  setUse(q.extra.use);
906
1047
  requestAnimationFrame(() => box.current?.focus());
907
1048
  };
908
- const waiting = queue ?? (queued ? [{ id: 'queued', message: queued }] : []);
1049
+ if (editQueued)
1050
+ editQueued.current = onUnqueue ? edit : null;
1051
+ const waiting = queueShown ? [] : (queue ?? (queued ? [{ id: 'queued', message: queued }] : []));
909
1052
  const pickCommand = (o) => {
910
1053
  const c = commands.find((x) => x.name === o.value);
911
1054
  if (!c)
package/dist/index.d.ts CHANGED
@@ -41,6 +41,7 @@ export { DetailsTable, type DetailsTableProps, FactsTable } from './details-tabl
41
41
  export { DrawerSection, type DrawerSectionProps } from './drawer-section.js';
42
42
  export { type DrawerAction, LumenDrawer, type LumenDrawerProps } from './lumen-drawer.js';
43
43
  export { LumenBottomSheet, type LumenBottomSheetProps, type SheetSnap } from './lumen-bottom-sheet.js';
44
+ export { type FloatingRect, LumenFloatingPanel, type LumenFloatingPanelProps, } from './lumen-floating-panel.js';
44
45
  export { LumenCode, type LumenCodeProps, langOfPath } from './lumen-code.js';
45
46
  export { LumenCodeEditor, type LumenCodeEditorProps, type LumenCodeLang, } from './lumen-code-editor.js';
46
47
  export { RenameField, type RenameFieldProps } from './rename-field.js';
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ export { DetailsTable, FactsTable } from './details-table.js';
48
48
  export { DrawerSection } from './drawer-section.js';
49
49
  export { LumenDrawer } from './lumen-drawer.js';
50
50
  export { LumenBottomSheet } from './lumen-bottom-sheet.js';
51
+ export { LumenFloatingPanel, } from './lumen-floating-panel.js';
51
52
  export { LumenCode, langOfPath } from './lumen-code.js';
52
53
  export { LumenCodeEditor, } from './lumen-code-editor.js';
53
54
  export { RenameField } from './rename-field.js';
@@ -13,6 +13,11 @@ export interface LumenBottomSheetProps {
13
13
  onSnap: (snap: SheetSnap) => void;
14
14
  /** Its name for screen readers. */
15
15
  title: string;
16
+ /**
17
+ * Its top row (tabs, a title): shown at every snap, and part of the drag area with the grab bar,
18
+ * so a drag that starts anywhere on it moves the sheet. Taps on its buttons still reach them.
19
+ */
20
+ header?: React.ReactNode;
16
21
  /** What it holds. It stays mounted at every snap, so running work inside keeps going. */
17
22
  children: React.ReactNode;
18
23
  className?: string;
@@ -22,4 +27,4 @@ export interface LumenBottomSheetProps {
22
27
  * drags above it reach the page. The person drags its handle between `snaps` (peek, half, full);
23
28
  * it never closes, so what it holds is never unmounted. Built on vaul's snap points.
24
29
  */
25
- export declare function LumenBottomSheet({ snaps, snap, onSnap, title, children, className, }: LumenBottomSheetProps): React.JSX.Element;
30
+ export declare function LumenBottomSheet({ snaps, snap, onSnap, title, header, children, className, }: LumenBottomSheetProps): React.JSX.Element;
@@ -46,7 +46,7 @@ function useScreenHeight() {
46
46
  * drags above it reach the page. The person drags its handle between `snaps` (peek, half, full);
47
47
  * it never closes, so what it holds is never unmounted. Built on vaul's snap points.
48
48
  */
49
- export function LumenBottomSheet({ snaps, snap, onSnap, title, children, className, }) {
49
+ export function LumenBottomSheet({ snaps, snap, onSnap, title, header, children, className, }) {
50
50
  const h = useScreenHeight();
51
51
  const inset = useSafeBottom();
52
52
  // A px snap rests above the home bar: vaul gets it with the bar's height added.
@@ -63,7 +63,10 @@ export function LumenBottomSheet({ snaps, snap, onSnap, title, children, classNa
63
63
  const [typing, setTyping] = React.useState(false);
64
64
  const lifted = typing && kb ? Math.min(rested, kb.height) : undefined;
65
65
  const shown = lifted ?? rested;
66
- return (_jsx(Drawer.Root, { open: true, modal: false, dismissible: false, handleOnly: true, snapPoints: rests, activeSnapPoint: rests[at] ?? rest(snap), setActiveSnapPoint: (s) => {
66
+ return (_jsx(Drawer.Root, { open: true, modal: false, dismissible: false,
67
+ // The grab bar and the header drag it; the body is marked no-drag, so its lists and chat
68
+ // scroll and never move the sheet. (Only the 4px bar used to drag, so most swipes missed.)
69
+ snapPoints: rests, activeSnapPoint: rests[at] ?? rest(snap), setActiveSnapPoint: (s) => {
67
70
  const i = s == null ? -1 : rests.indexOf(s);
68
71
  const next = snaps[i];
69
72
  if (next !== undefined)
@@ -75,5 +78,5 @@ export function LumenBottomSheet({ snaps, snap, onSnap, title, children, classNa
75
78
  setTyping(false);
76
79
  }, "data-lifted": lifted === undefined ? undefined : '', style: lifted === undefined || !kb
77
80
  ? undefined
78
- : { top: kb.top + kb.height - lifted, bottom: 'auto', height: lifted }, className: cn('fixed inset-x-0 bottom-0 z-40 flex h-dvh flex-col rounded-t-2xl border-t bg-popover text-popover-foreground shadow-[0_-4px_16px_rgb(0_0_0/0.15)] outline-none', className), children: [_jsx(Drawer.Title, { className: "sr-only", children: title }), _jsxs("div", { className: "flex min-h-0 flex-col overflow-hidden", style: { height: shown }, children: [_jsx(Drawer.Handle, { "aria-label": "Drag to resize", className: "mx-auto! my-2! h-1! w-9! shrink-0 rounded-full! bg-border! opacity-100!" }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col', lifted === undefined && 'pb-[env(safe-area-inset-bottom)]'), children: children })] })] }) }) }) }));
81
+ : { top: kb.top + kb.height - lifted, bottom: 'auto', height: lifted }, className: cn('fixed inset-x-0 bottom-0 z-40 flex h-dvh flex-col rounded-t-2xl border-t bg-popover text-popover-foreground shadow-[0_-4px_16px_rgb(0_0_0/0.15)] outline-none', className), children: [_jsx(Drawer.Title, { className: "sr-only", children: title }), _jsxs("div", { className: "flex min-h-0 flex-col overflow-hidden", style: { height: shown }, children: [_jsx("div", { className: "flex h-5 shrink-0 items-center justify-center", children: _jsx(Drawer.Handle, { "aria-label": "Drag to resize", className: "m-0! h-1! w-9! shrink-0 rounded-full! bg-border! opacity-100!" }) }), header !== undefined && _jsx("div", { className: "shrink-0", children: header }), _jsx("div", { "data-vaul-no-drag": "", className: cn('flex min-h-0 flex-1 flex-col', lifted === undefined && 'pb-[env(safe-area-inset-bottom)]'), children: children })] })] }) }) }) }));
79
82
  }
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ /** Where the panel sits in its box, and how big it is, px. */
3
+ export interface FloatingRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface LumenFloatingPanelProps {
10
+ /** Its name for screen readers, and the fold button's tooltip. */
11
+ title: string;
12
+ /** Its top row (tabs, a title), between the grip and the fold button. Shown folded too. */
13
+ header?: React.ReactNode;
14
+ /** What it holds. It stays mounted while folded, so running work inside keeps going. */
15
+ children: React.ReactNode;
16
+ /** Where it starts on a device that has not moved it yet. */
17
+ defaultRect?: Partial<FloatingRect>;
18
+ /** Kept on this device under this key: where the person left it, its size and whether it is folded. */
19
+ storageKey?: string;
20
+ /** Folded to its header, controlled (left out, it keeps its own and the device remembers it). */
21
+ folded?: boolean;
22
+ onFold?: (folded: boolean) => void;
23
+ minWidth?: number;
24
+ minHeight?: number;
25
+ className?: string;
26
+ }
27
+ /**
28
+ * A see-through panel that floats over a live page (a 3D view, a map) inside its parent: the person
29
+ * drags it by its grip, resizes it from its edges and corner, and folds it to its header. Its box is
30
+ * the parent (which must be `relative`), and it is kept inside it when the box changes size (a phone
31
+ * turning). Taps outside it reach the page. Built on react-rnd.
32
+ */
33
+ export declare function LumenFloatingPanel({ title, header, children, defaultRect, storageKey, folded: foldedProp, onFold, minWidth, minHeight, className, }: LumenFloatingPanelProps): React.JSX.Element;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDownIcon, ChevronUpIcon, GripVerticalIcon } from 'lucide-react';
3
+ import * as React from 'react';
4
+ import { Rnd } from 'react-rnd';
5
+ import { cn } from './lib/utils.js';
6
+ import { Button } from './ui/button.js';
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
8
+ const DEFAULT = { x: 12, y: 12, width: 330, height: 300 };
9
+ function load(key) {
10
+ if (!key)
11
+ return {};
12
+ try {
13
+ return JSON.parse(localStorage.getItem(key) ?? '{}') ?? {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ /**
20
+ * A see-through panel that floats over a live page (a 3D view, a map) inside its parent: the person
21
+ * drags it by its grip, resizes it from its edges and corner, and folds it to its header. Its box is
22
+ * the parent (which must be `relative`), and it is kept inside it when the box changes size (a phone
23
+ * turning). Taps outside it reach the page. Built on react-rnd.
24
+ */
25
+ export function LumenFloatingPanel({ title, header, children, defaultRect, storageKey, folded: foldedProp, onFold, minWidth = 240, minHeight = 160, className, }) {
26
+ const saved = React.useMemo(() => load(storageKey), [storageKey]);
27
+ const [rect, setRect] = React.useState(saved.rect ?? { ...DEFAULT, ...defaultRect });
28
+ const [foldedOwn, setFoldedOwn] = React.useState(!!saved.folded);
29
+ const folded = foldedProp ?? foldedOwn;
30
+ const setFolded = (f) => {
31
+ setFoldedOwn(f);
32
+ onFold?.(f);
33
+ };
34
+ const box = React.useRef(null);
35
+ const [room, setRoom] = React.useState(null);
36
+ React.useLayoutEffect(() => {
37
+ const el = box.current;
38
+ if (!el)
39
+ return;
40
+ const read = () => setRoom({ w: el.clientWidth, h: el.clientHeight });
41
+ read();
42
+ const seen = new ResizeObserver(read);
43
+ seen.observe(el);
44
+ return () => seen.disconnect();
45
+ }, []);
46
+ React.useEffect(() => {
47
+ if (!storageKey)
48
+ return;
49
+ try {
50
+ localStorage.setItem(storageKey, JSON.stringify({ rect, folded }));
51
+ }
52
+ catch { }
53
+ }, [storageKey, rect, folded]);
54
+ // Inside the box whatever its size now: smaller than the box, and moved back in if it hangs out.
55
+ const fit = (r) => {
56
+ if (!room)
57
+ return r;
58
+ const width = Math.max(Math.min(minWidth, room.w), Math.min(r.width, room.w));
59
+ const height = Math.max(Math.min(minHeight, room.h), Math.min(r.height, room.h));
60
+ return {
61
+ width,
62
+ height,
63
+ x: Math.max(0, Math.min(r.x, room.w - width)),
64
+ y: Math.max(0, Math.min(r.y, room.h - height)),
65
+ };
66
+ };
67
+ const shown = fit(rect);
68
+ return (_jsx("div", { ref: box, className: "pointer-events-none absolute inset-0 z-20", children: room && (_jsx(Rnd, { bounds: "parent", position: { x: shown.x, y: shown.y }, size: { width: shown.width, height: folded ? 'auto' : shown.height }, minWidth: minWidth, minHeight: folded ? undefined : minHeight, dragHandleClassName: "lumen-float-grip", enableResizing: folded
69
+ ? false
70
+ : { right: true, bottom: true, bottomRight: true, left: true, bottomLeft: true },
71
+ // A finger needs a fuller edge than a mouse: 12px edges, a 24px corner.
72
+ resizeHandleStyles: {
73
+ right: { width: 12, right: -6 },
74
+ left: { width: 12, left: -6 },
75
+ bottom: { height: 12, bottom: -6 },
76
+ bottomRight: { width: 24, height: 24, right: -6, bottom: -6 },
77
+ bottomLeft: { width: 24, height: 24, left: -6, bottom: -6 },
78
+ }, onDragStop: (_e, d) => setRect((r) => ({ ...r, x: d.x, y: d.y })), onResizeStop: (_e, _dir, el, _delta, pos) => setRect({ x: pos.x, y: pos.y, width: el.offsetWidth, height: el.offsetHeight }), className: "pointer-events-auto", children: _jsxs("section", { "data-slot": "lumen-floating-panel", "aria-label": title, className: cn('flex h-full flex-col overflow-hidden rounded-xl border bg-popover/85 text-popover-foreground shadow-lg backdrop-blur-md', className), children: [_jsxs("div", { className: "flex shrink-0 items-center gap-0.5 border-b pr-1", children: [_jsx("span", { className: "lumen-float-grip flex h-11 w-8 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground active:cursor-grabbing", "aria-hidden": true, children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: header }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": folded ? `Open ${title}` : `Fold ${title}`, "aria-expanded": !folded, onClick: () => setFolded(!folded), children: folded ? _jsx(ChevronDownIcon, {}) : _jsx(ChevronUpIcon, {}) }) }), _jsx(TooltipContent, { children: folded ? `Open ${title}` : `Fold ${title}` })] })] }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col', folded && 'hidden'), children: children })] }) })) }));
79
+ }
@@ -12,10 +12,16 @@ export interface LumenTab<V extends string = string> {
12
12
  /** Left out of the row (a tab this page does not have). */
13
13
  hidden?: boolean;
14
14
  }
15
- export declare function LumenTabs<V extends string>({ tabs, value, onChange, actions, }: {
15
+ export declare function LumenTabs<V extends string>({ tabs, value, onChange, actions, variant, }: {
16
16
  tabs: LumenTab<V>[];
17
17
  value: V | undefined;
18
18
  onChange: (value: V) => void;
19
19
  /** What only the open tab uses, at the row's right end. */
20
20
  actions?: ReactNode;
21
+ /**
22
+ * `row` (default): icon and name side by side, the rest in More. `stacked`: a phone panel's bar,
23
+ * the tabs sharing the row evenly, each its icon over a short name. `icons`: icons only, each
24
+ * named in its tooltip, for a small floating panel.
25
+ */
26
+ variant?: 'row' | 'stacked' | 'icons';
21
27
  }): import("react").JSX.Element;
@@ -10,12 +10,26 @@ import { useLayoutEffect, useRef, useState } from 'react';
10
10
  import { cn } from './lib/utils.js';
11
11
  import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
12
12
  import { Tabs, TabsList, TabsTrigger } from './ui/tabs.js';
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
13
14
  /** The row's gap between tabs (`gap-6` in TabsList). */
14
15
  const GAP = 24;
15
16
  /** The More button, drawn like a tab. */
16
17
  const MORE = 'relative inline-flex shrink-0 items-center gap-1.5 pb-2 text-sm leading-4 font-medium whitespace-nowrap outline-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full hover:text-foreground focus-visible:rounded-sm focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0';
17
- export function LumenTabs({ tabs, value, onChange, actions, }) {
18
+ export function LumenTabs({ tabs, value, onChange, actions, variant = 'row', }) {
18
19
  const shown = tabs.filter((t) => !t.hidden);
20
+ if (variant !== 'row')
21
+ return (_jsx(EvenTabs, { tabs: shown, value: value, onChange: onChange, actions: actions, icons: variant === 'icons' }));
22
+ return _jsx(RowTabs, { tabs: shown, value: value, onChange: onChange, actions: actions });
23
+ }
24
+ /** A bar of tabs that share the row evenly: icon over name, or icons alone with a tooltip. */
25
+ function EvenTabs({ tabs, value, onChange, actions, icons, }) {
26
+ return (_jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(Tabs, { value: value, onValueChange: (v) => onChange(v), className: "min-w-0 flex-1", children: _jsx(TabsList, { className: "w-full gap-0", children: tabs.map((t) => {
27
+ const name = typeof t.label === 'string' ? t.label : undefined;
28
+ const trigger = (_jsxs(TabsTrigger, { value: t.value, "aria-label": icons ? name : undefined, className: cn('relative min-h-11 min-w-0 flex-1 justify-center py-1 after:inset-x-3', !icons && 'flex-col gap-0.5 text-xs leading-3 [&_svg]:size-5'), children: [_jsx(t.icon, {}), !icons && _jsx("span", { className: "max-w-full truncate", children: t.label }), t.after && (_jsx("span", { className: cn('text-xs', !icons ? 'absolute top-0.5 left-1/2 ml-2' : ''), children: t.after }))] }, t.value));
29
+ return icons ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: trigger }), _jsx(TooltipContent, { children: t.label })] }, t.value)) : (trigger);
30
+ }) }) }), actions] }));
31
+ }
32
+ function RowTabs({ tabs: shown, value, onChange, actions, }) {
19
33
  const box = useRef(null);
20
34
  const ruler = useRef(null);
21
35
  // How many tabs fit before the More menu; all of them until measured.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.309",
3
+ "version": "0.1.311",
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.309",
52
+ "@columenlabs/lumen-ui-types": "0.1.311",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",
@@ -75,6 +75,7 @@
75
75
  "react-intersection-observer": "9.16.0",
76
76
  "react-markdown": "10.1.0",
77
77
  "react-resizable-panels": "^4.14.3",
78
+ "react-rnd": "10.5.3",
78
79
  "react-zoom-pan-pinch": "4.2.0",
79
80
  "recharts": "^3.10.1",
80
81
  "remark-gfm": "4.0.1",