@columenlabs/lumen-ui 0.1.300 → 0.1.301

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.
@@ -49,7 +49,7 @@ function itemMarkdown(i, depth, name) {
49
49
  case 'step': {
50
50
  const facts = [
51
51
  i.rows !== undefined ? `${i.rows} rows` : '',
52
- `${(i.ms / 1000).toFixed(1)} s`,
52
+ i.ms ? `${(i.ms / 1000).toFixed(1)} s` : '',
53
53
  i.write ? i.write.status : '',
54
54
  ].filter(Boolean);
55
55
  return `**${i.ok ? 'Tool' : 'Failed'}: ${i.title}** (${facts.join(', ')})${i.error ? `\n\nError: ${i.error}` : ''}`;
@@ -1,6 +1,6 @@
1
1
  export { type AssistantAdapter, assistantClient } from './client.js';
2
2
  export { ChatImage } from './chat-image.js';
3
- export { type AssistantCommand, type AssistantMode, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
3
+ export { type AssistantCommand, type AssistantMode, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, QuickReplies, WorkCard, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
4
4
  export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
5
5
  export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
6
6
  export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
@@ -3,7 +3,7 @@
3
3
  // `@columenlabs/lumen-ui-types/assistant`.
4
4
  export { assistantClient } from './client.js';
5
5
  export { ChatImage } from './chat-image.js';
6
- export { LumenAssistant, LumenComposer, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
6
+ export { LumenAssistant, LumenComposer, QuickReplies, WorkCard, assistantStatusKey, readAssistantImage, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
7
7
  export { AutoCompactField, assistantAdminSections, assistantSettingsSections } from './settings.js';
8
8
  export { AssistantChatCost, AssistantUsageTable, UsageHistoryView, assistantUsageKey, tokens as formatTokens, usd as formatUsd, useAssistantUsage, } from './usage-history.js';
9
9
  export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
@@ -1,4 +1,4 @@
1
- import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantQuote, type AssistantRefNote, type AssistantStatus, type AssistantTask, type AssistantTaskState } from '@columenlabs/lumen-ui-types/assistant';
1
+ import { type AllowanceUnit, type AssistantImage, type AssistantMessage, type AssistantQuote, type AssistantRefNote, type AssistantStatus, type AssistantTask, type AssistantTaskState, type AssistantTaskView, type StepChoices } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import { type LucideIcon } from 'lucide-react';
3
3
  import { type ReactNode } from 'react';
4
4
  import { type LumenOption } from '../lumen-selector.js';
@@ -65,14 +65,21 @@ export interface LumenAssistantProps {
65
65
  compactAt?: number;
66
66
  /**
67
67
  * A task in the chat (`AssistantTask`, the app's work in its own session): the buttons in its
68
- * block's header, such as Keep and Drop, from the app.
68
+ * card's header, such as Discard and Accept, from the app; `live` while the task's turn runs
69
+ * (the card adds Stop then).
69
70
  */
70
- taskActions?: (task: AssistantTask) => ReactNode;
71
+ taskActions?: (task: AssistantTask, live: boolean) => ReactNode;
71
72
  /**
72
73
  * The app's word on where a task stands, ahead of the chat's: it took or dropped the task's result
73
- * in its own screens. A task no longer open drops its chip over the box.
74
+ * in its own screens. While a task is open, each message the person sends goes to it.
74
75
  */
75
76
  taskState?: (task: AssistantTask) => AssistantTaskState | undefined;
77
+ /**
78
+ * The header of a task's card (`AssistantTaskView`): its tone (running, ready, settled, failed,
79
+ * waiting on an answer, stopped), title and the line under it. Without it the card shows the
80
+ * task's label and the chat's word for its state.
81
+ */
82
+ taskView?: (task: AssistantTask, live: boolean) => AssistantTaskView | undefined;
76
83
  }
77
84
  /** A composer mode: its toggle's icon and label (also its tooltip and aria-label). */
78
85
  export interface AssistantMode {
@@ -94,9 +101,37 @@ export interface AssistantCommand {
94
101
  run: (args: string) => string | undefined | Promise<string | undefined>;
95
102
  }
96
103
  /** The assistant's chat: header, messages and composer. */
97
- export declare function LumenAssistant({ client, context, onSettings, keepDays, chatScope, onClose, chat: outside, commands, sources, images, modes, imageTool, compactAt, taskActions, taskState, }: LumenAssistantProps): import("react").JSX.Element;
104
+ export declare function LumenAssistant({ client, context, onSettings, keepDays, chatScope, onClose, chat: outside, commands, sources, images, modes, imageTool, compactAt, taskActions, taskState, taskView, }: LumenAssistantProps): import("react").JSX.Element;
98
105
  /** An allowance in its unit: `$4.20`, or `350 credits`. */
99
106
  export declare const allowanceAmount: (u: AllowanceUnit, n: number) => string;
107
+ /**
108
+ * A piece of the assistant's work as one card: a tinted icon for where it stands, its title and the
109
+ * line under it (`AssistantTaskView`), the app's buttons, Stop while it runs, a thin running bar,
110
+ * and a chevron that shows its steps (`children`). The chat draws each task this way; an app draws
111
+ * its own work the same way (an edit waiting for review) so every piece of work looks alike.
112
+ */
113
+ export declare function WorkCard({ view, live, state, actions, onStop, steps, open: shownAt, children, }: {
114
+ view: AssistantTaskView;
115
+ live?: boolean;
116
+ /** The task's own state, for `data-state`. */
117
+ state?: string;
118
+ actions?: ReactNode;
119
+ onStop?: () => void;
120
+ /** How many steps `children` holds: no chevron without any. */
121
+ steps?: number;
122
+ /** Starts open; by default it is open while the work runs. */
123
+ open?: boolean;
124
+ children?: ReactNode;
125
+ }): import("react").JSX.Element;
126
+ /**
127
+ * Quick replies under the assistant's question: one row per answer, the suggested one outlined,
128
+ * numbered so a key picks it (when no text box has the focus). With `multi` each row ticks, and
129
+ * Send sends the ticked ones together. They are shortcuts: typing an answer in the box works too.
130
+ */
131
+ export declare function QuickReplies({ choices, onSend, }: {
132
+ choices: StepChoices;
133
+ onSend: (text: string) => void;
134
+ }): import("react").JSX.Element;
100
135
  /** What the composer sends with the text: the tier picked with `#` and the things pointed at with `@`. */
101
136
  export interface ComposerExtra {
102
137
  tier?: string;
@@ -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, } from '@columenlabs/lumen-ui-types/assistant';
8
8
  import { useQuery, useQueryClient } from '@tanstack/react-query';
9
- import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, 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, 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';
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';
@@ -47,9 +47,9 @@ export function useAssistantStatus(client) {
47
47
  return useQuery({ queryKey: assistantStatusKey(client), queryFn: () => client.status() });
48
48
  }
49
49
  export { usd };
50
- const seconds = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
50
+ const seconds = (ms) => !ms ? '' : ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
51
51
  /** The assistant's chat: header, messages and composer. */
52
- export function LumenAssistant({ client, context, onSettings, keepDays = 30, chatScope, onClose, chat: outside, commands = [], sources = [], images = false, modes = [], imageTool, compactAt = COMPACT_AT, taskActions, taskState, }) {
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
55
  const qc = useQueryClient();
@@ -80,12 +80,9 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
80
80
  // each new screen call runs here once; how it ended goes with the next message.
81
81
  const screen = useAssistantScreen();
82
82
  const screenOn = !!screen && (s?.screen ?? 'move') === 'move';
83
- // The open task's chip over the box: the next message goes to the task (the app decides) until
84
- // the person steps out with its ×, or the task ends.
85
- const [stepped, setStepped] = useState(null);
86
- const openTask = chat.task && (taskState?.(chat.task) ?? chat.task.state) === 'open' && stepped !== chat.task.id
87
- ? chat.task
88
- : null;
83
+ // While a task is open, every message goes to it (the app decides what that means); the task
84
+ // ends when the app takes or drops its result.
85
+ const openTask = chat.task && (taskState?.(chat.task) ?? chat.task.state) === 'open' ? chat.task : null;
89
86
  const extra = () => ({
90
87
  ...(context ? { context } : {}),
91
88
  ...(screenOn && screen ? { screen: screenMapFor(screen) } : {}),
@@ -198,9 +195,9 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
198
195
  title: c.title,
199
196
  updated_at: new Date(c.at).toISOString(),
200
197
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
201
- })) ?? (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 }, children: _jsx(Messages, { chat: chat, status: s, onSettings: onSettings, onRetry: (at, user) => chat.retry(at, user, extra()), onFresh: (choice) => chat.fresh(choice, extra()) }) }) }) }) }), openTask && (_jsx("div", { className: "flex px-3 pt-1", children: _jsx(TaskChip, { task: openTask, onLeave: () => setStepped(openTask.id) }) })), _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 }) })] }));
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
199
  }
