@columenlabs/lumen-ui 0.1.300 → 0.1.302

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 = '@';
@@ -36,10 +36,12 @@ export declare function AssistantChatCost({ client, chatId, busy, }: {
36
36
  busy?: boolean;
37
37
  }): import("react").JSX.Element | null;
38
38
  /**
39
- * This month's use, one row a session (a chat, or every run of one task on one thing): its Type (the person's API key, their plan sign-in, or
40
- * the app's credits), Date, Calls and Amount, grouped by Type, Date or not at all, filtered by
41
- * type or name. A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the
42
- * same tokens at the model's API rates (the plan charges $0).
39
+ * This month's use, one row a session (a chat, or every run of one task on one thing): its
40
+ * Category (Chat, Edit, Rebuild: `UsageEntry.category`), Type (the person's API key, their plan
41
+ * sign-in, or the app's credits), Date, Calls and Amount, grouped by Date (first), Category, Type or
42
+ * not at all, filtered by category, type or name, PAGE_SIZE rows a page (a group's totals count all
43
+ * its rows). A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the same
44
+ * tokens at the model's API rates (the plan charges $0).
43
45
  */
44
46
  export declare function AssistantUsageTable({ client }: {
45
47
  client: AssistantAdapter;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useQuery } from '@tanstack/react-query';
3
- import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, CpuIcon, HashIcon, KeyRoundIcon, MessageSquareIcon, MessagesSquareIcon, ReceiptIcon, } from 'lucide-react';
3
+ import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, CpuIcon, HashIcon, KeyRoundIcon, LayersIcon, MessageSquareIcon, MessagesSquareIcon, ReceiptIcon, } from 'lucide-react';
4
4
  import { Fragment, useEffect, useMemo, useState } from 'react';
5
5
  import { DetailsTable } from '../details-table.js';
6
6
  import { IconButton } from '../icon-button.js';
@@ -8,6 +8,7 @@ import { InfoLabel } from '../info-tip.js';
8
8
  import { cn } from '../lib/utils.js';
9
9
  import { LumenTokenizedFilter, useLumenFilter } from '../lumen-filter.js';
10
10
  import { LumenTable } from '../lumen-view.js';
11
+ import { LumenPageFooter } from '../pager.js';
11
12
  import { SettingField } from '../setting-parts.js';
12
13
  import { Badge } from '../ui/badge.js';
13
14
  import { Label } from '../ui/label.js';
