@columenlabs/lumen-ui 0.1.299 → 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 = {
@@ -330,7 +332,11 @@ function Item({ item, onSettle, busy, }) {
330
332
  case 'fresh':
331
333
  return _jsx(FreshCard, {});
332
334
  case 'stopped':
333
- return _jsx(Rule, { children: item.reason === 'cap' ? "Stopped at this month's spend cap" : 'Stopped' });
335
+ return (_jsx(Rule, { children: item.reason === 'cap'
336
+ ? "Stopped at this month's spend cap"
337
+ : item.reason === 'limit'
338
+ ? 'Stopped at the time limit'
339
+ : 'Stopped' }));
334
340
  case 'error':
335
341
  return (_jsxs("p", { role: item.retried ? undefined : 'alert', className: cn('flex items-start gap-1.5 text-sm', item.retried ? 'text-muted-foreground' : 'text-loss'), children: [_jsx(CircleAlertIcon, { className: "mt-0.5 size-4 shrink-0" }), item.text] }));
336
342
  }
@@ -452,7 +458,13 @@ function ToolStep({ step, live }) {
452
458
  setUndoing(false);
453
459
  }
454
460
  };
455
- 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 &&
456
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"] })))] }));
457
469
  }
458
470
  /** The arguments as short lines: `name: value`. */
@@ -537,28 +549,150 @@ const WHY = {
537
549
  function HandOff({ item, onSettle, busy, }) {
538
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 })] }));
539
551
  }
540
- /** 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. */
541
553
  const TaskSlots = createContext({});
554
+ /** The words a card says without the app's own view. */
542
555
  const TASK_STATE = {
543
556
  open: 'Open',
544
557
  done: 'Kept',
545
558
  dropped: 'Dropped',
546
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
+ }
547
602
  /**
548
- * A task's turns in the chat (`TaskItem`): one block with its label and state; while open the
549
- * 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.
550
621
  */
551
622
  function TaskBlock({ item, onSettle, busy, }) {
552
623
  const slots = useContext(TaskSlots);
553
624
  const state = slots.state?.(item) ?? item.state;
554
- const live = item.items.some(isLive);
555
- const [open, setOpen] = useState(null);
556
- const shown = open ?? (state === 'open' || live);
557
- 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 })] }));
558
637
  }
559
- /** The open task's chip over the box: the next message goes to it; its × steps out. */
560
- function TaskChip({ task, onLeave }) {
561
- 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('; ')) })] }))] }));
562
696
  }
563
697
  /** The composer's triggers: `@` points at a thing (the `sources`), `#` picks the tier for one message. */
564
698
  const POINT = '@';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.299",
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.299",
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",