203
- function Messages({ chat, status, onSettings, onRetry, onFresh, }) {
200
+ function Messages({ chat, status, onSettings, onRetry, onFresh, onAnswer, }) {
204
201
  const end = useRef(null);
205
202
  const box = useRef(null);
206
203
  const count = JSON.stringify(chat.items).length;
@@ -219,7 +216,12 @@ function Messages({ chat, status, onSettings, onRetry, onFresh, }) {
219
216
  status.allowance &&
220
217
  status.allowance.request?.status !== 'open'
221
218
  ? 'Request more'
222
- : '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 && !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 })] }));
219
+ : '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 &&
220
+ !status?.wait &&
221
+ (() => {
222
+ const c = lastChoices(chat.items);
223
+ return c ? (_jsx("div", { className: "mt-2", children: _jsx(QuickReplies, { choices: c, onSend: onAnswer }, chat.items.length) })) : null;
224
+ })(), 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 })] }));
223
225
  }
224
226
  const isLive = (i) => !!i.live || ((i.kind === 'handoff' || i.kind === 'task') && i.items.some(isLive));
225
227
  const WAIT = {
@@ -456,7 +458,13 @@ function ToolStep({ step, live }) {
456
458
  setUndoing(false);
457
459
  }
458
460
  };
459
- return (_jsxs("p", { className: cn('flex min-w-0 items-center gap-1.5 text-xs', step.ok ? 'text-muted-foreground' : 'text-loss'), children: [live ? (_jsx(Spinner, { className: "size-3" })) : w ? (_jsx(PencilIcon, { className: "size-3.5 shrink-0" })) : step.ok ? (_jsx(WrenchIcon, { className: "size-3.5 shrink-0" })) : (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })), _jsx("span", { className: "truncate", children: step.title }), !live && (_jsxs("span", { className: "shrink-0 tabular-nums", children: [step.rows !== undefined && `${step.rows} row${step.rows === 1 ? '' : 's'} · `, seconds(step.ms)] })), step.error && _jsxs("span", { className: "truncate", children: ["\u00B7 ", step.error] }), w?.status === 'undone' ? (_jsx("span", { className: "shrink-0", children: "\u00B7 Undone" })) : (w?.undo &&
461
+ // A change that did not land is a failure too, whatever the call returned.
462
+ const ok = step.ok && w?.status !== 'failed';
463
+ const facts = [
464
+ step.rows !== undefined ? `${step.rows} row${step.rows === 1 ? '' : 's'}` : '',
465
+ seconds(step.ms),
466
+ ].filter(Boolean);
467
+ return (_jsxs("p", { "data-slot": "chat-step", "data-ok": ok, className: cn('flex min-w-0 items-center gap-1.5 text-xs', ok ? 'text-muted-foreground' : 'text-loss'), children: [live ? (_jsx(Spinner, { className: "size-3" })) : !ok ? (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })) : w ? (_jsx(PencilIcon, { className: "size-3.5 shrink-0" })) : (_jsx(WrenchIcon, { className: "size-3.5 shrink-0" })), _jsx("span", { className: "truncate", title: step.title, children: step.title }), !live && facts.length > 0 && (_jsx("span", { className: "shrink-0 tabular-nums", children: facts.join(' · ') })), (step.error || w?.error) && (_jsxs("span", { className: "truncate", title: step.error ?? w?.error, children: ["\u00B7 ", step.error ?? w?.error] })), w?.status === 'undone' ? (_jsx("span", { className: "shrink-0", children: "\u00B7 Undone" })) : (w?.undo &&
460
468
  undo && (_jsxs(Button, { size: "sm", variant: "ghost", className: "ml-auto h-6 shrink-0 px-2 text-xs", disabled: undoing, onClick: () => void take(), children: [undoing ? _jsx(Spinner, {}) : _jsx(Undo2Icon, {}), "Undo"] })))] }));
461
469
  }
