@columenlabs/lumen-ui 0.1.310 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.310",
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.310",
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",