@columenlabs/lumen-ui 0.1.80 → 0.1.82
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.
- package/dist/api-fetch.js +5 -1
- package/dist/assistant/copy.d.ts +14 -0
- package/dist/assistant/copy.js +74 -0
- package/dist/assistant/lumen-assistant.d.ts +3 -1
- package/dist/assistant/lumen-assistant.js +133 -23
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/route-error.js +2 -0
- package/dist/spotlight.d.ts +33 -3
- package/dist/spotlight.js +68 -15
- package/dist/telemetry.d.ts +40 -0
- package/dist/telemetry.js +135 -0
- package/dist/theme.css +6 -0
- package/package.json +3 -2
package/dist/api-fetch.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* One JSON fetch for the assistant, workspaces and joins. A failure throws `ApiError` with the
|
|
3
3
|
* method, address, status and the server's `error` and `code`, so a caller shows what failed and
|
|
4
4
|
* Workers Logs keep it: a server fault (5xx) or a network failure is reported to
|
|
5
|
-
* `/api/client-error` with `where` naming the caller.
|
|
5
|
+
* `/api/client-error` with `where` naming the caller. Every failure is also a `fetch_fail`
|
|
6
|
+
* telemetry point (its route with ids folded, status and request id) once `useLumenTelemetry` runs. A 4xx is the server saying why it refused,
|
|
6
7
|
* shown to the person and not logged as a fault.
|
|
7
8
|
*/
|
|
8
9
|
import { reportError } from './route-error.js';
|
|
10
|
+
import { apiRoute, reportFailedFetch } from './telemetry.js';
|
|
9
11
|
export class ApiError extends Error {
|
|
10
12
|
method;
|
|
11
13
|
url;
|
|
@@ -39,6 +41,7 @@ export async function apiJson(url, init = {}, where = 'api') {
|
|
|
39
41
|
throw err;
|
|
40
42
|
const e = new ApiError(`Couldn't reach the server (${method} ${url})`, method, url, 0);
|
|
41
43
|
reportError(e, 'shown', where);
|
|
44
|
+
reportFailedFetch(apiRoute(url), 0);
|
|
42
45
|
throw e;
|
|
43
46
|
}
|
|
44
47
|
const text = await res.text();
|
|
@@ -54,6 +57,7 @@ export async function apiJson(url, init = {}, where = 'api') {
|
|
|
54
57
|
const e = new ApiError(b.error ?? `${method} ${url} answered ${res.status}`, method, url, res.status, b.code);
|
|
55
58
|
if (res.status >= 500)
|
|
56
59
|
reportError(e, 'shown', where);
|
|
60
|
+
reportFailedFetch(apiRoute(url), res.status, res.headers.get('x-request-id'));
|
|
57
61
|
throw e;
|
|
58
62
|
}
|
|
59
63
|
return body;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ChatItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
|
+
/** The whole chat, its title as the heading. */
|
|
3
|
+
export declare function chatMarkdown(title: string, items: ChatItem[], name: string): string;
|
|
4
|
+
/** One turn: a message and everything the assistant did for it. */
|
|
5
|
+
export declare function turnMarkdown(items: ChatItem[], name: string): string;
|
|
6
|
+
/** Splits a chat into turns: each starts at a message from the person. */
|
|
7
|
+
export declare function chatTurns<T extends {
|
|
8
|
+
kind: string;
|
|
9
|
+
}>(items: T[]): {
|
|
10
|
+
from: number;
|
|
11
|
+
items: T[];
|
|
12
|
+
}[];
|
|
13
|
+
/** Puts Markdown on the clipboard and says so. */
|
|
14
|
+
export declare function copyMarkdown(md: string, what: string): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
2
|
+
/** The whole chat, its title as the heading. */
|
|
3
|
+
export function chatMarkdown(title, items, name) {
|
|
4
|
+
return [`# ${title || `${name} chat`}`, ...items.map((i) => itemMarkdown(i, 2, name))]
|
|
5
|
+
.filter(Boolean)
|
|
6
|
+
.join('\n\n')
|
|
7
|
+
.concat('\n');
|
|
8
|
+
}
|
|
9
|
+
/** One turn: a message and everything the assistant did for it. */
|
|
10
|
+
export function turnMarkdown(items, name) {
|
|
11
|
+
return `${items
|
|
12
|
+
.map((i) => itemMarkdown(i, 2, name))
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join('\n\n')}\n`;
|
|
15
|
+
}
|
|
16
|
+
/** Splits a chat into turns: each starts at a message from the person. */
|
|
17
|
+
export function chatTurns(items) {
|
|
18
|
+
const starts = items.flatMap((x, i) => (x.kind === 'user' || i === 0 ? [i] : []));
|
|
19
|
+
return starts.map((from, t) => ({
|
|
20
|
+
from,
|
|
21
|
+
items: items.slice(from, starts[t + 1] ?? items.length),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
/** Puts Markdown on the clipboard and says so. */
|
|
25
|
+
export function copyMarkdown(md, what) {
|
|
26
|
+
void navigator.clipboard.writeText(md).then(() => lumenToast(`${what} copied as Markdown`), () => lumenToast.error(`Couldn't copy the ${what.toLowerCase()}`));
|
|
27
|
+
}
|
|
28
|
+
const fence = (body) => {
|
|
29
|
+
const ticks = body.includes('```') ? '````' : '```';
|
|
30
|
+
return `${ticks}json\n${body}\n${ticks}`;
|
|
31
|
+
};
|
|
32
|
+
function itemMarkdown(i, depth, name) {
|
|
33
|
+
const h = '#'.repeat(Math.min(depth, 6));
|
|
34
|
+
switch (i.kind) {
|
|
35
|
+
case 'user': {
|
|
36
|
+
const refs = i.refs?.length
|
|
37
|
+
? `\n\nPointing at: ${i.refs.map((r) => r.label).join(', ')}`
|
|
38
|
+
: '';
|
|
39
|
+
return `${h} You\n\n${i.text}${refs}`;
|
|
40
|
+
}
|
|
41
|
+
case 'text':
|
|
42
|
+
return `${h} ${name}\n\n${i.text}`;
|
|
43
|
+
case 'step': {
|
|
44
|
+
const facts = [
|
|
45
|
+
i.rows !== undefined ? `${i.rows} rows` : '',
|
|
46
|
+
`${(i.ms / 1000).toFixed(1)} s`,
|
|
47
|
+
i.write ? i.write.status : '',
|
|
48
|
+
].filter(Boolean);
|
|
49
|
+
return `**${i.ok ? 'Tool' : 'Failed'}: ${i.title}** (${facts.join(', ')})${i.error ? `\n\nError: ${i.error}` : ''}`;
|
|
50
|
+
}
|
|
51
|
+
case 'draft':
|
|
52
|
+
return [
|
|
53
|
+
`**Card: ${i.title}** (${i.status})`,
|
|
54
|
+
fence(JSON.stringify(i.args, null, 2)),
|
|
55
|
+
i.error ? `Error: ${i.error}` : '',
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join('\n\n');
|
|
59
|
+
case 'handoff':
|
|
60
|
+
return [
|
|
61
|
+
`${h} ${i.label} (${i.why})`,
|
|
62
|
+
`Task: ${i.task}`,
|
|
63
|
+
...i.items.map((x) => itemMarkdown(x, depth + 1, i.label)),
|
|
64
|
+
].join('\n\n');
|
|
65
|
+
case 'advice':
|
|
66
|
+
return `_${i.error ? 'Could not ask' : 'Asked'} ${i.label} for advice._`;
|
|
67
|
+
case 'compacted':
|
|
68
|
+
return '_Compacted._';
|
|
69
|
+
case 'stopped':
|
|
70
|
+
return '_Stopped._';
|
|
71
|
+
case 'error':
|
|
72
|
+
return `**Error${i.code ? ` (${i.code})` : ''}:** ${i.text}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -52,8 +52,10 @@ export interface ComposerExtra {
|
|
|
52
52
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
53
53
|
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
54
54
|
*/
|
|
55
|
-
export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, onSend, onStop, }: {
|
|
55
|
+
export declare function LumenComposer({ name, busy, queued, disabled, commands, tiers, sources, page, onSend, onStop, }: {
|
|
56
56
|
name: string;
|
|
57
|
+
/** The page's name for `#` This page; the document's title by default. */
|
|
58
|
+
page?: string;
|
|
57
59
|
busy: boolean;
|
|
58
60
|
queued: string | null;
|
|
59
61
|
disabled?: boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
-
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CornerDownRightIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
3
|
+
import { ArrowUpIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CopyIcon, CornerDownRightIcon, CrosshairIcon, FileTextIcon, LightbulbIcon, PencilIcon, PlusIcon, SparklesIcon, SquareIcon, Undo2Icon, WrenchIcon, XIcon, } from 'lucide-react';
|
|
4
4
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
5
5
|
import Markdown from 'react-markdown';
|
|
6
6
|
import { LumenAssistantButton, LumenAssistantChats, LumenAssistantPanel, assistantOpen, useAssistantHotkey, } from '../assistant-panel.js';
|
|
@@ -9,13 +9,15 @@ import { errorText } from '../integrations.js';
|
|
|
9
9
|
import { cn } from '../lib/utils.js';
|
|
10
10
|
import { LumenSelector, LumenSelectorList, } from '../lumen-selector.js';
|
|
11
11
|
import { lumenToast } from '../lumen-toast.js';
|
|
12
|
+
import { cardsOnScreen, kindName, pointAt, sectionsOnScreen, targetEl, textOf, } from '../spotlight.js';
|
|
12
13
|
import { Alert, AlertDescription } from '../ui/alert.js';
|
|
13
14
|
import { Button } from '../ui/button.js';
|
|
14
15
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible.js';
|
|
15
16
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
|
|
16
17
|
import { Spinner } from '../ui/spinner.js';
|
|
17
18
|
import { Textarea } from '../ui/textarea.js';
|
|
18
|
-
import {
|
|
19
|
+
import { chatMarkdown, chatTurns, copyMarkdown, turnMarkdown } from './copy.js';
|
|
20
|
+
import { composeAssistant, selectionRef, useComposeRequest, usePageSelection, useShownAssistantSources, } from './sources.js';
|
|
19
21
|
import { useAssistantChat } from './use-assistant-chat.js';
|
|
20
22
|
/** The main model's context drawn full (tokens); the mark sits where the assistant compacts. */
|
|
21
23
|
const CONTEXT_MAX = 200_000;
|
|
@@ -48,6 +50,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
|
|
|
48
50
|
enabled: false,
|
|
49
51
|
});
|
|
50
52
|
const s = status.data;
|
|
53
|
+
const name = s?.name ?? 'Assistant';
|
|
51
54
|
// A finished answer changes the spend and the chats list.
|
|
52
55
|
useEffect(() => {
|
|
53
56
|
if (chat.busy)
|
|
@@ -71,6 +74,16 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
|
|
|
71
74
|
about: 'Summarise the chat so far to free up context',
|
|
72
75
|
run: () => '/compact',
|
|
73
76
|
},
|
|
77
|
+
{
|
|
78
|
+
name: 'copy',
|
|
79
|
+
about: 'Copy the last turn as Markdown',
|
|
80
|
+
run: () => {
|
|
81
|
+
const last = chatTurns(chat.items).at(-1);
|
|
82
|
+
if (last)
|
|
83
|
+
copyMarkdown(turnMarkdown(last.items, name), 'Turn');
|
|
84
|
+
return undefined;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
74
87
|
{
|
|
75
88
|
name: 'clear',
|
|
76
89
|
about: 'Start a new chat',
|
|
@@ -126,7 +139,7 @@ export function LumenAssistant({ client, context, onSettings, keepDays = 30, onC
|
|
|
126
139
|
id: c.id,
|
|
127
140
|
title: c.title,
|
|
128
141
|
updated_at: new Date(c.at).toISOString(),
|
|
129
|
-
})) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, note: `Chats are kept ${keepDays} days after their last message.`, onRefresh: () => void chats.refetch(), onOpen: (c) => void chat.open(c.id) }), _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(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, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
|
|
142
|
+
})) ?? (chats.isFetched ? [] : null), value: chat.chatId ?? undefined, 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(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, onSend: (text, extra) => chat.send(text, { ...(context ? { context } : {}), ...extra }), onStop: chat.stop }), _jsx(UsageLine, { usage: chat.usage })] }));
|
|
130
143
|
}
|
|
131
144
|
function Messages({ chat, status, onSettings, }) {
|
|
132
145
|
const end = useRef(null);
|
|
@@ -135,7 +148,7 @@ function Messages({ chat, status, onSettings, }) {
|
|
|
135
148
|
useEffect(() => {
|
|
136
149
|
end.current?.scrollIntoView({ block: 'end' });
|
|
137
150
|
}, [count]);
|
|
138
|
-
return (_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "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(Items, { items: chat.items, onSettle: chat.settle, busy: chat.busy }) }) })), 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 })] }));
|
|
151
|
+
return (_jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-3", children: [status?.wait && (_jsxs(Alert, { className: "mb-3", children: [_jsx(CircleAlertIcon, {}), _jsxs(AlertDescription, { className: "flex flex-col items-start gap-2", children: [WAIT[status.wait](status.admin, status.providers.find((p) => p.id === status.wait_provider)?.label ?? 'API'), onSettings && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSettings, children: "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(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 })] }));
|
|
139
152
|
}
|
|
140
153
|
const isLive = (i) => !!i.live || (i.kind === 'handoff' && i.items.some(isLive));
|
|
141
154
|
const WAIT = {
|
|
@@ -145,11 +158,19 @@ const WAIT = {
|
|
|
145
158
|
app_cap: () => "You reached this month's cap on the shared keys. Add your own key, or wait for next month.",
|
|
146
159
|
own_cap: () => 'Your own keys reached their monthly cap. Raise it in Settings ▸ Model providers.',
|
|
147
160
|
};
|
|
161
|
+
/** The assistant's name, for a copied turn's headings. */
|
|
162
|
+
const AssistantName = createContext('Assistant');
|
|
148
163
|
/** A tier's name for a message's `@` chip. */
|
|
149
164
|
const TierNames = createContext((id) => id);
|
|
150
165
|
/** Takes back a change made at once (Undo on its line). */
|
|
151
166
|
const UndoWrite = createContext(null);
|
|
152
|
-
function Items({ items, onSettle, busy, }) {
|
|
167
|
+
function Items({ items, onSettle, busy, turns, }) {
|
|
168
|
+
const name = useContext(AssistantName);
|
|
169
|
+
if (turns)
|
|
170
|
+
return (_jsx("div", { className: "flex flex-col gap-3", children: chatTurns(items).map((t) => {
|
|
171
|
+
const open = busy && t.from + t.items.length === items.length;
|
|
172
|
+
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));
|
|
173
|
+
}) }));
|
|
153
174
|
// Cards drafted one after another sit in one stack, so many of them skim as lines.
|
|
154
175
|
const rows = [];
|
|
155
176
|
items.forEach((item, at) => {
|
|
@@ -306,7 +327,7 @@ function ComposerChip({ sign, label, onRemove, }) {
|
|
|
306
327
|
* triggers list what can follow them as it is typed: `/` at the start a command, `@` a tier for
|
|
307
328
|
* this message, `#` a thing to point at (the `sources`); a picked tier or thing shows as a chip.
|
|
308
329
|
*/
|
|
309
|
-
export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], onSend, onStop, }) {
|
|
330
|
+
export function LumenComposer({ name, busy, queued, disabled, commands = [], tiers = [], sources = [], page, onSend, onStop, }) {
|
|
310
331
|
const [text, setText] = useState('');
|
|
311
332
|
const [caret, setCaret] = useState(0);
|
|
312
333
|
const [closed, setClosed] = useState(null);
|
|
@@ -340,11 +361,7 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
340
361
|
const asked = word?.[3] ?? '';
|
|
341
362
|
const open = closed !== `${text}|${caret}`;
|
|
342
363
|
const slash = m && commands.length ? (m[2] === undefined ? 'command' : cmd?.options ? 'arg' : null) : null;
|
|
343
|
-
const at = trigger === '@' && tiers.length > 1
|
|
344
|
-
? '@'
|
|
345
|
-
: trigger === '#' && (sources.length || selection)
|
|
346
|
-
? '#'
|
|
347
|
-
: null;
|
|
364
|
+
const at = trigger === '@' && tiers.length > 1 ? '@' : trigger === '#' ? '#' : null;
|
|
348
365
|
const picking = open ? (slash ?? at) : null;
|
|
349
366
|
const extra = () => ({
|
|
350
367
|
...(tier ? { tier } : {}),
|
|
@@ -404,10 +421,70 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
404
421
|
box.current?.setSelectionRange(start, start);
|
|
405
422
|
});
|
|
406
423
|
};
|
|
424
|
+
// Each `#` row makes its chip when picked, so what it carries (a card's words) is read then.
|
|
407
425
|
const sourceItems = new Map();
|
|
408
426
|
const picked = selection ? selectionRef(selection) : null;
|
|
427
|
+
const added = (r) => refs.some((x) => sameRef(x, r)) ? { disabled: 'Added' } : {};
|
|
428
|
+
const onPage = [];
|
|
429
|
+
if (picking === '#') {
|
|
430
|
+
const pageRef = () => ({
|
|
431
|
+
kind: 'page',
|
|
432
|
+
label: page || document.title || 'This page',
|
|
433
|
+
path: location.pathname + location.search,
|
|
434
|
+
text: textOf(document.querySelector('main') ?? document.body, 6000),
|
|
435
|
+
});
|
|
436
|
+
sourceItems.set('page', pageRef);
|
|
437
|
+
onPage.push({
|
|
438
|
+
value: 'page',
|
|
439
|
+
label: 'This page',
|
|
440
|
+
group: 'On this page',
|
|
441
|
+
icon: FileTextIcon,
|
|
442
|
+
detail: page || document.title,
|
|
443
|
+
keywords: ['screen', 'page'],
|
|
444
|
+
...added({ kind: 'page', label: page || document.title || 'This page' }),
|
|
445
|
+
});
|
|
446
|
+
for (const c of cardsOnScreen()) {
|
|
447
|
+
const label = c.title || kindName(c.kind);
|
|
448
|
+
const ref = { kind: 'card', label, id: c.id };
|
|
449
|
+
sourceItems.set(`card:${c.id}`, () => ({
|
|
450
|
+
...ref,
|
|
451
|
+
card_kind: kindName(c.kind),
|
|
452
|
+
text: textOf(targetEl(`card:${c.id}`)),
|
|
453
|
+
}));
|
|
454
|
+
onPage.push({
|
|
455
|
+
value: `card:${c.id}`,
|
|
456
|
+
label,
|
|
457
|
+
group: 'On this page',
|
|
458
|
+
detail: kindName(c.kind),
|
|
459
|
+
target: `card:${c.id}`,
|
|
460
|
+
...added(ref),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
for (const x of sectionsOnScreen().slice(0, 60)) {
|
|
464
|
+
const ref = { kind: x.kind, label: x.label, id: x.id };
|
|
465
|
+
sourceItems.set(`section:${x.id}`, () => ({
|
|
466
|
+
...ref,
|
|
467
|
+
text: textOf(targetEl(`section:${x.id}`)),
|
|
468
|
+
}));
|
|
469
|
+
onPage.push({
|
|
470
|
+
value: `section:${x.id}`,
|
|
471
|
+
label: x.label,
|
|
472
|
+
group: 'On this page',
|
|
473
|
+
detail: x.kind,
|
|
474
|
+
target: `section:${x.id}`,
|
|
475
|
+
...added(ref),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
onPage.push({
|
|
479
|
+
value: 'point',
|
|
480
|
+
label: 'Point at something…',
|
|
481
|
+
group: 'On this page',
|
|
482
|
+
icon: CrosshairIcon,
|
|
483
|
+
description: 'Click a card or a part of the page; Esc cancels',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
409
486
|
if (picked)
|
|
410
|
-
sourceItems.set('selection', picked);
|
|
487
|
+
sourceItems.set('selection', () => picked);
|
|
411
488
|
const sourceOptions = picking === '#'
|
|
412
489
|
? [
|
|
413
490
|
...(picked
|
|
@@ -417,13 +494,14 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
417
494
|
label: picked.label,
|
|
418
495
|
group: 'Selected text',
|
|
419
496
|
...(selection?.where ? { detail: selection.where } : {}),
|
|
420
|
-
...(
|
|
497
|
+
...added(picked),
|
|
421
498
|
},
|
|
422
499
|
]
|
|
423
500
|
: []),
|
|
501
|
+
...onPage,
|
|
424
502
|
...sources.flatMap((s) => s.items(asked).map((i) => {
|
|
425
503
|
const value = `${s.id}:${i.id}`;
|
|
426
|
-
sourceItems.set(value, i.ref);
|
|
504
|
+
sourceItems.set(value, () => i.ref);
|
|
427
505
|
return {
|
|
428
506
|
value,
|
|
429
507
|
label: i.label,
|
|
@@ -432,17 +510,31 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
432
510
|
...(s.icon ? { icon: s.icon } : {}),
|
|
433
511
|
...(i.keywords ? { keywords: i.keywords } : {}),
|
|
434
512
|
...(i.target ? { target: i.target } : {}),
|
|
435
|
-
...(
|
|
513
|
+
...added(i.ref),
|
|
436
514
|
};
|
|
437
515
|
})),
|
|
438
516
|
]
|
|
439
517
|
: [];
|
|
518
|
+
// Point at…: on a phone the sheet covers the page, so it steps aside while the person picks and
|
|
519
|
+
// comes back with the pick as a chip and the words typed so far.
|
|
520
|
+
const point = async () => {
|
|
521
|
+
const phone = window.matchMedia('(max-width: 767px)').matches;
|
|
522
|
+
const typed = text.slice(0, caret - 1 - asked.length) + text.slice(caret).replace(/^ /, '');
|
|
523
|
+
if (phone)
|
|
524
|
+
assistantOpen.write(false);
|
|
525
|
+
const p = await pointAt();
|
|
526
|
+
if (phone)
|
|
527
|
+
composeAssistant({
|
|
528
|
+
text: typed,
|
|
529
|
+
refs: [...refs, ...(p ? [pointedRef(p)] : [])],
|
|
530
|
+
...(tier ? { tier } : {}),
|
|
531
|
+
});
|
|
532
|
+
else if (p)
|
|
533
|
+
setRefs((prev) => [...prev, pointedRef(p)]);
|
|
534
|
+
requestAnimationFrame(() => box.current?.focus());
|
|
535
|
+
};
|
|
440
536
|
const tierLabel = tiers.find((t) => t.id === tier)?.label;
|
|
441
|
-
const hints = [
|
|
442
|
-
commands.length && '/',
|
|
443
|
-
tiers.length > 1 && '@',
|
|
444
|
-
(sources.length || selection) && '#',
|
|
445
|
-
].filter(Boolean);
|
|
537
|
+
const hints = [commands.length && '/', tiers.length > 1 && '@', '#'].filter(Boolean);
|
|
446
538
|
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) => ({
|
|
447
539
|
value: c.name,
|
|
448
540
|
label: `/${c.name}`,
|
|
@@ -456,10 +548,14 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
456
548
|
setTier(o.value);
|
|
457
549
|
dropWord();
|
|
458
550
|
}, onClose: () => setClosed(`${text}|${caret}`) })), picking === '#' && (_jsx(LumenSelectorList, { handle: list, label: "Point at", className: "mb-2", query: asked, options: sourceOptions, empty: "Nothing matches", onPick: (o) => {
|
|
459
|
-
|
|
551
|
+
dropWord();
|
|
552
|
+
if (o.value === 'point') {
|
|
553
|
+
void point();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const r = sourceItems.get(o.value)?.();
|
|
460
557
|
if (r)
|
|
461
558
|
setRefs((p) => [...p, r]);
|
|
462
|
-
dropWord();
|
|
463
559
|
}, onClose: () => setClosed(`${text}|${caret}`) })), (tierLabel || refs.length > 0) && (_jsxs("div", { className: "mb-2 flex flex-wrap gap-1", children: [tierLabel && _jsx(ComposerChip, { sign: "@", label: tierLabel, onRemove: () => setTier(null) }), refs.map((r, i) => (_jsx(ComposerChip, { sign: "#", 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) => {
|
|
464
560
|
setText(e.target.value);
|
|
465
561
|
setCaret(e.target.selectionStart);
|
|
@@ -472,7 +568,21 @@ export function LumenComposer({ name, busy, queued, disabled, commands = [], tie
|
|
|
472
568
|
}
|
|
473
569
|
} }), _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 })) })] })] }));
|
|
474
570
|
}
|
|
475
|
-
|
|
571
|
+
// The same thing pointed at twice: its kind and what finds it, whatever its words read then.
|
|
572
|
+
const refKey = (r) => r.kind === 'page'
|
|
573
|
+
? 'page'
|
|
574
|
+
: JSON.stringify([r.kind, r.id ?? r.label, r.kind === 'selection' ? r.text : null]);
|
|
575
|
+
const sameRef = (a, b) => refKey(a) === refKey(b);
|
|
576
|
+
/** A card or section the person pointed at, as a chip with its words. */
|
|
577
|
+
const pointedRef = (p) => p.kind === 'card'
|
|
578
|
+
? {
|
|
579
|
+
kind: 'card',
|
|
580
|
+
label: p.card.title || kindName(p.card.kind),
|
|
581
|
+
id: p.card.id,
|
|
582
|
+
card_kind: kindName(p.card.kind),
|
|
583
|
+
text: textOf(p.el),
|
|
584
|
+
}
|
|
585
|
+
: { kind: p.section.kind, label: p.section.label, id: p.section.id, text: textOf(p.el) };
|
|
476
586
|
/** The chat's cost and tokens (a popup splits them per part), and how full the context is. */
|
|
477
587
|
function UsageLine({ usage }) {
|
|
478
588
|
if (!usage || !usage.calls)
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { LumenViewCard, type LumenViewCardProps } from './lumen-view-card.js';
|
|
|
4
4
|
export { LumenSelector, LumenSelectorList, type LumenSelectorListHandle, type LumenSelectorProps, type LumenOption, type LumenOptionGroup, SelectorRowContent, } from './lumen-selector.js';
|
|
5
5
|
export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
|
|
6
6
|
export { EditCell, EditInputCell } from './view-edit-cell.js';
|
|
7
|
-
export { type CardOnScreen, cardsOnScreen, kindName, onScreen, pointAtCard, spotlight, } from './spotlight.js';
|
|
7
|
+
export { type CardOnScreen, cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, type PointedAt, type SectionOnScreen, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
|
|
8
8
|
export { LinkDot, LinkPicker, type LinkColor, linkVar } from './lumen-links.js';
|
|
9
9
|
export { CardSegments, CardSwitch } from './card-switch.js';
|
|
10
10
|
export { PageInput, PageInputs } from './page-inputs.js';
|
|
@@ -52,6 +52,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestRe
|
|
|
52
52
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
53
53
|
export { LumenPager } from './pager.js';
|
|
54
54
|
export { createDeskQueryClient } from './query-client.js';
|
|
55
|
+
export { type ClientPoint, type ClientPointKind, type LumenTelemetryOptions, apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
|
|
55
56
|
export { refreshHeaders, withRefreshToken } from './refresh.js';
|
|
56
57
|
export { type LumenLoad, LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
|
|
57
58
|
export { LumenBoundary } from './lumen-boundary.js';
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { LumenViewCard } from './lumen-view-card.js';
|
|
|
11
11
|
export { LumenSelector, LumenSelectorList, SelectorRowContent, } from './lumen-selector.js';
|
|
12
12
|
export { ColorChip, ColorDot, colorIcon } from './color-dot.js';
|
|
13
13
|
export { EditCell, EditInputCell } from './view-edit-cell.js';
|
|
14
|
-
export { cardsOnScreen, kindName, onScreen, pointAtCard, spotlight, } from './spotlight.js';
|
|
14
|
+
export { cardsOnScreen, kindName, onScreen, pointAt, pointAtCard, sectionsOnScreen, spotlight, targetEl, textOf, } from './spotlight.js';
|
|
15
15
|
export { LinkDot, LinkPicker, linkVar } from './lumen-links.js';
|
|
16
16
|
export { CardSegments, CardSwitch } from './card-switch.js';
|
|
17
17
|
export { PageInput, PageInputs } from './page-inputs.js';
|
|
@@ -59,6 +59,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, err
|
|
|
59
59
|
export { LumenExpandList, LumenExpandRow } from './expand-list.js';
|
|
60
60
|
export { LumenPager } from './pager.js';
|
|
61
61
|
export { createDeskQueryClient } from './query-client.js';
|
|
62
|
+
export { apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
|
|
62
63
|
export { refreshHeaders, withRefreshToken } from './refresh.js';
|
|
63
64
|
export { LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
|
|
64
65
|
export { LumenBoundary } from './lumen-boundary.js';
|
package/dist/route-error.js
CHANGED
|
@@ -16,6 +16,7 @@ import { useRouter } from '@tanstack/react-router';
|
|
|
16
16
|
import { ChevronRight, RefreshCw, RotateCcw, TriangleAlert } from 'lucide-react';
|
|
17
17
|
import { useEffect, useState } from 'react';
|
|
18
18
|
import { toast } from 'sonner';
|
|
19
|
+
import { reportClientError } from './telemetry.js';
|
|
19
20
|
import { Button } from './ui/button.js';
|
|
20
21
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
|
|
21
22
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from './ui/empty.js';
|
|
@@ -166,6 +167,7 @@ export function reportError(error, outcome, where) {
|
|
|
166
167
|
if (Date.now() - (sent.get(key) ?? 0) < GAP_MS)
|
|
167
168
|
return;
|
|
168
169
|
sent.set(key, Date.now());
|
|
170
|
+
reportClientError(err);
|
|
169
171
|
const body = JSON.stringify({
|
|
170
172
|
message: err.message.slice(0, 500),
|
|
171
173
|
stack: (err.stack ?? '').slice(0, 2000),
|
package/dist/spotlight.d.ts
CHANGED
|
@@ -4,14 +4,29 @@ export interface CardOnScreen {
|
|
|
4
4
|
/** `view` for a table card, else the widget's kind (chart, tiles, ...). */
|
|
5
5
|
kind: string;
|
|
6
6
|
}
|
|
7
|
+
export interface SectionOnScreen {
|
|
8
|
+
/** `data-assistant-id`, else its name. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** `data-assistant-section`: what the person calls it. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** `data-assistant-kind`: what it is (role, story), `section` by default. */
|
|
13
|
+
kind: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const sectionOf: (el: HTMLElement) => SectionOnScreen;
|
|
16
|
+
/** The marked sections on the page now, top to bottom (the assistant's own are left out). */
|
|
17
|
+
export declare const sectionsOnScreen: () => SectionOnScreen[];
|
|
18
|
+
/** An element's text as the model reads it: its words, cut to `max` characters. */
|
|
19
|
+
export declare const textOf: (el: Element | null, max?: number) => string;
|
|
20
|
+
/** The element a `card:<id>` or `section:<id>` target stands for. */
|
|
21
|
+
export declare const targetEl: (target: string) => HTMLElement | null;
|
|
7
22
|
export declare const cardOf: (el: HTMLElement) => CardOnScreen;
|
|
8
23
|
/** The cards on the page now, top to bottom. */
|
|
9
24
|
export declare const cardsOnScreen: () => CardOnScreen[];
|
|
10
25
|
/** Where a target is on screen now: a card on the page, a sidebar page, or neither. */
|
|
11
|
-
export declare function onScreen(target: string | null | undefined): 'card' | 'nav' | null;
|
|
26
|
+
export declare function onScreen(target: string | null | undefined): 'card' | 'section' | 'nav' | null;
|
|
12
27
|
/**
|
|
13
|
-
* Lights one target (`card:<id>` or an app path) and dims the page around it; null
|
|
14
|
-
* target scrolls into view only after a short hover.
|
|
28
|
+
* Lights one target (`card:<id>`, `section:<id>` or an app path) and dims the page around it; null
|
|
29
|
+
* clears. The target scrolls into view only after a short hover.
|
|
15
30
|
*/
|
|
16
31
|
export declare function spotlight(target: string | null | undefined, name?: string): void;
|
|
17
32
|
/**
|
|
@@ -19,5 +34,20 @@ export declare function spotlight(target: string | null | undefined, name?: stri
|
|
|
19
34
|
* name; a click picks it, Esc or a click off any card cancels.
|
|
20
35
|
*/
|
|
21
36
|
export declare function pointAtCard(): Promise<CardOnScreen | null>;
|
|
37
|
+
/** What `pointAt` picked: a card, or a section an app marked. */
|
|
38
|
+
export type PointedAt = {
|
|
39
|
+
kind: 'card';
|
|
40
|
+
card: CardOnScreen;
|
|
41
|
+
el: HTMLElement;
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'section';
|
|
44
|
+
section: SectionOnScreen;
|
|
45
|
+
el: HTMLElement;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Point at a card or a marked section: the one under the cursor lights up with its name, the
|
|
49
|
+
* innermost when they nest; a click picks it, Esc or a click off any cancels.
|
|
50
|
+
*/
|
|
51
|
+
export declare const pointAt: () => Promise<PointedAt | null>;
|
|
22
52
|
/** A card's style (`kind:style`) as a person says it: a table, a donut, a page's own card. */
|
|
23
53
|
export declare function kindName(styleId: string): string;
|
package/dist/spotlight.js
CHANGED
|
@@ -5,10 +5,37 @@
|
|
|
5
5
|
// sidebar page gets the sidebar's muted fill. No border, no glow.
|
|
6
6
|
// pointAtCard() the cards dim, the one under the cursor lights up; a click picks it,
|
|
7
7
|
// Esc cancels. Works on any LumenLayout card with no per-page setup.
|
|
8
|
+
// pointAt() the same over cards and page sections: any element an app marks
|
|
9
|
+
// `data-assistant-section="<name>"` (a résumé role, a story, a job),
|
|
10
|
+
// with `data-assistant-kind` (what it is) and `data-assistant-id`.
|
|
11
|
+
// A section's target is `section:<id>`; lit, it gets a muted fill.
|
|
8
12
|
//
|
|
9
13
|
// Both drive the DOM directly through data attributes the theme styles: one spotlight at a time.
|
|
10
14
|
import { WIDGET_STYLES } from '@columenlabs/lumen-ui-types/views';
|
|
11
15
|
const CARD = '[data-slot="lumen-card"][data-card]';
|
|
16
|
+
const SECTION = '[data-assistant-section]';
|
|
17
|
+
const sectionId = (el) => el.dataset.assistantId || el.dataset.assistantSection || '';
|
|
18
|
+
const sectionEl = (id) => [...document.querySelectorAll(SECTION)].find((el) => sectionId(el) === id) ?? null;
|
|
19
|
+
export const sectionOf = (el) => ({
|
|
20
|
+
id: sectionId(el),
|
|
21
|
+
label: el.dataset.assistantSection ?? '',
|
|
22
|
+
kind: el.dataset.assistantKind || 'section',
|
|
23
|
+
});
|
|
24
|
+
/** The marked sections on the page now, top to bottom (the assistant's own are left out). */
|
|
25
|
+
export const sectionsOnScreen = () => [...document.querySelectorAll(SECTION)]
|
|
26
|
+
.filter((el) => !el.closest('[data-assistant]'))
|
|
27
|
+
.map(sectionOf);
|
|
28
|
+
/** An element's text as the model reads it: its words, cut to `max` characters. */
|
|
29
|
+
export const textOf = (el, max = 3000) => (el?.innerText ?? '')
|
|
30
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
31
|
+
.trim()
|
|
32
|
+
.slice(0, max);
|
|
33
|
+
/** The element a `card:<id>` or `section:<id>` target stands for. */
|
|
34
|
+
export const targetEl = (target) => target.startsWith('card:')
|
|
35
|
+
? cardEl(target.slice(5))
|
|
36
|
+
: target.startsWith('section:')
|
|
37
|
+
? sectionEl(target.slice(8))
|
|
38
|
+
: null;
|
|
12
39
|
const cardEl = (id) => document.querySelector(`${CARD.slice(0, -1)}="${CSS.escape(id)}"]`);
|
|
13
40
|
const navEl = (path) => [...document.querySelectorAll('[data-sidebar="sidebar"] a[href]')].find((a) => new URL(a.href).pathname.replace(/\/$/, '').endsWith(path.replace(/\/$/, ''))) ?? null;
|
|
14
41
|
export const cardOf = (el) => ({
|
|
@@ -31,20 +58,20 @@ function clear() {
|
|
|
31
58
|
tag?.remove();
|
|
32
59
|
tag = null;
|
|
33
60
|
}
|
|
34
|
-
function light(el, name,
|
|
61
|
+
function light(el, name, mode) {
|
|
35
62
|
if (lit !== el) {
|
|
36
63
|
if (lit)
|
|
37
64
|
delete lit.dataset.spotlit;
|
|
38
65
|
lit = el;
|
|
39
66
|
el.dataset.spotlit = '';
|
|
40
67
|
}
|
|
41
|
-
if (nav) {
|
|
68
|
+
if (mode === 'nav') {
|
|
42
69
|
document.body.dataset.spotlight = 'nav';
|
|
43
70
|
tag?.remove();
|
|
44
71
|
tag = null;
|
|
45
72
|
return;
|
|
46
73
|
}
|
|
47
|
-
document.body.dataset.spotlight =
|
|
74
|
+
document.body.dataset.spotlight = mode;
|
|
48
75
|
if (!name)
|
|
49
76
|
return;
|
|
50
77
|
tag ??= document.body.appendChild(document.createElement('div'));
|
|
@@ -60,42 +87,68 @@ export function onScreen(target) {
|
|
|
60
87
|
return null;
|
|
61
88
|
if (target.startsWith('card:'))
|
|
62
89
|
return cardEl(target.slice(5)) ? 'card' : null;
|
|
90
|
+
if (target.startsWith('section:'))
|
|
91
|
+
return sectionEl(target.slice(8)) ? 'section' : null;
|
|
63
92
|
return navEl(target) ? 'nav' : null;
|
|
64
93
|
}
|
|
65
94
|
/**
|
|
66
|
-
* Lights one target (`card:<id>` or an app path) and dims the page around it; null
|
|
67
|
-
* target scrolls into view only after a short hover.
|
|
95
|
+
* Lights one target (`card:<id>`, `section:<id>` or an app path) and dims the page around it; null
|
|
96
|
+
* clears. The target scrolls into view only after a short hover.
|
|
68
97
|
*/
|
|
69
98
|
export function spotlight(target, name) {
|
|
70
99
|
if (!target)
|
|
71
100
|
return clear();
|
|
72
|
-
const
|
|
73
|
-
|
|
101
|
+
const mode = target.startsWith('card:')
|
|
102
|
+
? 'card'
|
|
103
|
+
: target.startsWith('section:')
|
|
104
|
+
? 'section'
|
|
105
|
+
: 'nav';
|
|
106
|
+
const el = mode === 'nav' ? navEl(target) : targetEl(target);
|
|
74
107
|
if (!el)
|
|
75
108
|
return clear();
|
|
76
|
-
light(el, name,
|
|
109
|
+
light(el, name, mode);
|
|
77
110
|
clearTimeout(scrollTimer);
|
|
78
111
|
scrollTimer = setTimeout(() => {
|
|
79
112
|
const r = el.getBoundingClientRect();
|
|
80
113
|
if (r.bottom < 0 || r.top > window.innerHeight)
|
|
81
114
|
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
82
|
-
if (
|
|
83
|
-
light(el, name,
|
|
115
|
+
if (mode !== 'nav' && name)
|
|
116
|
+
light(el, name, mode);
|
|
84
117
|
}, 600);
|
|
85
118
|
}
|
|
86
119
|
/**
|
|
87
120
|
* Point at the page: the cards dim and the one under the cursor (or finger) lights up with its
|
|
88
121
|
* name; a click picks it, Esc or a click off any card cancels.
|
|
89
122
|
*/
|
|
90
|
-
export function pointAtCard() {
|
|
123
|
+
export async function pointAtCard() {
|
|
124
|
+
const picked = await pick(CARD);
|
|
125
|
+
return picked?.kind === 'card' ? picked.card : null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Point at a card or a marked section: the one under the cursor lights up with its name, the
|
|
129
|
+
* innermost when they nest; a click picks it, Esc or a click off any cancels.
|
|
130
|
+
*/
|
|
131
|
+
export const pointAt = () => pick(`${SECTION}, ${CARD}`);
|
|
132
|
+
function pick(selector) {
|
|
91
133
|
clear();
|
|
92
134
|
document.body.dataset.pointing = '';
|
|
93
135
|
return new Promise((done) => {
|
|
94
|
-
const under = (e) =>
|
|
136
|
+
const under = (e) => {
|
|
137
|
+
const el = e.target?.closest?.(selector) ?? null;
|
|
138
|
+
return el?.closest('[data-assistant]') ? null : el;
|
|
139
|
+
};
|
|
140
|
+
const what = (el) => el.matches(CARD)
|
|
141
|
+
? { kind: 'card', card: cardOf(el), el }
|
|
142
|
+
: { kind: 'section', section: sectionOf(el), el };
|
|
95
143
|
const move = (e) => {
|
|
96
144
|
const el = under(e);
|
|
97
|
-
if (el)
|
|
98
|
-
|
|
145
|
+
if (el) {
|
|
146
|
+
const p = what(el);
|
|
147
|
+
if (p.kind === 'card')
|
|
148
|
+
light(el, `${p.card.title} · ${kindName(p.card.kind)}`, 'card');
|
|
149
|
+
else
|
|
150
|
+
light(el, p.section.label, 'section');
|
|
151
|
+
}
|
|
99
152
|
else if (lit) {
|
|
100
153
|
delete lit.dataset.spotlit;
|
|
101
154
|
lit = null;
|
|
@@ -115,7 +168,7 @@ export function pointAtCard() {
|
|
|
115
168
|
e.preventDefault();
|
|
116
169
|
e.stopPropagation();
|
|
117
170
|
const el = under(e);
|
|
118
|
-
end(el ?
|
|
171
|
+
end(el ? what(el) : null);
|
|
119
172
|
};
|
|
120
173
|
const key = (e) => {
|
|
121
174
|
if (e.key !== 'Escape')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ClientPointKind = 'view' | 'error' | 'fetch_fail' | 'vital';
|
|
2
|
+
export interface ClientPoint {
|
|
3
|
+
kind: ClientPointKind;
|
|
4
|
+
/** The screen's route template (`/connections/$id`), never the address with its ids. */
|
|
5
|
+
route: string;
|
|
6
|
+
/** An error's class, an API route, or a vital's name (LCP, INP, CLS). */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** A hash of the error's stack frames. */
|
|
9
|
+
fingerprint?: string;
|
|
10
|
+
/** The `X-Request-Id` of the failed answer, to find its server side. */
|
|
11
|
+
requestId?: string;
|
|
12
|
+
/** ms, or a vital's score. */
|
|
13
|
+
value?: number;
|
|
14
|
+
status?: number;
|
|
15
|
+
device?: 'phone' | 'desktop';
|
|
16
|
+
}
|
|
17
|
+
/** The stack's frames only (a message can carry anything), with each file's query dropped. */
|
|
18
|
+
export declare function stackFingerprint(error: unknown): string;
|
|
19
|
+
/** An API address as a route: no host or query, and id-like segments as `:id`. */
|
|
20
|
+
export declare function apiRoute(url: string): string;
|
|
21
|
+
/** Queues one point (nothing is kept until `useLumenTelemetry` has named the app); the batch goes out every 15 s, at 50 points, or when the tab is hidden. */
|
|
22
|
+
export declare function reportClient(p: Omit<ClientPoint, 'device' | 'route'> & {
|
|
23
|
+
route?: string;
|
|
24
|
+
}): void;
|
|
25
|
+
/** A request that failed: its API route template, status (0 when it never answered) and id. */
|
|
26
|
+
export declare function reportFailedFetch(apiRoute: string, status: number, requestId?: string | null): void;
|
|
27
|
+
/** An error the page caught or didn't: its class and stack hash only. */
|
|
28
|
+
export declare function reportClientError(error: unknown): void;
|
|
29
|
+
export interface LumenTelemetryOptions {
|
|
30
|
+
/** The app's name in the points (`console`, `admin`). */
|
|
31
|
+
app: string;
|
|
32
|
+
/** The current screen's route template, from the app's router. */
|
|
33
|
+
route: string;
|
|
34
|
+
/** The build or deploy the page runs. */
|
|
35
|
+
version?: string;
|
|
36
|
+
/** Where batches go; `/api/t` by default. */
|
|
37
|
+
endpoint?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Mount once at the app's root: every screen change is a view, and errors and vitals follow. */
|
|
40
|
+
export declare function useLumenTelemetry(opts: LumenTelemetryOptions): void;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's own telemetry from the browser: screens opened, uncaught errors, failed requests and
|
|
3
|
+
* Web Vitals, sent in batches to the app's Worker (by default `POST /api/t`), which checks each
|
|
4
|
+
* point and writes it with `meterClientBatch` from `@columenlabs/lumen-ops/client`.
|
|
5
|
+
*
|
|
6
|
+
* It is first party and keeps nothing in the browser: no cookie, no storage, no third party. A
|
|
7
|
+
* point holds a route template, an error's class and a hash of its stack, a status and a number.
|
|
8
|
+
* Never an error's message, a URL's query, page text or anything typed.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect } from 'react';
|
|
11
|
+
import { onCLS, onINP, onLCP } from 'web-vitals';
|
|
12
|
+
const MAX_BATCH = 50;
|
|
13
|
+
const FLUSH_MS = 15_000;
|
|
14
|
+
const CODE = /^[A-Za-z0-9_.:/$-]{1,64}$/;
|
|
15
|
+
let endpoint = '/api/t';
|
|
16
|
+
let app = '';
|
|
17
|
+
let version = '';
|
|
18
|
+
let route = '';
|
|
19
|
+
const queue = [];
|
|
20
|
+
let started = false;
|
|
21
|
+
const fnv = (s) => {
|
|
22
|
+
let h = 2166136261;
|
|
23
|
+
for (let i = 0; i < s.length; i++)
|
|
24
|
+
h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0;
|
|
25
|
+
return h.toString(16).padStart(8, '0');
|
|
26
|
+
};
|
|
27
|
+
/** The stack's frames only (a message can carry anything), with each file's query dropped. */
|
|
28
|
+
export function stackFingerprint(error) {
|
|
29
|
+
const stack = error instanceof Error ? (error.stack ?? '') : '';
|
|
30
|
+
const frames = stack
|
|
31
|
+
.split('\n')
|
|
32
|
+
.filter((l) => /^\s*at\s|@/.test(l))
|
|
33
|
+
.slice(0, 8)
|
|
34
|
+
.map((l) => l.replace(/\?[^:)\s]*/g, '').trim());
|
|
35
|
+
return fnv(frames.join('\n'));
|
|
36
|
+
}
|
|
37
|
+
const device = () => typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
|
|
38
|
+
? 'phone'
|
|
39
|
+
: 'desktop';
|
|
40
|
+
const code = (s) => (s && CODE.test(s) ? s : undefined);
|
|
41
|
+
/** An API address as a route: no host or query, and id-like segments as `:id`. */
|
|
42
|
+
export function apiRoute(url) {
|
|
43
|
+
let path = url;
|
|
44
|
+
try {
|
|
45
|
+
path = new URL(url, 'http://x').pathname;
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
return path
|
|
49
|
+
.split('/')
|
|
50
|
+
.map((seg) => /^[a-z]{1,4}_[A-Za-z0-9]+$/.test(seg) ||
|
|
51
|
+
seg.length >= 20 ||
|
|
52
|
+
(seg.length >= 8 && /\d/.test(seg)) ||
|
|
53
|
+
/[^A-Za-z0-9_.-]/.test(seg)
|
|
54
|
+
? ':id'
|
|
55
|
+
: seg)
|
|
56
|
+
.join('/')
|
|
57
|
+
.slice(0, 64);
|
|
58
|
+
}
|
|
59
|
+
function flush() {
|
|
60
|
+
if (!app || queue.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
const points = queue.splice(0, MAX_BATCH);
|
|
63
|
+
const body = JSON.stringify({ app, version: code(version), points });
|
|
64
|
+
try {
|
|
65
|
+
const sent = navigator.sendBeacon?.(endpoint, new Blob([body], { type: 'application/json' }));
|
|
66
|
+
if (!sent)
|
|
67
|
+
fetch(endpoint, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body,
|
|
71
|
+
keepalive: true,
|
|
72
|
+
}).catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Telemetry must never break the page.
|
|
76
|
+
}
|
|
77
|
+
if (queue.length)
|
|
78
|
+
flush();
|
|
79
|
+
}
|
|
80
|
+
/** Queues one point (nothing is kept until `useLumenTelemetry` has named the app); the batch goes out every 15 s, at 50 points, or when the tab is hidden. */
|
|
81
|
+
export function reportClient(p) {
|
|
82
|
+
if (!started)
|
|
83
|
+
return;
|
|
84
|
+
queue.push({
|
|
85
|
+
...p,
|
|
86
|
+
route: p.route ?? route,
|
|
87
|
+
name: code(p.name),
|
|
88
|
+
requestId: code(p.requestId),
|
|
89
|
+
device: device(),
|
|
90
|
+
});
|
|
91
|
+
if (queue.length >= MAX_BATCH)
|
|
92
|
+
flush();
|
|
93
|
+
}
|
|
94
|
+
/** A request that failed: its API route template, status (0 when it never answered) and id. */
|
|
95
|
+
export function reportFailedFetch(apiRoute, status, requestId) {
|
|
96
|
+
reportClient({ kind: 'fetch_fail', name: apiRoute, status, requestId: requestId ?? undefined });
|
|
97
|
+
}
|
|
98
|
+
/** An error the page caught or didn't: its class and stack hash only. */
|
|
99
|
+
export function reportClientError(error) {
|
|
100
|
+
reportClient({
|
|
101
|
+
kind: 'error',
|
|
102
|
+
name: error instanceof Error ? error.name : 'Error',
|
|
103
|
+
fingerprint: stackFingerprint(error),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const vital = (m) => reportClient({ kind: 'vital', name: m.name, value: m.value });
|
|
107
|
+
function start() {
|
|
108
|
+
if (started || typeof window === 'undefined')
|
|
109
|
+
return;
|
|
110
|
+
started = true;
|
|
111
|
+
window.addEventListener('error', (e) => reportClientError(e.error ?? new Error('script')));
|
|
112
|
+
window.addEventListener('unhandledrejection', (e) => reportClientError(e.reason));
|
|
113
|
+
document.addEventListener('visibilitychange', () => {
|
|
114
|
+
if (document.visibilityState === 'hidden')
|
|
115
|
+
flush();
|
|
116
|
+
});
|
|
117
|
+
window.addEventListener('pagehide', flush);
|
|
118
|
+
setInterval(flush, FLUSH_MS);
|
|
119
|
+
onLCP(vital);
|
|
120
|
+
onINP(vital);
|
|
121
|
+
onCLS(vital);
|
|
122
|
+
}
|
|
123
|
+
/** Mount once at the app's root: every screen change is a view, and errors and vitals follow. */
|
|
124
|
+
export function useLumenTelemetry(opts) {
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
app = opts.app;
|
|
127
|
+
version = opts.version ?? '';
|
|
128
|
+
endpoint = opts.endpoint ?? '/api/t';
|
|
129
|
+
start();
|
|
130
|
+
}, [opts.app, opts.version, opts.endpoint]);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
route = opts.route;
|
|
133
|
+
reportClient({ kind: 'view', route: opts.route });
|
|
134
|
+
}, [opts.route]);
|
|
135
|
+
}
|
package/dist/theme.css
CHANGED
|
@@ -525,6 +525,12 @@ body[data-pointing],
|
|
|
525
525
|
body[data-pointing] [data-slot="lumen-card"] * {
|
|
526
526
|
cursor: crosshair;
|
|
527
527
|
}
|
|
528
|
+
body[data-spotlight="section"] [data-spotlit],
|
|
529
|
+
body[data-pointing] [data-assistant-section][data-spotlit] {
|
|
530
|
+
background: color-mix(in oklab, var(--primary) 8%, transparent);
|
|
531
|
+
border-radius: calc(var(--radius) * 0.6);
|
|
532
|
+
transition: background 120ms ease-out;
|
|
533
|
+
}
|
|
528
534
|
body[data-spotlight="nav"] [data-spotlit] {
|
|
529
535
|
background: var(--sidebar-accent);
|
|
530
536
|
color: var(--sidebar-accent-foreground);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.82",
|
|
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.
|
|
52
|
+
"@columenlabs/lumen-ui-types": "0.1.82",
|
|
53
53
|
"@dnd-kit/core": "6.3.1",
|
|
54
54
|
"@dnd-kit/sortable": "10.0.0",
|
|
55
55
|
"@dnd-kit/utilities": "3.2.2",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"sonner": "^2.0.8",
|
|
84
84
|
"tailwind-merge": "^3.7.0",
|
|
85
85
|
"topojson-client": "3.1.0",
|
|
86
|
+
"web-vitals": "6.2.3",
|
|
86
87
|
"world-atlas": "2.0.2"
|
|
87
88
|
},
|
|
88
89
|
"peerDependencies": {
|