@@ -280,6 +281,7 @@ function usageRows(h) {
280
281
  id,
281
282
  title: e.title,
282
283
  type: 'credits',
284
+ category: s?.category ?? (e.category || (e.kind === 'chat' ? 'Chat' : (e.task ?? 'Run'))),
283
285
  at: Math.max(s?.at ?? 0, e.at),
284
286
  calls: (s?.calls ?? 0) + e.calls,
285
287
  tokens: {
@@ -320,6 +322,7 @@ function usageRows(h) {
320
322
  id: `earlier:${type}:${title}`,
321
323
  title,
322
324
  type,
325
+ category: EARLIER,
323
326
  at: 0,
324
327
  calls,
325
328
  key: type === 'key' ? amount : 0,
@@ -430,6 +433,17 @@ const USAGE_FIELDS = [
430
433
  value: (r) => TYPE_LABEL[r.type],
431
434
  render: (r) => (_jsx(UsageTip, { r: r, children: _jsx(Badge, { variant: "outline", className: cn('whitespace-nowrap', CHIP_TONE[TYPE_TONE[r.type]]), children: TYPE_LABEL[r.type] }) })),
432
435
  },
436
+ {
437
+ key: 'category',
438
+ label: 'Category',
439
+ icon: LayersIcon,
440
+ format: 'text',
441
+ groupable: true,
442
+ filterable: true,
443
+ hideOnPhone: true,
444
+ value: (r) => r.category,
445
+ render: (r) => (_jsx(UsageTip, { r: r, children: _jsx(Badge, { variant: "outline", className: cn('whitespace-nowrap', CHIP_TONE.muted), children: r.category }) })),
446
+ },
433
447
  {
434
448
  key: 'at',
435
449
  label: 'Date',
@@ -488,27 +502,42 @@ const USAGE_DOMAIN = {
488
502
  formulas: false,
489
503
  };
490
504
  const USAGE_FILTER = [
505
+ { key: 'category', label: 'Category', icon: LayersIcon, values: (r) => [r.category] },
491
506
  { key: 'type', label: 'Type', icon: KeyRoundIcon, values: (r) => [TYPE_LABEL[r.type]] },
492
507
  ];
508
+ const fieldOf = (key) => USAGE_FIELDS.find((f) => f.key === key) ?? null;
493
509
  const GROUPS = [
494
- { id: 'type', label: 'Type', field: USAGE_FIELDS[1] },
495
- { id: 'none', label: 'None', field: null },
496
510
  { id: 'date', label: 'Date', field: DAY_FIELD },
511
+ { id: 'category', label: 'Category', field: fieldOf('category') },
512
+ { id: 'type', label: 'Type', field: fieldOf('type') },
513
+ { id: 'none', label: 'None', field: null },
497
514
  ];
515
+ /** Rows a page of the table shows; a group's totals still count every row of it. */
516
+ const PAGE_SIZE = 50;
498
517
  /**
499
- * This month's use, one row a session (a chat, or every run of one task on one thing): its Type (the person's API key, their plan sign-in, or
500
- * the app's credits), Date, Calls and Amount, grouped by Type, Date or not at all, filtered by
501
- * type or name. A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the
502
- * same tokens at the model's API rates (the plan charges $0).
518
+ * This month's use, one row a session (a chat, or every run of one task on one thing): its
519
+ * Category (Chat, Edit, Rebuild: `UsageEntry.category`), Type (the person's API key, their plan
520
+ * sign-in, or the app's credits), Date, Calls and Amount, grouped by Date (first), Category, Type or
521
+ * not at all, filtered by category, type or name, PAGE_SIZE rows a page (a group's totals count all
522
+ * its rows). A row's hover gives its tokens and cost. A sign-in's amount is an estimate: the same
523
+ * tokens at the model's API rates (the plan charges $0).
503
524
  */
504
525
  export function AssistantUsageTable({ client }) {
505
- const [group, setGroup] = useState('type');
526
+ const [group, setGroup] = useState('date');
527
+ const [page, setPage] = useState(0);
506
528
  const q = useAssistantUsage(client);
507
529
  const rows = useMemo(() => (q.data ? usageRows(q.data) : []), [q.data]);
508
530
  const filter = useLumenFilter(rows, USAGE_FILTER, { text: (r) => [r.title] });
531
+ const shown = filter.results;
532
+ // A new filter or grouping starts from the first page; a page past the end shows the last.
533
+ const rowSet = shown.map((r) => r.id).join('\u0000');
534
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resets on these changes only
535
+ useEffect(() => setPage(0), [rowSet, group]);
536
+ const pages = Math.max(1, Math.ceil(shown.length / PAGE_SIZE));
537
+ const at = Math.min(page, pages - 1);
509
538
  if (!client.usage)
510
539
  return null;
511
540
  if (!q.data)
512
541
  return (_jsx("div", { className: "flex justify-center p-4", children: q.isError ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Usage didn't load. Try again later." })) : (_jsx(Spinner, {})) }));
513
- return (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(LumenTokenizedFilter, { filter: filter, label: "Filter usage", placeholder: "Filter: type, or a session name", className: "min-w-48 flex-1", link: null }), _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: group, "aria-label": "Group by", onValueChange: (v) => v && setGroup(v), children: GROUPS.map((g) => (_jsx(ToggleGroupItem, { value: g.id, "aria-label": `Group by ${g.label}`, children: g.label }, g.id))) })] }), _jsx("div", { className: "min-w-0", children: _jsx(LumenTable, { domain: USAGE_DOMAIN, rows: filter.results, groupBy: GROUPS.find((g) => g.id === group)?.field ?? null, sort: { key: 'at', desc: true }, emptyState: rows.length ? undefined : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing used this month." })), onClearFilter: filter.clear }) }), _jsxs("p", { className: "text-muted-foreground text-xs", children: ["Sign-in amounts are estimates: your ", PLAN_NAME, " charges $0, so the amount is what the same tokens would cost at the model's API rates. Key and credit amounts are what was charged."] })] }));
542
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(LumenTokenizedFilter, { filter: filter, label: "Filter usage", placeholder: "Filter: category, type, or a session name", className: "min-w-48 flex-1", link: null }), _jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: group, "aria-label": "Group by", onValueChange: (v) => v && setGroup(v), children: GROUPS.map((g) => (_jsx(ToggleGroupItem, { value: g.id, "aria-label": `Group by ${g.label}`, children: g.label }, g.id))) })] }), _jsxs("div", { className: "min-w-0", children: [_jsx(LumenTable, { domain: USAGE_DOMAIN, rows: shown, groupBy: GROUPS.find((g) => g.id === group)?.field ?? null, page: { index: at, size: PAGE_SIZE }, sort: { key: 'at', desc: true }, emptyState: rows.length ? undefined : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing used this month." })), onClearFilter: filter.clear }), pages > 1 && (_jsx(LumenPageFooter, { total: shown.length, page: at, size: PAGE_SIZE, onPage: setPage }))] }), _jsxs("p", { className: "text-muted-foreground text-xs", children: ["Sign-in amounts are estimates: your ", PLAN_NAME, " charges $0, so the amount is what the same tokens would cost at the model's API rates. Key and credit amounts are what was charged."] })] }));
514
543
  }
@@ -213,7 +213,8 @@ export interface LumenTableProps<T> {
213
213
  rows: T[];
214
214
  groupBy?: ViewField<T> | null;
215
215
  chips?: ViewChip[];
216
- /** The page a flat table shows; null shows every row. */
216
+ /** The page a table shows; null shows every row. A grouped table pages its rows in group order:
217
+ * a group with rows on the page shows its row there, its count and totals over all its rows. */
217
218
  page?: {
218
219
  index: number;
219
220
  size: number;
@@ -761,6 +761,13 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
761
761
  }
762
762
  return [...m];
763
763
  }, [groupBy, sorted, serverGroups]);
764
+ // A grouped table's page (`page`): the rows on it, in group order; every group keeps its totals.
765
+ const paged = useMemo(() => {
766
+ if (!groups || !page || serverGroups)
767
+ return null;
768
+ const start = page.index * page.size;
769
+ return new Set(groups.flatMap(([, ms]) => ms).slice(start, start + page.size));
770
+ }, [groups, page, serverGroups]);
764
771
  const span = shown.length + (selection ? 1 : 0) + (add ? 1 : 0);
765
772
  const served = new Map((serverGroups?.list ?? []).map((g) => [g.key, g]));
766
773
  const startsOpen = (i) => rows.length <= OPEN_ALL || i === 0;
@@ -959,6 +966,9 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
959
966
  (!serverGroups || serverGroups.onToggleAll) && (_jsx(IconButton, { icon: anyClosed ? ChevronsUpDownIcon : ChevronsDownUpIcon, size: "icon-xs", className: "-ml-2 mr-1 align-middle", label: anyClosed ? 'Expand all groups' : 'Collapse all groups', onClick: () => setAll(anyClosed) })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", className: cn('font-medium', !(i === 0 && groups) && '-mx-2'), style: { color: colorOf(domain, shown[i]) ?? undefined }, onClick: h.column.getCanSort() ? h.column.getToggleSortingHandler() : undefined, children: [shown[i] ? _jsx(FreshnessMark, { domain: domain, field: shown[i] }) : null, flexRender(h.column.columnDef.header, h.getContext()), _jsx(SortIcon, { dir: h.column.getIsSorted() })] }) }), shown[i] && (domain.groups?.length || freshnessOf(domain, shown[i])) ? (_jsxs(TooltipContent, { children: [domain.groups?.length ? _jsx("div", { children: pathOf(domain, shown[i]) }) : null, freshnessLine(domain, shown[i]) ? (_jsx("div", { children: freshnessLine(domain, shown[i]) })) : null] })) : null] }), resizing && shown[i] && (_jsx(ColumnResizer, { label: shown[i].label, width: widthOf(shown[i]), onWidth: (w) => colWidths.set(h.id, w), onDone: (w) => colWidths.save(h.id, w), onReset: () => colWidths.reset(h.id) }))] }, h.id))), add && (_jsx(TableHead, { className: cn('w-8 pl-1', PINNED_HEAD), children: _jsx(AddColumnMenu, { add: add, fieldOpen: fieldOpen, onFieldOpen: setFieldOpen }) }))] }, hg.id))) }), _jsxs(TableBody, { children: [!rows.length && !serverGroups?.list?.length && (_jsx(TableRow, { "data-slot": "table-empty", className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: span, className: "py-6 text-center whitespace-normal text-muted-foreground", children: _jsxs("div", { className: "flex flex-col items-center gap-3", children: [emptyState ?? 'No rows match.', onClearFilter && (_jsxs(Button, { variant: "outline", size: "sm", onClick: onClearFilter, children: [_jsx(FilterXIcon, {}), "Clear filters"] }))] }) }) })), groups && groupBy
960
967
  ? groups.map(([key, members], gi) => {
961
968
  const open = isOpen(key, gi);
969
+ const here = paged ? members.filter((m) => paged.has(m)) : members;
970
+ if (!here.length)
971
+ return null;
962
972
  return (_jsxs(Fragment, { children: [_jsxs(TableRow, { className: ROLLUP, "data-group": key, "data-rollup": "", children: [selection && (_jsx(TableCell, { "data-card": "select", className: cn('w-8 pr-0', PINNED_GROUP), children: _jsx(Checkbox, { checked: members.every((m) => selection.keys.has(keyOf(m.original)))
963
973
  ? true
964
974
  : members.some((m) => selection.keys.has(keyOf(m.original)))
@@ -972,7 +982,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
972
982
  }), children: [_jsx(ChevronRightIcon, { className: cn('transition-transform', open && 'rotate-90') }), _jsx(GroupMark, { field: groupBy, value: key }), _jsx("span", { className: "truncate", children: key }), _jsx(GroupCount, { children: serverGroups
973
983
  ? (served.get(key)?.n?.toLocaleString() ?? '')
974
984
  : members.length })] }), groupActions?.(key, members.map((m) => m.original))] }) }, f.key)) : (_jsx(TableCell, { ...col(f, PINNED_GROUP), children: !serverGroups &&
975
- rollCell(f, members.map((m) => m.original), 'group') }, f.key))), add && _jsx(TableCell, { className: PINNED_GROUP })] }), open && (serverGroups ? servedRows(members, key) : drawn(members, key))] }, key));
985
+ rollCell(f, members.map((m) => m.original), 'group') }, f.key))), add && _jsx(TableCell, { className: PINNED_GROUP })] }), open && (serverGroups ? servedRows(members, key) : drawn(here, key))] }, key));
976
986
  })
977
987
  : drawn(sorted, ''), serverGroups?.more && !serverGroups.pages && (_jsx(TableRow, { "data-more": true, children: _jsx(TableCell, { colSpan: span, className: "py-1", children: _jsxs(Button, { variant: "ghost", size: "xs", onClick: serverGroups.more, children: [_jsx(ChevronsDownIcon, {}), "More groups"] }) }) })), hasTotal && rows.length > 0 && (_jsxs(TableRow, { className: cn(ROLLUP, 'border-t-2'), "data-total": true, "data-rollup": "", children: [selection && _jsx(TableCell, {}), rollCells(rows).map((cell, i) => i === 0 && !(shown[0] && rollsUp(shown[0])) ? (_jsx(TableCell, { "data-card": "lead", children: "Total" }, "total")) : (cell)), add && _jsx(TableCell, {})] }))] })] }) })] }));
978
988
  }
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.302",
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.302",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",