462
470
  /** The arguments as short lines: `name: value`. */
@@ -541,28 +549,150 @@ const WHY = {
541
549
  function HandOff({ item, onSettle, busy, }) {
542
550
  return (_jsxs("section", { className: "flex flex-col gap-2 border-l-2 border-primary/40 pl-3", children: [_jsxs("p", { className: "flex items-center gap-1.5 text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3.5 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: item.label }), _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 ", WHY[item.why]] })] }), item.task && _jsx("p", { className: "text-muted-foreground text-xs", children: item.task }), _jsx(Items, { items: item.items, onSettle: onSettle, busy: busy })] }));
543
551
  }
544
- /** The app's parts of a task's block (`taskActions`, `taskState`). */
552
+ /** The app's parts of a task's card (`taskActions`, `taskState`, `taskView`) and the chat's Stop. */
545
553
  const TaskSlots = createContext({});
554
+ /** The words a card says without the app's own view. */
546
555
  const TASK_STATE = {
547
556
  open: 'Open',
548
557
  done: 'Kept',
549
558
  dropped: 'Dropped',
550
559
  };
560
+ /** Each tone's icon and colour: the icon sits in a tinted square at the head of the card. */
561
+ const TONE = {
562
+ live: {
563
+ icon: LoaderCircleIcon,
564
+ box: 'bg-primary/12 text-primary',
565
+ word: 'text-primary',
566
+ spin: true,
567
+ },
568
+ ready: { icon: CircleCheckIcon, box: 'bg-gain/15 text-gain', word: 'text-gain' },
569
+ done: {
570
+ icon: CheckCheckIcon,
571
+ box: 'bg-muted text-muted-foreground',
572
+ word: 'text-muted-foreground',
573
+ },
574
+ failed: { icon: CircleAlertIcon, box: 'bg-loss/12 text-loss', word: 'text-loss' },
575
+ ask: { icon: HandIcon, box: 'bg-warning/15 text-warning', word: 'text-warning' },
576
+ stopped: {
577
+ icon: CircleXIcon,
578
+ box: 'bg-muted text-muted-foreground',
579
+ word: 'text-muted-foreground',
580
+ },
581
+ };
582
+ /** m:ss since a time, ticking each second while shown. */
583
+ function Clock({ since }) {
584
+ const [now, setNow] = useState(() => Date.now());
585
+ useEffect(() => {
586
+ const t = setInterval(() => setNow(Date.now()), 1000);
587
+ return () => clearInterval(t);
588
+ }, []);
589
+ const s = Math.max(0, Math.floor((now - since) / 1000));
590
+ return (_jsx("span", { className: "tabular-nums", children: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }));
591
+ }
592
+ /** Where a task's card sits among its items: after the person's last message that tools answered. */
593
+ function cardAt(items) {
594
+ const last = items.findLastIndex((i) => i.kind === 'step' || i.kind === 'draft');
595
+ if (last < 0)
596
+ return items.findLastIndex((i) => i.kind === 'user') + 1;
597
+ for (let i = last; i >= 0; i--)
598
+ if (items[i]?.kind === 'user')
599
+ return i + 1;
600
+ return 0;
601
+ }
551
602
  /**
552
- * A task's turns in the chat (`TaskItem`): one block with its label and state; while open the
553
- * app's buttons (`taskActions`) sit in its header, once ended it folds to that one line.
603
+ * A piece of the assistant's work as one card: a tinted icon for where it stands, its title and the
604
+ * line under it (`AssistantTaskView`), the app's buttons, Stop while it runs, a thin running bar,
605
+ * and a chevron that shows its steps (`children`). The chat draws each task this way; an app draws
606
+ * its own work the same way (an edit waiting for review) so every piece of work looks alike.
607
+ */
608
+ export function WorkCard({ view, live = false, state, actions, onStop, steps = 0, open: shownAt, children, }) {
609
+ const tone = TONE[view.tone];
610
+ const [open, setOpen] = useState(null);
611
+ const shown = (open ?? (shownAt || live)) && steps > 0;
612
+ const Icon = tone.icon;
613
+ 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 })] }));
614
+ }
615
+ /**
616
+ * A task's turns in the chat (`TaskItem`) as one card: a tinted icon for where it stands, its title
617
+ * and a line under it (the app's `taskView`), the app's buttons (`taskActions`), Stop while it runs
618
+ * and a chevron that shows the steps. The person's messages and the assistant's words stay in the
619
+ * chat around it, so a conversation about the work reads as one; the card sits at the newest turn
620
+ * that ran tools, holding that turn's steps, and earlier turns' steps stay as lines where they ran.
554
621
  */
