@columenlabs/lumen-ui 0.1.215 → 0.1.217

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.
@@ -38,6 +38,13 @@ export interface AssistantAdapter {
38
38
  usage?(month?: string): Promise<UsageHistory>;
39
39
  /** One answer: `onEvent` hears each event as it streams; abort `signal` to stop it. */
40
40
  send(msg: AssistantMessage, onEvent: (e: AssistantEvent) => void, signal?: AbortSignal): Promise<void>;
41
+ /**
42
+ * A message for the answer running in `chatId`: it joins at the answer's next step. `steered:
43
+ * false` (or no method) and the panel queues it for after the answer instead.
44
+ */
45
+ steer?(chatId: string, message: string): Promise<{
46
+ steered: boolean;
47
+ }>;
41
48
  settleDraft(chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
42
49
  /** Takes back a change the assistant made at once. */
43
50
  undoWrite(chatId: string, stepId: string): Promise<StepItem>;
@@ -71,6 +71,7 @@ export function assistantClient(base) {
71
71
  action,
72
72
  ...(amount !== undefined ? { amount } : {}),
73
73
  }),
74
+ steer: (chatId, message) => send('POST', `/chats/${encodeURIComponent(chatId)}/steer`, { message }),
74
75
  async send(msg, onEvent, signal) {
75
76
  const url = `${root}/chat`;
76
77
  const res = await fetch(url, {
@@ -3,7 +3,7 @@ import { type LumenOption } from '../lumen-selector.js';
3
3
  import type { AssistantAdapter } from './client.js';
4
4
  import { type AssistantSource } from './sources.js';
5
5
  import { usd } from './usage-history.js';
6
- import { type AssistantChatState } from './use-assistant-chat.js';
6
+ import { type AssistantChatState, type QueuedMessage } from './use-assistant-chat.js';
7
7
  export declare const assistantStatusKey: (client: AssistantAdapter) => string[];
8
8
  /** The person's assistant status, shared by the panel and Settings. */
9
9
  export declare function useAssistantStatus(client: AssistantAdapter): import("@tanstack/react-query").UseQueryResult<AssistantStatus, Error>;
@@ -68,18 +68,28 @@ export interface ComposerExtra {
68
68
  images?: AssistantImage[];
69
69
  }
70
70
  /**
71
- * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
71
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. While it
72
+ * runs, words in the box go into it with Enter (Steer, where `canSteer`) or wait for it to end
73
+ * with Alt+Enter (Queue); the `queue` lists above the box, each with Steer now, Edit and Remove.
74
+ * Three
72
75
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
73
76
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
74
77
  */
75
- export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
78
+ export declare function LumenComposer({ name, busy, queued, queue, canSteer, onSteerQueued, onUnqueue, disabled, commands, tiers, sources, page, images: canAttach, onSend, onStop, }: {
76
79
  name: string;
77
80
  /** Images can be pasted or dropped on the box (`LumenAssistant` `images`). */
78
81
  images?: boolean;
79
82
  /** The page's name for `@` This page; the document's title by default. */
80
83
  page?: string;
81
84
  busy: boolean;
85
+ /** The first queued message; `queue` lists them all, with their actions. */
82
86
  queued: string | null;
87
+ queue?: QueuedMessage[];
88
+ /** Enter steers a message into the running answer; Alt+Enter queues it. */
89
+ canSteer?: boolean;
90
+ onSteerQueued?: (id: string) => void;
91
+ /** Takes a queued message out; Edit puts its words back in the box. */
92
+ onUnqueue?: (id: string) => QueuedMessage | undefined;
83
93
  disabled?: boolean;
84
94
  commands?: AssistantCommand[];
85
95
  /** What `#` lists. */
@@ -90,7 +100,7 @@ export declare function LumenComposer({ name, busy, queued, disabled, commands,
90
100
  }[];
91
101
  /** What `@` lists, a group each. */
92
102
  sources?: readonly AssistantSource[];
93
- onSend: (text: string, extra: ComposerExtra) => void;
103
+ onSend: (text: string, extra: ComposerExtra, how?: 'steer' | 'queue') => void;
94
104
  onStop: () => void;
95
105
  }): import("react").JSX.Element;
96
106
  /**
@@ -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, } 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, LightbulbIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } from 'lucide-react';
9
+ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, GlobeIcon, LightbulbIcon, ListPlusIcon, MonitorIcon, PencilIcon, PlusIcon, ReceiptIcon, SearchIcon, SparklesIcon, SquareIcon, TriangleAlertIcon, Undo2Icon, WrenchIcon, XIcon, ZapIcon, } 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';
@@ -30,7 +30,7 @@ import { ChatImage } from './chat-image.js';
30
30
  import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
31
31
  import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
32
32
  import { UsageHistoryView, tokens, usd } from './usage-history.js';
33
- import { useAssistantChat } from './use-assistant-chat.js';
33
+ import { useAssistantChat, } from './use-assistant-chat.js';
34
34
  // The Markdown renderer (tables included) loads with the first answer (lazy-part.tsx).
35
35
  // A tab whose build a deploy replaced shows the answer as plain text instead of reloading mid chat.
36
36
  const ChatMarkdown = lazyPart(() => import('./chat-markdown.js').then((m) => m.default), null, ({ children }) => _jsx("p", { className: "whitespace-pre-wrap", children: children }));
@@ -190,7 +190,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, cha
190
190
  title: c.title,
191
191
  updated_at: new Date(c.at).toISOString(),
192
192
  ...(chatScope && allScopes && c.scope ? { where: chatScope.name?.(c.scope) } : {}),
193
- })) ?? (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(Messages, { chat: chat, status: s, onSettings: onSettings }) }) }), _jsx(LumenComposer, { name: s?.name ?? 'the assistant', busy: chat.busy, queued: chat.queued, disabled: !s || !!s.wait, commands: allCommands, tiers: tiers, sources: allSources, page: context?.label, images: images, onSend: (text, more) => chat.send(text, { ...extra(), ...more }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
193
+ })) ?? (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(Messages, { chat: chat, status: s, onSettings: onSettings }) }) }), _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, onSend: (text, more, how) => chat.send(text, { ...extra(), ...more }, how), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage, client: client, spent: s?.spend.usd })] }));
194
194
  }
195
195
  function Messages({ chat, status, onSettings, }) {
196
196
  const end = useRef(null);
@@ -242,7 +242,7 @@ function Items({ items, onSettle, busy, turns, }) {
242
242
  if (turns)
243
243
  return (_jsx("div", { className: "flex flex-col gap-3", children: chatTurns(items).map((t) => {
244
244
  const open = busy && t.from + t.items.length === items.length;
245
- return (_jsxs("div", { "data-slot": "assistant-turn", className: "group/turn flex flex-col gap-3", children: [_jsx(Items, { items: t.items, onSettle: onSettle, busy: busy }), !open && (_jsx("div", { className: "-mt-2 flex justify-end opacity-0 transition-opacity group-hover/turn:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100", children: _jsx(IconButton, { icon: CopyIcon, label: "Copy this turn", className: "size-6 text-muted-foreground", onClick: () => copyMarkdown(turnMarkdown(t.items, name), 'Turn') }) }))] }, t.from));
245
+ return (_jsxs("div", { "data-slot": "assistant-turn", className: "group/turn flex flex-col gap-3", children: [_jsx(Items, { items: t.items, onSettle: onSettle, busy: busy }), !open && _jsx(WebSources, { items: t.items }), !open && (_jsx("div", { className: "-mt-2 flex justify-end opacity-0 transition-opacity group-hover/turn:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100", children: _jsx(IconButton, { icon: CopyIcon, label: "Copy this turn", className: "size-6 text-muted-foreground", onClick: () => copyMarkdown(turnMarkdown(t.items, name), 'Turn') }) }))] }, t.from));
246
246
  }) }));
247
247
  // Cards drafted one after another sit in one stack, so many of them skim as lines.
248
248
  const rows = [];
@@ -264,6 +264,8 @@ function Item({ item, onSettle, busy, }) {
264
264
  const tierName = useContext(TierNames);
265
265
  switch (item.kind) {
266
266
  case 'user':
267
+ if (item.steered)
268
+ return (_jsxs("div", { className: "ml-8 flex flex-col items-end gap-0.5 self-end", children: [_jsxs("span", { className: "flex items-center gap-1 text-primary text-xs", children: [_jsx(CornerDownRightIcon, { className: "size-3" }), "Steered"] }), _jsx("div", { className: "rounded-lg border border-primary/40 bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words", children: item.text })] }));
267
269
  return (_jsxs("div", { className: "ml-8 self-end rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap has-[[data-slot=chat-image]]:ml-0 has-[[data-slot=chat-image]]:w-full", children: [(item.tier || item.refs?.length) && (_jsxs("span", { className: "mb-1 flex flex-wrap gap-1", children: [item.tier && _jsx(ComposerChip, { sign: TIER, label: tierName(item.tier) }), item.refs?.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label }, `${r.kind}:${r.label}:${i}`)))] })), item.images?.length ? (_jsx("span", { className: "mb-1 flex flex-wrap gap-1.5", children: item.images.map((src, i) => (_jsx(ChatImage, { src: src, alt: `Pasted ${i + 1}` }, `${i}:${src.length}`))) })) : null, item.text, item.context && (_jsxs("span", { className: "mt-1 block text-muted-foreground text-xs", children: ["on ", item.context] }))] }));
268
270
  case 'text':
269
271
  return (_jsxs("div", { className: "prose-sm text-sm leading-relaxed [&_code]:font-code [&_code]:text-xs [&_li]:ml-4 [&_ol]:list-decimal [&_p]:my-1.5 [&_ul]:list-disc", children: [_jsx(ChatMarkdown, { children: item.text }), item.live && (_jsx("span", { className: "ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-foreground/60 align-middle" }))] }));
@@ -324,7 +326,64 @@ function ScreenChip({ item }) {
324
326
  return (_jsxs("div", { "data-slot": "assistant-screen", "data-status": status, className: "flex min-w-0 items-center gap-2 rounded-lg border bg-muted/30 px-3 py-1.5 text-muted-foreground text-xs", children: [status === 'running' ? (_jsx(Spinner, { className: "size-3.5" })) : status === 'done' ? (_jsx(CheckIcon, { className: "size-3.5 shrink-0 text-edge" })) : status === 'failed' ? (_jsx(TriangleAlertIcon, { className: "size-3.5 shrink-0 text-loss" })) : status === 'undone' ? (_jsx(Undo2Icon, { className: "size-3.5 shrink-0" })) : (_jsx(MonitorIcon, { className: "size-3.5 shrink-0" })), _jsxs("span", { className: "min-w-0 flex-1 truncate", title: `${item.label} · ${movesText(item.moves)}`, children: [_jsx("span", { className: cn(status === 'undone' ? 'line-through' : 'text-foreground'), children: item.label }), said && _jsxs("span", { children: [" \u00B7 ", said] })] }), status === 'done' && c?.canUndo(item.id) && (_jsx(IconButton, { icon: Undo2Icon, label: "Undo", size: "icon-xs", onClick: () => c.undo(item.id) })), status === 'waiting' && c && (_jsx(IconButton, { icon: MonitorIcon, label: "Go now", variant: "outline", size: "icon-xs", onClick: () => c.go(item) }))] }));
325
327
  }
326
328
  const Rule = ({ children }) => (_jsx("p", { className: "flex items-center gap-2 text-muted-foreground text-xs before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border", children: children }));
329
+ /** A page's host without `www.`, or the address when it has none. */
330
+ function hostOf(url) {
331
+ try {
332
+ return new URL(url).hostname.replace(/^www\./, '');
333
+ }
334
+ catch {
335
+ return url;
336
+ }
337
+ }
338
+ /** A page's path after its host, for a read's line. */
339
+ function pathOf(url) {
340
+ try {
341
+ const u = new URL(url);
342
+ const p = decodeURIComponent(u.pathname).replace(/\/$/, '');
343
+ return p.split('/').pop() || '';
344
+ }
345
+ catch {
346
+ return '';
347
+ }
348
+ }
349
+ /** A web step: a search, its results folded under it, or one page read. */
350
+ function WebStep({ step, live }) {
351
+ const web = step.web ?? {};
352
+ const results = web.results ?? [];
353
+ const icon = live ? (_jsx(Spinner, { className: "size-3" })) : !step.ok ? (_jsx(CircleAlertIcon, { className: "size-3.5 shrink-0" })) : web.read ? (_jsx(GlobeIcon, { className: "size-3.5 shrink-0" })) : (_jsx(SearchIcon, { className: "size-3.5 shrink-0" }));
354
+ const line = (_jsxs(_Fragment, { children: [icon, web.tried ? (_jsxs("span", { className: "truncate", title: web.tried.url, children: ["Could not open ", _jsx("span", { className: "text-foreground", children: hostOf(web.tried.url) })] })) : web.read ? (_jsxs("span", { className: "truncate", title: web.read.title ?? web.read.url, children: ["Read ", _jsx("span", { className: "text-foreground", children: hostOf(web.read.url) }), pathOf(web.read.url) && ` · ${pathOf(web.read.url)}`] })) : (_jsxs("span", { className: "truncate", title: web.query, children: ["Searched the web", web.query ? ': ' : '', web.query && _jsx("span", { className: "text-foreground", children: web.query })] })), !live && !web.read && !web.tried && step.ok && (_jsxs("span", { className: "ml-auto shrink-0 tabular-nums", children: [results.length, " result", results.length === 1 ? '' : 's'] })), step.error && _jsxs("span", { className: "truncate", children: ["\u00B7 ", step.error] })] }));
355
+ const cls = cn('flex min-w-0 items-center gap-1.5 text-xs', step.ok ? 'text-muted-foreground' : 'text-loss');
356
+ if (!results.length)
357
+ return _jsx("p", { className: cls, children: line });
358
+ return (_jsxs(Collapsible, { children: [_jsxs(CollapsibleTrigger, { className: cn(cls, 'group/web w-full text-left'), children: [line, _jsx(ChevronRightIcon, { className: "size-3.5 shrink-0 transition-transform group-data-[state=open]/web:rotate-90" })] }), _jsx(CollapsibleContent, { children: _jsx("ul", { className: "mt-1 ml-1.5 flex flex-col gap-0.5 border-l pl-3 text-muted-foreground text-xs", children: results.map((r) => (_jsx("li", { className: "min-w-0 truncate", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", title: r.url, className: "hover:underline", children: [_jsx("span", { className: "text-foreground", children: r.title || hostOf(r.url) }), " \u00B7", ' ', hostOf(r.url)] }) }, r.url))) }) })] }));
359
+ }
360
+ /** The pages a turn read, once each, in the order read: the numbers its answer cites. */
361
+ function turnPages(items) {
362
+ const pages = [];
363
+ const walk = (list) => {
364
+ for (const i of list) {
365
+ if (i.kind === 'handoff')
366
+ walk(i.items);
367
+ else if (i.kind === 'step' && i.web?.read && !pages.some((p) => p.url === i.web?.read?.url))
368
+ pages.push(i.web.read);
369
+ }
370
+ };
371
+ walk(items);
372
+ return pages;
373
+ }
374
+ /** The pages a turn read, as numbered chips under its answer; each opens in a new tab. */
375
+ function WebSources({ items }) {
376
+ const pages = turnPages(items);
377
+ if (!pages.length)
378
+ return null;
379
+ return (_jsx("ol", { "aria-label": "Sources", className: "flex min-w-0 flex-wrap gap-1.5", children: pages.map((p, n) => (_jsx("li", { className: "min-w-0 max-w-full", children: _jsxs("a", { href: p.url, target: "_blank", rel: "noopener noreferrer", title: p.title ? `${p.title} · ${p.url}` : p.url, className: "flex h-6 min-w-0 items-center gap-1.5 rounded-full border pr-2.5 pl-1 text-xs hover:bg-muted", children: [_jsx("span", { className: "grid size-4 shrink-0 place-items-center rounded-full bg-primary/15 font-medium text-primary text-xs tabular-nums", children: n + 1 }), _jsx("span", { className: "truncate", children: hostOf(p.url) })] }) }, p.url))) }));
380
+ }
327
381
  function Step({ step, live }) {
382
+ if (step.web)
383
+ return _jsx(WebStep, { step: step, live: live });
384
+ return _jsx(ToolStep, { step: step, live: live });
385
+ }
386
+ function ToolStep({ step, live }) {
328
387
  const undo = useContext(UndoWrite);
329
388
  const [undoing, setUndoing] = useState(false);
330
389
  const w = step.write;
@@ -466,11 +525,14 @@ async function readImage(file) {
466
525
  }
467
526
  const imageUrl = (i) => `data:${i.media_type};base64,${i.data}`;
468
527
  /**
469
- * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. Three
528
+ * The message box: Enter sends, Shift+Enter breaks a line; Stop while an answer runs. While it
529
+ * runs, words in the box go into it with Enter (Steer, where `canSteer`) or wait for it to end
530
+ * with Alt+Enter (Queue); the `queue` lists above the box, each with Steer now, Edit and Remove.
531
+ * Three
470
532
  * triggers list what can follow them as it is typed: `/` at the start a command, `@` a thing to
471
533
  * point at (the `sources`), `#` a tier for this message; a picked tier or thing shows as a chip.
472
534
  */
473
- export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
535
+ export function LumenComposer({ name, busy, queued, queue, canSteer = false, onSteerQueued, onUnqueue, disabled, commands = [], tiers = [], sources = [], page, images: canAttach = false, onSend, onStop, }) {
474
536
  const [text, setText] = useState('');
475
537
  const [caret, setCaret] = useState(0);
476
538
  const [closed, setClosed] = useState(null);
@@ -557,7 +619,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
557
619
  lumenToast.error(errorText(e));
558
620
  }
559
621
  };
560
- const send = () => {
622
+ const send = (how) => {
561
623
  if (!text.trim() || disabled)
562
624
  return;
563
625
  const c = m ? commands.find((x) => x.name === m[1]) : undefined;
@@ -565,9 +627,25 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
565
627
  void run(c, m?.[2] ?? '');
566
628
  return;
567
629
  }
568
- onSend(text, extra());
630
+ onSend(text, extra(), busy ? (how ?? (canSteer ? 'steer' : 'queue')) : undefined);
569
631
  clear();
570
632
  };
633
+ // Edit: a queued message back in the box, its chips with it.
634
+ const edit = (id) => {
635
+ const q = onUnqueue?.(id);
636
+ if (!q)
637
+ return;
638
+ setText(q.message);
639
+ setCaret(q.message.length);
640
+ if (q.extra?.tier)
641
+ setTier(q.extra.tier);
642
+ if (q.extra?.refs?.length)
643
+ setRefs(q.extra.refs);
644
+ if (q.extra?.images?.length)
645
+ setImages(q.extra.images);
646
+ requestAnimationFrame(() => box.current?.focus());
647
+ };
648
+ const waiting = queue ?? (queued ? [{ id: 'queued', message: queued }] : []);
571
649
  const pickCommand = (o) => {
572
650
  const c = commands.find((x) => x.name === o.value);
573
651
  if (!c)
@@ -707,7 +785,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
707
785
  };
708
786
  const tierLabel = tiers.find((t) => t.id === tier)?.label;
709
787
  const hints = [commands.length && '/', POINT, tiers.length > 1 && TIER].filter(Boolean);
710
- return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [queued && (_jsxs("p", { className: "mb-2 truncate text-muted-foreground text-xs", children: ["Queued: ", _jsx("span", { className: "text-foreground", children: queued })] })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
788
+ return (_jsxs("div", { className: "shrink-0 border-t p-3", children: [waiting.length > 0 && (_jsx(ComposerQueue, { queue: waiting, canSteer: busy && canSteer && !!onSteerQueued, onSteer: (id) => onSteerQueued?.(id), onEdit: onUnqueue ? edit : undefined, onRemove: onUnqueue ? (id) => void onUnqueue(id) : undefined })), picking === 'command' && (_jsx(LumenSelectorList, { handle: list, label: "Commands", className: "mb-2", fuzzy: true, query: m?.[1] ?? '', options: commands.map((c) => ({
711
789
  value: c.name,
712
790
  label: `/${c.name}`,
713
791
  ...(c.args ? { detail: c.args } : {}),
@@ -728,7 +806,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
728
806
  const r = sourceItems.get(o.value)?.();
729
807
  if (r)
730
808
  setRefs((p) => [...p, r]);
731
- }, onClose: () => setClosed(`${text}|${caret}`) })), images.length > 0 && (_jsx("div", { className: "mb-2 flex flex-wrap gap-1.5", "aria-label": "Images going with your message", children: images.map((img, i) => (_jsxs("span", { className: "relative", children: [_jsx("img", { src: imageUrl(img), alt: `Pasted ${i + 1}`, className: "size-12 rounded-md border object-cover" }), _jsx("button", { type: "button", "aria-label": `Remove image ${i + 1}`, className: "absolute -top-1.5 -right-1.5 grid size-4 place-items-center rounded-full border bg-background text-muted-foreground hover:text-foreground", onClick: () => setImages((p) => p.filter((_, j) => j !== i)), children: _jsx(XIcon, { className: "size-3" }) })] }, `${i}:${img.data.length}`))) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && (_jsx(ComposerChip, { sign: TIER, label: tierLabel, onRemove: () => setTier(null) })), refs.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: "max-h-48 min-h-11 resize-none pr-11", value: text, disabled: disabled, onChange: (e) => {
809
+ }, onClose: () => setClosed(`${text}|${caret}`) })), images.length > 0 && (_jsx("div", { className: "mb-2 flex flex-wrap gap-1.5", "aria-label": "Images going with your message", children: images.map((img, i) => (_jsxs("span", { className: "relative", children: [_jsx("img", { src: imageUrl(img), alt: `Pasted ${i + 1}`, className: "size-12 rounded-md border object-cover" }), _jsx("button", { type: "button", "aria-label": `Remove image ${i + 1}`, className: "absolute -top-1.5 -right-1.5 grid size-4 place-items-center rounded-full border bg-background text-muted-foreground hover:text-foreground", onClick: () => setImages((p) => p.filter((_, j) => j !== i)), children: _jsx(XIcon, { className: "size-3" }) })] }, `${i}:${img.data.length}`))) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && (_jsx(ComposerChip, { sign: TIER, label: tierLabel, onRemove: () => setTier(null) })), refs.map((r, i) => (_jsx(ComposerChip, { sign: POINT, label: r.label, onRemove: () => setRefs((p) => p.filter((_, j) => j !== i)) }, `${r.kind}:${r.label}:${i}`)))] })), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: box, "aria-label": `Message ${name}`, placeholder: `Ask ${name}…${hints.length ? ` (${hints.join(' ')})` : ''}`, className: cn('max-h-48 min-h-11 resize-none', busy && text.trim() && canSteer ? 'pr-18' : 'pr-11'), value: text, disabled: disabled, onChange: (e) => {
732
810
  setText(e.target.value);
733
811
  setCaret(e.target.selectionStart);
734
812
  }, onSelect: (e) => setCaret(e.currentTarget.selectionStart), onPaste: (e) => {
@@ -746,9 +824,17 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
746
824
  return;
747
825
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
748
826
  e.preventDefault();
749
- send();
827
+ send(e.altKey ? 'queue' : undefined);
750
828
  }
751
- } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: busy ? 'Queue' : 'Send', variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: send })) })] })] }));
829
+ } }), _jsx("div", { className: "absolute right-1.5 bottom-1.5", children: busy && !text.trim() ? (_jsx(IconButton, { icon: SquareIcon, label: "Stop", variant: "outline", size: "icon-xs", onClick: onStop })) : busy ? (_jsxs("span", { className: "flex gap-1", children: [_jsx(IconButton, { icon: ListPlusIcon, label: canSteer ? 'Queue for after (⌥↩)' : 'Queue for after (↩)', variant: canSteer ? 'outline' : 'default', size: "icon-xs", disabled: disabled, onClick: () => send('queue') }), canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now (\u21A9)", variant: "default", size: "icon-xs", disabled: disabled, onClick: () => send('steer') }))] })) : (_jsx(IconButton, { icon: ArrowUpIcon, label: "Send", variant: "default", size: "icon-xs", disabled: !text.trim() || disabled, onClick: () => send() })) })] })] }));
830
+ }
831
+ /**
832
+ * What waits above the box while an answer runs: steering messages until the answer reads them,
833
+ * then queued ones in order, each with Steer now, Edit (back in the box) and Remove.
834
+ */
835
+ function ComposerQueue({ queue, canSteer, onSteer, onEdit, onRemove, }) {
836
+ const waiting = queue.filter((q) => !q.steering).length;
837
+ return (_jsxs("div", { className: "mb-2 flex flex-col gap-1", children: [waiting > 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-muted-foreground text-xs", children: [_jsx(ListPlusIcon, { className: "size-3.5" }), "Queued \u00B7 ", waiting] })), queue.map((q) => (_jsxs("div", { className: "flex h-8 min-w-0 items-center gap-1 rounded-md border bg-background pr-1 pl-2 text-sm", children: [q.steering && (_jsx(CornerDownRightIcon, { "aria-label": "Steering: Sage reads it at its next step", className: "size-3.5 shrink-0 animate-pulse text-primary" })), _jsx("span", { className: cn('min-w-0 flex-1 truncate', q.steering && 'text-muted-foreground'), title: q.message, children: q.message }), !q.steering && canSteer && (_jsx(IconButton, { icon: CornerDownRightIcon, label: "Steer now", size: "icon-xs", onClick: () => onSteer(q.id) })), !q.steering && onEdit && (_jsx(IconButton, { icon: PencilIcon, label: "Edit", size: "icon-xs", onClick: () => onEdit(q.id) })), !q.steering && onRemove && (_jsx(IconButton, { icon: XIcon, label: "Remove", size: "icon-xs", onClick: () => onRemove(q.id) }))] }, q.id)))] }));
752
838
  }
753
839
  // The same thing pointed at twice: its kind and what finds it, whatever its words read then.
754
840
  const refKey = (r) => r.kind === 'page'
@@ -4,6 +4,15 @@ import type { AssistantAdapter } from './client.js';
4
4
  export type LiveItem = ChatItem & {
5
5
  live?: boolean;
6
6
  };
7
+ type Extra = Omit<AssistantMessage, 'message' | 'chat_id'>;
8
+ /** A message sent while an answer runs: waiting for it to end, or steered into it. */
9
+ export interface QueuedMessage {
10
+ id: string;
11
+ message: string;
12
+ extra?: Extra;
13
+ /** Sent into the running answer, not read by it yet. */
14
+ steering?: boolean;
15
+ }
7
16
  export interface AssistantChatState {
8
17
  chatId: string | null;
9
18
  title: string;
@@ -11,11 +20,23 @@ export interface AssistantChatState {
11
20
  usage: ChatUsage | null;
12
21
  /** An answer is streaming. */
13
22
  busy: boolean;
14
- /** A message waiting for the answer to end. */
23
+ /** The first message waiting for the answer to end (`queue` has them all). */
15
24
  queued: string | null;
25
+ /** Messages sent while the answer runs, in order: steering ones first wait to be read. */
26
+ queue: QueuedMessage[];
27
+ /** The adapter takes a message into a running answer (Steer). */
28
+ canSteer: boolean;
16
29
  /** The chat is loading. */
17
30
  loading: boolean;
18
- send: (message: string, extra?: Omit<AssistantMessage, 'message' | 'chat_id'>) => void;
31
+ /**
32
+ * Sends now, or while an answer runs: `steer` (the default where the adapter can) into it at its
33
+ * next step, else queued for after it.
34
+ */
35
+ send: (message: string, extra?: Extra, how?: 'steer' | 'queue') => void;
36
+ /** A queued message steered into the running answer now. */
37
+ steerQueued: (id: string) => void;
38
+ /** Takes a queued message out; its words and extras come back (Edit puts them in the box). */
39
+ unqueue: (id: string) => QueuedMessage | undefined;
19
40
  stop: () => void;
20
41
  open: (id: string) => Promise<void>;
21
42
  reset: () => void;
@@ -28,3 +49,4 @@ export interface AssistantChatState {
28
49
  /** A copy of the items with one event applied (hand-off sections copied too). */
29
50
  export declare function applyEvent(prev: LiveItem[], e: AssistantEvent): LiveItem[];
30
51
  export declare function useAssistantChat(client: AssistantAdapter): AssistantChatState;
52
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { ApiError } from '../api-fetch.js';
2
3
  import { errorText } from '../integrations.js';
3
4
  /** The browser's IANA zone, sent with each message for the assistant's bearings. */
4
5
  function browserZone() {
@@ -30,6 +31,8 @@ function keepOutcomes(m) {
30
31
  }
31
32
  catch { }
32
33
  }
34
+ let queuedSeq = 0;
35
+ const queuedId = () => `q${++queuedSeq}`;
33
36
  /** Where an event lands: the chat, or the last hand-off section when it came from inside one. */
34
37
  function sinkOf(items, handoff) {
35
38
  if (!handoff)
@@ -148,12 +151,34 @@ export function useAssistantChat(client) {
148
151
  const [usage, setUsage] = useState(null);
149
152
  const [busy, setBusy] = useState(false);
150
153
  const [loading, setLoading] = useState(false);
151
- const [queued, setQueued] = useState(null);
154
+ const [queue, setQueue] = useState([]);
155
+ // An adapter whose app has no steer route: Steer hides and messages queue.
156
+ const [steerOff, setSteerOff] = useState(false);
152
157
  const abort = useRef(null);
153
158
  const outcomes = useRef(null);
154
159
  outcomes.current ??= readOutcomes();
155
160
  const idRef = useRef(null);
156
161
  idRef.current = chatId;
162
+ const queueRef = useRef([]);
163
+ queueRef.current = queue;
164
+ /** Steering messages not read (all, or those `texts`) go back to waiting, first in line. */
165
+ const unsteer = useCallback((texts) => {
166
+ setQueue((q) => {
167
+ const left = [...(texts ?? [])];
168
+ const back = q.map((x) => {
169
+ if (!x.steering)
170
+ return x;
171
+ if (texts) {
172
+ const at = left.indexOf(x.message);
173
+ if (at < 0)
174
+ return x;
175
+ left.splice(at, 1);
176
+ }
177
+ return { ...x, steering: false };
178
+ });
179
+ return back.some((x, i) => x !== q[i]) ? back : q;
180
+ });
181
+ }, []);
157
182
  const run = useCallback(async (message, extra) => {
158
183
  const ctl = new AbortController();
159
184
  abort.current = ctl;
@@ -194,6 +219,19 @@ export function useAssistantChat(client) {
194
219
  setUsage(e.data);
195
220
  else if (e.event === 'done')
196
221
  setUsage(e.data.usage);
222
+ else if (e.event === 'steered') {
223
+ // Read by the answer here: the line moves from the queue into the chat.
224
+ setQueue((q) => {
225
+ const at = q.findIndex((m) => m.steering && m.message === e.data.text);
226
+ return at < 0 ? q : [...q.slice(0, at), ...q.slice(at + 1)];
227
+ });
228
+ setItems((p) => [
229
+ ...settled(p),
230
+ { kind: 'user', text: e.data.text, at: e.data.at, steered: true },
231
+ ]);
232
+ }
233
+ else if (e.event === 'unsteered')
234
+ unsteer(e.data.texts);
197
235
  else
198
236
  setItems((p) => applyEvent(p, e));
199
237
  }, ctl.signal);
@@ -211,30 +249,78 @@ export function useAssistantChat(client) {
211
249
  }
212
250
  finally {
213
251
  setItems(settled);
252
+ // Steers the answer never read (Stop, an error) wait for the next answer instead.
253
+ unsteer();
214
254
  abort.current = null;
215
255
  setBusy(false);
216
256
  }
217
- }, [client]);
218
- // A queued message goes once the answer ends.
257
+ }, [client, unsteer]);
258
+ // A queued message goes once the answer ends, one an answer.
219
259
  useEffect(() => {
220
- if (busy || !queued)
260
+ if (busy)
261
+ return;
262
+ const next = queue[0];
263
+ if (!next || next.steering)
221
264
  return;
222
- setQueued(null);
223
- void run(queued.message, queued.extra);
224
- }, [busy, queued, run]);
225
- const send = useCallback((message, extra) => {
265
+ setQueue((q) => q.slice(1));
266
+ void run(next.message, next.extra);
267
+ }, [busy, queue, run]);
268
+ const canSteer = !!client.steer && !steerOff;
269
+ // Into the running answer; refused (none runs, the model reads none) it waits in the queue.
270
+ const steerIn = useCallback(async (m) => {
271
+ const id = idRef.current;
272
+ let steered = false;
273
+ if (id && client.steer && abort.current)
274
+ try {
275
+ steered = (await client.steer(id, m.message)).steered;
276
+ }
277
+ catch (err) {
278
+ if (err instanceof ApiError && [404, 405, 410].includes(err.status))
279
+ setSteerOff(true);
280
+ }
281
+ if (!steered)
282
+ setQueue((q) => q.map((x) => (x.id === m.id ? { ...x, steering: false } : x)));
283
+ }, [client]);
284
+ const send = useCallback((message, extra, how) => {
226
285
  const text = message.trim();
227
286
  if (!text)
228
287
  return;
229
- if (abort.current)
230
- setQueued({ message: text, ...(extra ? { extra } : {}) });
231
- else
232
- void run(text, extra);
233
- }, [run]);
288
+ if (!abort.current)
289
+ return void run(text, extra);
290
+ const steer = (how ?? 'steer') === 'steer' && canSteer && !!idRef.current;
291
+ const m = {
292
+ id: queuedId(),
293
+ message: text,
294
+ ...(extra ? { extra } : {}),
295
+ ...(steer ? { steering: true } : {}),
296
+ };
297
+ // Steering ones sit first: they are read before anything queued after the answer.
298
+ setQueue((q) => steer ? [...q.filter((x) => x.steering), m, ...q.filter((x) => !x.steering)] : [...q, m]);
299
+ if (steer)
300
+ void steerIn(m);
301
+ }, [run, canSteer, steerIn]);
302
+ const steerQueued = useCallback((id) => {
303
+ const m = queueRef.current.find((x) => x.id === id && !x.steering);
304
+ if (!m || !abort.current)
305
+ return;
306
+ const on = { ...m, steering: true };
307
+ setQueue((q) => [
308
+ ...q.filter((x) => x.steering),
309
+ on,
310
+ ...q.filter((x) => !x.steering && x.id !== id),
311
+ ]);
312
+ void steerIn(on);
313
+ }, [steerIn]);
314
+ const unqueue = useCallback((id) => {
315
+ const m = queueRef.current.find((x) => x.id === id && !x.steering);
316
+ if (m)
317
+ setQueue((q) => q.filter((x) => x.id !== id));
318
+ return m;
319
+ }, []);
234
320
  const stop = useCallback(() => abort.current?.abort(), []);
235
321
  const reset = useCallback(() => {
236
322
  abort.current?.abort();
237
- setQueued(null);
323
+ setQueue([]);
238
324
  setChatId(null);
239
325
  idRef.current = null;
240
326
  setTitle('');
@@ -243,7 +329,7 @@ export function useAssistantChat(client) {
243
329
  }, []);
244
330
  const open = useCallback(async (id) => {
245
331
  abort.current?.abort();
246
- setQueued(null);
332
+ setQueue([]);
247
333
  setLoading(true);
248
334
  try {
249
335
  const c = await client.chat(id);
@@ -306,9 +392,13 @@ export function useAssistantChat(client) {
306
392
  items,
307
393
  usage,
308
394
  busy,
309
- queued: queued?.message ?? null,
395
+ queued: queue[0]?.message ?? null,
396
+ queue,
397
+ canSteer,
310
398
  loading,
311
399
  send,
400
+ steerQueued,
401
+ unqueue,
312
402
  stop,
313
403
  open,
314
404
  reset,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.215",
3
+ "version": "0.1.217",
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.215",
52
+ "@columenlabs/lumen-ui-types": "0.1.217",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",