555
622
  function TaskBlock({ item, onSettle, busy, }) {
556
623
  const slots = useContext(TaskSlots);
557
624
  const state = slots.state?.(item) ?? item.state;
558
- const live = item.items.some(isLive);
559
- const [open, setOpen] = useState(null);
560
- const shown = open ?? (state === 'open' || live);
561
- return (_jsxs("section", { "data-slot": "chat-task", "data-state": state, className: "flex min-w-0 flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-1.5 text-xs", children: [_jsx(WrenchIcon, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx("button", { type: "button", className: "min-w-0 flex-1 truncate text-left font-medium", title: item.label, "aria-expanded": shown, onClick: () => setOpen(!shown), children: item.label }), state === 'open' ? (!live && slots.actions?.(item)) : (_jsxs("span", { className: "shrink-0 text-muted-foreground", children: [state === 'done' ? _jsx(CheckIcon, { className: "inline size-3.5", "aria-hidden": true }) : null, ' ', TASK_STATE[state]] }))] }), shown && _jsx(Items, { items: item.items, onSettle: onSettle, busy: busy })] }));
625
+ const items = item.items;
626
+ const live = items.some(isLive);
627
+ const view = slots.view?.(item, live) ?? {
628
+ tone: live ? 'live' : state === 'open' ? 'ready' : 'done',
629
+ title: item.label,
630
+ state: live ? 'Working' : TASK_STATE[state],
631
+ };
632
+ const at = cardAt(items);
633
+ const steps = items.slice(at).filter((i) => i.kind === 'step' || i.kind === 'draft');
634
+ const after = items.slice(at).filter((i) => i.kind !== 'step' && i.kind !== 'draft');
635
+ 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 }) }));
636
+ 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 })] }));
562
637
  }
563
- /** The open task's chip over the box: the next message goes to it; its × steps out. */
564
- function TaskChip({ task, onLeave }) {
565
- return (_jsxs("span", { "data-slot": "chat-task-chip", className: "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-primary/30 bg-primary/5 px-1.5 py-0.5 text-xs", title: `Your next message goes to ${task.label}`, children: [_jsx(WrenchIcon, { className: "size-3 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "truncate", children: task.label }), _jsx(IconButton, { icon: XIcon, label: "Step out of this task", size: "icon-xs", onClick: onLeave })] }));
638
+ /** The quick replies the last turn offers (`StepItem.choices`), unless the person answered since. */
639
+ function lastChoices(items) {
640
+ for (let i = items.length - 1; i >= 0; i--) {
641
+ const it = items[i];
642
+ if (it.kind === 'user')
643
+ return null;
644
+ if (it.kind === 'task') {
645
+ const inner = lastChoices(it.items);
646
+ if (inner || it.items.some((x) => x.kind === 'user'))
647
+ return inner;
648
+ continue;
649
+ }
650
+ if (it.kind === 'step' && it.choices)
651
+ return it.choices;
652
+ }
653
+ return null;
654
+ }
655
+ /**
656
+ * Quick replies under the assistant's question: one row per answer, the suggested one outlined,
657
+ * numbered so a key picks it (when no text box has the focus). With `multi` each row ticks, and
658
+ * Send sends the ticked ones together. They are shortcuts: typing an answer in the box works too.
659
+ */
660
+ export function QuickReplies({ choices, onSend, }) {
661
+ const [picked, setPicked] = useState(() => choices.multi ? choices.options.flatMap((o, n) => (o.on ? [n] : [])) : []);
662
+ const { options, multi, suggested } = choices;
663
+ const pick = (n) => {
664
+ const o = options[n];
665
+ if (!o)
666
+ return;
667
+ if (!multi)
668
+ return onSend(o.label);
669
+ setPicked((p) => (p.includes(n) ? p.filter((x) => x !== n) : [...p, n]));
670
+ };
671
+ const pickRef = useRef(pick);
672
+ pickRef.current = pick;
673
+ useEffect(() => {
674
+ const on = (e) => {
675
+ const t = e.target;
676
+ if (e.metaKey || e.ctrlKey || e.altKey)
677
+ return;
678
+ if (t?.closest('input, textarea, [contenteditable="true"], [role="dialog"]'))
679
+ return;
680
+ const n = Number(e.key);
681
+ if (!Number.isInteger(n) || n < 1 || n > options.length)
682
+ return;
683
+ e.preventDefault();
684
+ pickRef.current(n - 1);
685
+ };
686
+ window.addEventListener('keydown', on);
687
+ return () => window.removeEventListener('keydown', on);
688
+ }, [options.length]);
689
+ return (_jsxs("fieldset", { "data-slot": "chat-choices", "aria-label": "Quick replies", className: "m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0", children: [options.map((o, n) => {
690
+ const on = picked.includes(n);
691
+ return (_jsxs("button", { type: "button", "aria-pressed": multi ? on : undefined, onClick: () => pick(n), className: cn('flex min-w-0 items-center gap-2.5 rounded-lg border bg-card px-2.5 py-2 text-left text-sm hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none', n === suggested && !multi && 'border-primary ring-1 ring-primary', on && 'border-primary'), children: [multi ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn('grid size-4 shrink-0 place-items-center rounded border', on && 'border-primary bg-primary text-primary-foreground'), children: on && _jsx(CheckIcon, { className: "size-3" }) }), _jsx("span", { className: "grid size-4.5 shrink-0 place-items-center rounded-full bg-muted font-medium text-muted-foreground text-xs tabular-nums", children: n + 1 })] })) : (_jsx("kbd", { className: "grid size-4.5 shrink-0 place-items-center rounded bg-muted font-medium text-xs text-muted-foreground", children: n + 1 })), _jsxs("span", { className: "min-w-0 flex-1 truncate", title: o.label, children: [o.label, (o.note || n === suggested) && (_jsx("span", { className: "ml-1.5 text-muted-foreground text-xs", children: o.note ?? 'suggested' }))] })] }, o.label));
692
+ }), multi && (_jsxs("div", { className: "flex items-center justify-end gap-2 text-muted-foreground text-xs", children: [_jsxs("span", { children: [picked.length, " picked"] }), _jsx(IconButton, { icon: ArrowUpIcon, label: "Send picks", variant: "default", size: "icon-sm", disabled: !picked.length, onClick: () => onSend(options
693
+ .filter((_, n) => picked.includes(n))
694
+ .map((o) => o.label)
695
+ .join('; ')) })] }))] }));
566
696
  }
567
697
  /** The composer's triggers: `@` points at a thing (the `sources`), `#` picks the tier for one message. */
568
698
  const POINT = '@';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.300",
3
+ "version": "0.1.301",
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.300",
52
+ "@columenlabs/lumen-ui-types": "0.1.301",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",