@adecore/agents-react 0.21.0 → 0.21.1

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.
Files changed (47) hide show
  1. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +14 -1
  2. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +47 -5
  3. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +3 -1
  4. package/dist/chat/ui/intelligent-ui/UiReply.js +34 -30
  5. package/dist/chat/ui/intelligent-ui/block-local.d.ts +9 -0
  6. package/dist/chat/ui/intelligent-ui/block-local.js +30 -1
  7. package/dist/chat/ui/intelligent-ui/node-text.d.ts +6 -0
  8. package/dist/chat/ui/intelligent-ui/node-text.js +54 -0
  9. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  10. package/dist/chat/ui/intelligent-ui/render-context.d.ts +3 -0
  11. package/dist/chat/ui/intelligent-ui/renderers/chart.js +5 -7
  12. package/dist/chat/ui/intelligent-ui/renderers/choices.js +6 -2
  13. package/dist/chat/ui/intelligent-ui/renderers/content.js +6 -13
  14. package/dist/chat/ui/intelligent-ui/renderers/data.js +8 -15
  15. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +1 -0
  16. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +16 -4
  17. package/dist/chat/ui/intelligent-ui/renderers/links.js +2 -1
  18. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +4 -4
  19. package/dist/chat/ui/intelligent-ui/use-ui-links.js +29 -10
  20. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +2 -1
  21. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +34 -21
  22. package/dist/chat/ui/rows/MessageRows.js +1 -1
  23. package/dist/locales/en/agent-chat.json +23 -4
  24. package/dist/locales/nl/agent-chat.json +23 -4
  25. package/dist/state/chats.js +5 -1
  26. package/package.json +24 -29
  27. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +135 -5
  28. package/src/chat/ui/intelligent-ui/UiReply.tsx +48 -38
  29. package/src/chat/ui/intelligent-ui/block-local.ts +36 -1
  30. package/src/chat/ui/intelligent-ui/node-text.ts +60 -0
  31. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  32. package/src/chat/ui/intelligent-ui/render-context.ts +6 -0
  33. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +5 -7
  34. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +6 -2
  35. package/src/chat/ui/intelligent-ui/renderers/content.tsx +9 -14
  36. package/src/chat/ui/intelligent-ui/renderers/data.tsx +13 -19
  37. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +28 -5
  38. package/src/chat/ui/intelligent-ui/renderers/links.tsx +2 -1
  39. package/src/chat/ui/intelligent-ui/use-ui-links.ts +31 -11
  40. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +48 -22
  41. package/src/chat/ui/rows/MessageRows.tsx +1 -1
  42. package/src/locales/en/agent-chat.json +23 -4
  43. package/src/locales/nl/agent-chat.json +23 -4
  44. package/src/state/chats.ts +5 -1
  45. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +0 -15
  46. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +0 -50
  47. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +0 -139
@@ -1,6 +1,19 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { UiViewNode } from '@adecore/intelligent-ui';
3
- import { type UiBlockFooterProps } from './UiBlockFooter.js';
3
+ import { type UiLiveStatus } from './render-context.js';
4
+ export interface UiBlockFix {
5
+ code: string;
6
+ message: string;
7
+ }
8
+ export interface UiBlockFooterProps {
9
+ live?: UiLiveStatus | null;
10
+ answered?: {
11
+ label: string;
12
+ at: number;
13
+ } | null;
14
+ fixes?: readonly UiBlockFix[];
15
+ shownAsText?: string | null;
16
+ }
4
17
  export interface UiBlockFrameProps extends UiBlockFooterProps {
5
18
  nodes: readonly UiViewNode[];
6
19
  phase: 'streaming' | 'final';
@@ -1,11 +1,53 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useLayoutEffect, useRef, useState } from 'react';
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useLayoutEffect, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Skeleton } from '@adecore/ui';
6
- import { uiBlockHead } from './node-text.js';
5
+ import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
6
+ import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
7
+ import { formatAgo, formatMoment } from '@adecore/ui/format';
8
+ import { uiBlockHead, uiReasonText } from './node-text.js';
7
9
  import { UiHeadContext } from './render-context.js';
8
- import { UiBlockFooter } from './UiBlockFooter.js';
10
+ import { useLater } from './use-later.js';
11
+ const NO_FIXES = [];
12
+ const READING_SPINNER_MS = 1000;
13
+ /* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
14
+ function LivePart({ live }) {
15
+ const { t } = useTranslation('agent-chat');
16
+ const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
17
+ const { readAt } = live;
18
+ const fresh = live.state === 'fresh' || live.state === 'reading';
19
+ const ago = useTickingText(() => {
20
+ if (readAt === null) {
21
+ return '';
22
+ }
23
+ const time = formatAgo(Date.now() - readAt);
24
+ return fresh ? t('blocks.live.read', { time }) : time;
25
+ });
26
+ const source = live.source ?? live.sources[0] ?? '';
27
+ const icon = slow ? (_jsx(Spinner, { size: 12 })) : live.state === 'failed' ? (_jsx(Icon, { icon: TriangleAlert, size: 12, className: "text-status-needs-you" })) : live.state === 'refused' ? (_jsx(Icon, { icon: Lock, size: 12 })) : (_jsx(Icon, { icon: Radio, size: 12, className: "text-status-idle" }));
28
+ const words = live.state === 'failed'
29
+ ? t('blocks.live.failed', { source })
30
+ : live.state === 'refused'
31
+ ? t('blocks.live.refused', { source })
32
+ : t('blocks.live.label');
33
+ const reason = live.state === 'failed' || live.state === 'refused' ? uiReasonText(t, live.code, live.reason) : undefined;
34
+ const hint = reason !== undefined ? reason : live.sources.length > 0 ? t('blocks.live.sources', { sources: live.sources.join(', ') }) : null;
35
+ const body = (_jsxs("span", { tabIndex: hint === null ? undefined : 0, className: "flex items-center gap-1.5", children: [icon, _jsx("span", { children: words }), readAt !== null && live.state !== 'refused' && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("time", { dateTime: new Date(readAt).toISOString(), children: _jsx("span", { ref: ago, className: "tabular-nums" }) })] })), readAt === null && fresh && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("span", { children: t('blocks.live.notRead') })] }))] }));
36
+ return hint === null ? body : _jsx(Tooltip, { label: hint, children: body });
37
+ }
38
+ /*
39
+ * The line under a block, only when it has something to say: live, answered, repaired or drawn as
40
+ * text, in that order. A hairline of the soft border runs the full width of the card over it.
41
+ */
42
+ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }) {
43
+ const { t } = useTranslation('agent-chat');
44
+ const [fixesOpen, setFixesOpen] = useState(false);
45
+ const listId = useId();
46
+ if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
47
+ return null;
48
+ }
49
+ return (_jsxs("div", { className: "-mx-2.25 mt-2.25 -mb-2.25 flex flex-col gap-1.5 border-t border-border-soft px-4.25 py-2 text-xs text-text-faint", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1", children: [live !== null && _jsx(LivePart, { live: live }), answered !== null && (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { children: t('blocks.answered', { label: answered.label }) }), _jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("time", { dateTime: new Date(answered.at).toISOString(), children: formatMoment(answered.at) })] })), fixes.length > 0 && (_jsxs("button", { type: "button", "aria-expanded": fixesOpen, "aria-controls": listId, className: "flex items-center gap-1.5 rounded-sm hover:text-text-muted", onClick: () => setFixesOpen(!fixesOpen), children: [_jsx(Icon, { icon: Wrench, size: 12 }), t('blocks.fixes', { count: fixes.length })] })), shownAsText !== null && (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { children: t('blocks.shownAsText') }), _jsx("span", { "aria-hidden": true, children: "\u00B7" }), _jsx("span", { children: shownAsText })] }))] }), fixes.length > 0 && (_jsx("ul", { id: listId, hidden: !fixesOpen, className: "flex flex-col gap-0.5 text-text-muted", children: fixes.map((fix, index) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { className: "shrink-0 font-mono", children: fix.code }), _jsx("span", { className: "min-w-0", children: fix.message })] }, index))) }))] }));
50
+ }
9
51
  const RELEASE_MS = 200;
10
52
  /*
11
53
  * The tallest the card has been, while `active`: a streaming block grows and never shrinks until its
@@ -3,11 +3,13 @@ import { type UiBlock } from '@adecore/intelligent-ui';
3
3
  import type { ChatUiAnswer, ChatUiQueryState } from '@adecore/agent-contracts';
4
4
  import { ReplyMarkdown } from '../Markdown.js';
5
5
  import type { UiRenderContext } from './render-context.js';
6
+ type UiBlockIdentity = Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'blockId' | 'phase'>;
6
7
  export declare function UiReply({ text, blocks, context, answers, queries, reply }: {
7
8
  text: string;
8
9
  blocks: readonly UiBlock[];
9
- context: Omit<UiRenderContext, 'blockId'>;
10
+ context: Omit<UiBlockIdentity, 'blockId'>;
10
11
  answers?: Readonly<Record<string, ChatUiAnswer>>;
11
12
  queries?: ChatUiQueryState;
12
13
  reply?: ComponentProps<typeof ReplyMarkdown>['reply'];
13
14
  }): ReactNode;
15
+ export {};
@@ -1,26 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Component, createContext, Fragment, memo, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { Component, createContext, Fragment, memo, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import ReactMarkdown, {} from 'react-markdown';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { evaluateUiBlock, uiInputValues, isUiComponent, UI_CATALOG_VERSION, UiState } from '@adecore/intelligent-ui';
6
- import { ErrorBoundary } from '@adecore/ui';
6
+ import { ErrorBoundary, Tooltip } from '@adecore/ui';
7
7
  import { chatHost } from '../../../host.js';
8
8
  import { UiReplyNavigationContext } from '../reply-context.js';
9
9
  import { ReplyMarkdown } from '../Markdown.js';
10
10
  import { rehypeFadeWords } from '../rehype-fade.js';
11
11
  import { UI_RENDERERS } from './registry.js';
12
+ import { UiPageMemory } from './block-local.js';
12
13
  import { UiBlockFrame } from './UiBlockFrame.js';
13
14
  import { UiFallbackPart } from './UiFallbackPart.js';
14
15
  import { useUiLinks } from './use-ui-links.js';
15
16
  import { useUiQueries, useUiLiveValues } from './use-ui-queries.js';
16
17
  import { UiSourceCitation } from './renderers/content.js';
17
- import { uiChildrenOf, revealUiSource } from './node-text.js';
18
+ import { uiChildrenOf, revealUiSource, uiLiveChoiceReason, uiUrlDomain } from './node-text.js';
18
19
  import { uiReplyParts } from './reply-parts.js';
19
- const localStates = new Map();
20
- const LOCAL_STATE_LIMIT = 64;
20
+ const localStates = new UiPageMemory(64);
21
21
  function localState(key, block, answered) {
22
22
  let state = localStates.get(key);
23
- localStates.delete(key);
24
23
  if (!state) {
25
24
  state = new UiState(block);
26
25
  if (answered && answered.revision === block.revision) {
@@ -28,10 +27,7 @@ function localState(key, block, answered) {
28
27
  state.set(name, value);
29
28
  }
30
29
  }
31
- }
32
- localStates.set(key, state);
33
- if (localStates.size > LOCAL_STATE_LIMIT) {
34
- localStates.delete(localStates.keys().next().value);
30
+ localStates.set(key, state);
35
31
  }
36
32
  return state;
37
33
  }
@@ -41,7 +37,7 @@ const BLOCK = '$block';
41
37
  * Parents whose text is read as written: code, a short tag, and the labels of controls, whose
42
38
  * accessible name and sent text are that same string.
43
39
  */
44
- const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Column']);
40
+ const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Button', 'Column']);
45
41
  /* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
46
42
  const PARAGRAPHS = {
47
43
  [BLOCK]: 'px-2 text-xs text-text',
@@ -129,8 +125,6 @@ function sourceTargets(nodes) {
129
125
  }
130
126
  const PROSE_PLUGINS = [remarkInlineProse, remarkCitations];
131
127
  const PROSE_ELEMENTS = ['p', 'br', 'strong', 'em', 'code', 'a', 'span'];
132
- // Raw HTML never parses here, so the only spans are the words the fade wraps.
133
- const FADING_PROSE_ELEMENTS = [...PROSE_ELEMENTS, 'span'];
134
128
  const FADE_PLUGINS = [rehypeFadeWords];
135
129
  const NO_PLUGINS = [];
136
130
  const ProseUrlContext = createContext(undefined);
@@ -140,7 +134,7 @@ function ProseLink({ href, children }) {
140
134
  if (openUrl === undefined || href === undefined || !/^https?:\/\//i.test(href)) {
141
135
  return children;
142
136
  }
143
- return (_jsx("button", { type: "button", className: "cursor-pointer text-accent underline underline-offset-2 select-text", onClick: () => openUrl(href), children: children }));
137
+ return (_jsx(Tooltip, { label: uiUrlDomain(href), children: _jsx("button", { type: "button", className: "cursor-pointer text-accent underline underline-offset-2 select-text", onClick: () => openUrl(href), children: children }) }));
144
138
  }
145
139
  const PROSE_COMPONENTS = {
146
140
  p: ({ children }) => children,
@@ -158,7 +152,7 @@ const UiProse = memo(function UiProse({ text, fade, openUrl }) {
158
152
  if (body === '') {
159
153
  return text;
160
154
  }
161
- return (_jsxs(ProseUrlContext, { value: openUrl, children: [lead, _jsx(ReactMarkdown, { remarkPlugins: PROSE_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, allowedElements: fade ? FADING_PROSE_ELEMENTS : PROSE_ELEMENTS, unwrapDisallowed: true, components: PROSE_COMPONENTS, children: body }), trail] }));
155
+ return (_jsxs(ProseUrlContext, { value: openUrl, children: [lead, _jsx(ReactMarkdown, { remarkPlugins: PROSE_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, allowedElements: PROSE_ELEMENTS, unwrapDisallowed: true, components: PROSE_COMPONENTS, children: body }), trail] }));
162
156
  });
163
157
  /* Which text nodes of a block the agent wrote out, by id; the value of an expression is data and stays as it is. */
164
158
  function writtenTexts(nodes, into = new Map()) {
@@ -183,6 +177,10 @@ function isBlank(node) {
183
177
  function UiNodes({ nodes, parent, texts, context }) {
184
178
  const drawn = (node) => (_jsx(UiNodeBoundary, { node: node, children: _jsx(UiNodeBody, { node: node, parent: parent, texts: texts, context: context }) }, node.id));
185
179
  const paragraph = PARAGRAPHS[parent];
180
+ if (parent === 'Choices') {
181
+ // A primary choice stands first in the order a keyboard and a screen reader meet it too, not only on screen.
182
+ return [...nodes].sort((a, b) => Number(b.props.primary === true) - Number(a.props.primary === true)).map(drawn);
183
+ }
186
184
  if (paragraph === undefined) {
187
185
  return nodes.map(drawn);
188
186
  }
@@ -239,7 +237,7 @@ function UiBlockBody({ block, context, answered, frozen }) {
239
237
  const key = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id]);
240
238
  const [state] = useState(() => localState(key, block, answered));
241
239
  state.sync(block);
242
- useSyncExternalStore((listener) => state.subscribe(listener), () => state.snapshot(), () => state.snapshot());
240
+ const stateRevision = useSyncExternalStore((listener) => state.subscribe(listener), () => state.snapshot(), () => state.snapshot());
243
241
  const [optimistic, setOptimistic] = useState(null);
244
242
  const [failedChoiceId, setFailedChoiceId] = useState(null);
245
243
  const pending = useRef(false);
@@ -254,14 +252,26 @@ function UiBlockBody({ block, context, answered, frozen }) {
254
252
  ? { choiceId: answered.choiceId, state: answered.queued ? 'queued' : 'sent' }
255
253
  : optimistic?.revision === block.revision
256
254
  ? optimistic
257
- : context.answer;
255
+ : null;
258
256
  const [element, setElement] = useState(null);
259
257
  const navigation = useContext(UiReplyNavigationContext);
260
258
  const flash = navigation?.flash;
261
259
  const flashing = navigation?.chatId === context.chatId && flash?.itemId === context.itemId && flash.blockId === block.id && flash.revision === block.revision;
262
260
  const queries = useUiQueries(block, state, context, element, frozen);
263
261
  const links = useUiLinks(block, state, context, queries.reads, frozen);
264
- const evaluation = evaluateUiBlock(block, state);
262
+ // The state changes in place, so its revision is what tells this evaluation it went stale.
263
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
264
+ const evaluation = useMemo(() => evaluateUiBlock(block, state), [block, state, stateRevision]);
265
+ const texts = useMemo(() => writtenTexts(block.nodes), [block]);
266
+ const sources = useMemo(() => sourceTargets(evaluation.nodes), [evaluation]);
267
+ const citations = useMemo(() => ({
268
+ sources,
269
+ reveal: element
270
+ ? (source) => {
271
+ void revealUiSource(element, source.id, source.ancestors);
272
+ }
273
+ : undefined
274
+ }), [sources, element]);
265
275
  const liveValue = useUiLiveValues(block, evaluation.nodes, queries.currentReadings);
266
276
  const sendChoice = chatHost().intelligentUi?.sendChoice;
267
277
  const rendering = {
@@ -269,8 +279,9 @@ function UiBlockBody({ block, context, answered, frozen }) {
269
279
  ...links,
270
280
  answer,
271
281
  failedChoiceId,
272
- live: queries.live ?? context.live,
273
- liveValue: liveValue ?? context.liveValue,
282
+ live: queries.live,
283
+ liveValue,
284
+ choiceReason: uiLiveChoiceReason(t, queries.live),
274
285
  onChoose: !queries.ready || queries.reading
275
286
  ? undefined
276
287
  : sendChoice && block.revision && block.complete && context.phase === 'final'
@@ -305,21 +316,14 @@ function UiBlockBody({ block, context, answered, frozen }) {
305
316
  pending.current = false;
306
317
  });
307
318
  }
308
- : context.onChoose
319
+ : undefined
309
320
  };
310
321
  const unknown = block.catalogVersion !== UI_CATALOG_VERSION;
311
322
  const failed = !unknown && !block.nodes.length && block.diagnostics.length > 0;
312
323
  const shownAsText = unknown ? t('blocks.unreadable') : failed ? block.diagnostics[0].message : undefined;
313
- return (_jsxs("div", { ref: setElement, className: "relative", "data-ui-block": block.id, "data-ui-revision": block.revision, tabIndex: -1, children: [flashing && _jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce), _jsx(UiBlockFrame, { live: queries.live, nodes: evaluation.nodes, phase: context.phase, fixes: [...block.diagnostics, ...evaluation.diagnostics], shownAsText: shownAsText, answered: answered, children: unknown || failed ? (_jsx(ReplyMarkdown, { text: block.fallback, streaming: false })) : (_jsx(CitationContext, { value: {
314
- sources: sourceTargets(evaluation.nodes),
315
- reveal: element
316
- ? (source) => {
317
- void revealUiSource(element, source.id, source.ancestors);
318
- }
319
- : undefined
320
- }, children: _jsx(UiNodes, { nodes: evaluation.nodes, parent: BLOCK, texts: writtenTexts(block.nodes), context: rendering }) })) })] }));
324
+ return (_jsxs("div", { ref: setElement, className: "relative", "data-ui-block": block.id, "data-ui-revision": block.revision, tabIndex: -1, children: [flashing && _jsx("span", { className: "chat-flash pointer-events-none absolute inset-0 rounded-lg", "aria-hidden": true }, flash.nonce), _jsx(UiBlockFrame, { live: queries.live, nodes: evaluation.nodes, phase: context.phase, fixes: [...block.diagnostics, ...evaluation.diagnostics], shownAsText: shownAsText, answered: answered, children: unknown || failed ? (_jsx(ReplyMarkdown, { text: block.fallback, streaming: false })) : (_jsx(CitationContext, { value: citations, children: _jsx(UiNodes, { nodes: evaluation.nodes, parent: BLOCK, texts: texts, context: rendering }) })) })] }));
321
325
  }
322
326
  /* Prose and cards a block gap apart, as the rows of a turn are; text that is only the newline between two fences draws nothing. */
323
327
  export function UiReply({ text, blocks, context, answers, queries, reply }) {
324
- return (_jsx("div", { className: "flex flex-col gap-(--chat-block-gap)", children: uiReplyParts(text, blocks, context.phase === 'streaming').map((part, index) => part.kind === 'text' ? (part.text.trim() !== '' && (_jsx(Fragment, { children: _jsx(ReplyMarkdown, { text: part.text, streaming: context.phase === 'streaming', reply: reply }) }, `text:${index}`))) : (_jsx(ErrorBoundary, { label: "UI block", resetKeys: [context.scopeId, context.chatId, context.itemId, part.block.id], children: _jsx(UiBlockBody, { block: part.block, frozen: queries?.blocks[part.block.id], answered: answers?.[part.block.id], context: { ...context, blockId: part.block.id } }, JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])) }, part.block.id))) }));
328
+ return (_jsx("div", { className: "flex flex-col gap-(--chat-block-gap)", children: uiReplyParts(text, blocks, context.phase === 'streaming').map((part, index) => part.kind === 'text' ? (part.text.trim() !== '' && (_jsx(Fragment, { children: _jsx(ReplyMarkdown, { text: part.text, streaming: context.phase === 'streaming', reply: reply }) }, `text:${index}`))) : (_jsx(ErrorBoundary, { label: "UI block", children: _jsx(UiBlockBody, { block: part.block, frozen: queries?.blocks[part.block.id], answered: answers?.[part.block.id], context: { scopeId: context.scopeId, chatId: context.chatId, itemId: context.itemId, phase: context.phase, blockId: part.block.id } }) }, JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])))) }));
325
329
  }
@@ -1,2 +1,11 @@
1
1
  import type { UiRenderContext } from './render-context.js';
2
+ export declare class UiPageMemory<Value> {
3
+ private readonly entries;
4
+ private readonly limit;
5
+ constructor(limit: number);
6
+ has(key: string): boolean;
7
+ get(key: string): Value | undefined;
8
+ set(key: string, value: Value): void;
9
+ get size(): number;
10
+ }
2
11
  export declare function useBlockLocal<Value>(context: UiRenderContext, nodeId: string, initial: () => Value): [Value, (next: Value) => void];
@@ -1,6 +1,35 @@
1
1
  import { useState } from 'react';
2
+ /* Values that last as long as the page, past `limit` forgetting the one used longest ago. */
3
+ export class UiPageMemory {
4
+ entries = new Map();
5
+ limit;
6
+ constructor(limit) {
7
+ this.limit = limit;
8
+ }
9
+ has(key) {
10
+ return this.entries.has(key);
11
+ }
12
+ /* Reading a value counts as using it. */
13
+ get(key) {
14
+ const value = this.entries.get(key);
15
+ if (value !== undefined) {
16
+ this.set(key, value);
17
+ }
18
+ return value;
19
+ }
20
+ set(key, value) {
21
+ this.entries.delete(key);
22
+ this.entries.set(key, value);
23
+ if (this.entries.size > this.limit) {
24
+ this.entries.delete(this.entries.keys().next().value);
25
+ }
26
+ }
27
+ get size() {
28
+ return this.entries.size;
29
+ }
30
+ }
2
31
  // Which tab or section a person opened, so a block the thread unmounts while scrolling comes back as they left it.
3
- const remembered = new Map();
32
+ const remembered = new UiPageMemory(512);
4
33
  /* State of one node that lasts as long as the page, such as a fold; local input state belongs to the runtime instead. */
5
34
  export function useBlockLocal(context, nodeId, initial) {
6
35
  const key = `${context.scopeId}\n${context.chatId}\n${context.itemId}\n${context.blockId}\n${nodeId}`;
@@ -1,4 +1,6 @@
1
+ import type { TFunction } from 'i18next';
1
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
+ import type { UiLiveStatus } from './render-context.js';
2
4
  export declare function uiNodeText(node: UiViewNode): string;
3
5
  export declare function uiNodeLabel(node: UiViewNode): string;
4
6
  export declare function uiChildrenOf<Name extends UiComponentName>(node: UiViewNode, type: Name): UiViewNode<UiProps<Name>>[];
@@ -7,3 +9,7 @@ export declare function uiBlockHead(nodes: readonly UiViewNode[]): {
7
9
  label: string;
8
10
  } | null;
9
11
  export declare function revealUiSource(root: ParentNode, nodeId: string, ancestors?: readonly string[]): Promise<boolean>;
12
+ export declare function uiReasonText(t: TFunction<'agent-chat'>, code: string | undefined, reason: string | undefined): string | undefined;
13
+ export declare function uiWithUnit(text: string, unit: string | undefined): string;
14
+ export declare function uiLiveChoiceReason(t: TFunction<'agent-chat'>, live: UiLiveStatus | null | undefined): string | null;
15
+ export declare function uiUrlDomain(url: string): string;
@@ -50,3 +50,57 @@ export async function revealUiSource(root, nodeId, ancestors = []) {
50
50
  row.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
51
51
  return true;
52
52
  }
53
+ /* The reason codes this version says in its own words; any other code reads as the reason the machine gave. */
54
+ const UI_REASON_CODES = new Set([
55
+ 'access-unavailable',
56
+ 'block-stale',
57
+ 'busy',
58
+ 'link-unchecked',
59
+ 'link-unsupported',
60
+ 'links-unsupported',
61
+ 'no-unique-match',
62
+ 'outside-project',
63
+ 'query-undeclared',
64
+ 'refresh-limit',
65
+ 'result-too-large',
66
+ 'snapshot-too-large',
67
+ 'source-unregistered',
68
+ 'stale-read',
69
+ 'target-unavailable',
70
+ 'timed-out',
71
+ 'unreadable'
72
+ ]);
73
+ /* Why a reading failed or a link stayed plain, in the person's language where the code is known. */
74
+ export function uiReasonText(t, code, reason) {
75
+ return code !== undefined && UI_REASON_CODES.has(code) ? t(`blocks.live.reasons.${code}`) : reason;
76
+ }
77
+ /*
78
+ * A number with its unit, where a percent sign hugs the number and every other unit stands apart
79
+ * from it, by a space that never lets the two wrap apart.
80
+ */
81
+ export function uiWithUnit(text, unit) {
82
+ if (unit === undefined || unit === '') {
83
+ return text;
84
+ }
85
+ return unit === '%' ? `${text}${unit}` : `${text}\u00a0${unit}`;
86
+ }
87
+ /* Why the choices of a live block are closed: its data is still being read, or the reason a reading failed. Null while nothing live holds them. */
88
+ export function uiLiveChoiceReason(t, live) {
89
+ if (live === null || live === undefined || live.state === 'fresh') {
90
+ return null;
91
+ }
92
+ if (live.state === 'reading') {
93
+ return t('blocks.choice.reading');
94
+ }
95
+ const source = live.source ?? live.sources[0] ?? '';
96
+ return uiReasonText(t, live.code, live.reason) ?? t(live.state === 'failed' ? 'blocks.live.failed' : 'blocks.live.refused', { source });
97
+ }
98
+ /* The host of an address without its `www.`, which is how a source or a link in prose says where it goes. */
99
+ export function uiUrlDomain(url) {
100
+ try {
101
+ return new URL(url).hostname.replace(/^www\./, '');
102
+ }
103
+ catch {
104
+ return url;
105
+ }
106
+ }
@@ -2,7 +2,7 @@ import { ChartRenderer } from './renderers/chart.js';
2
2
  import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices.js';
3
3
  import { CodeBlockRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content.js';
4
4
  import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data.js';
5
- import { ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs.js';
5
+ import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs.js';
6
6
  import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links.js';
7
7
  import { SectionRenderer, SectionsRenderer, TabRenderer, TabsRenderer } from './renderers/structure.js';
8
8
  import { CalloutRenderer, ProgressRenderer, StepRenderer, StepsRenderer, SummaryRenderer, TagRenderer } from './renderers/text.js';
@@ -43,6 +43,7 @@ export const UI_RENDERERS = {
43
43
  Slider: SliderRenderer,
44
44
  Segmented: SegmentedRenderer,
45
45
  Option: null,
46
+ Button: ButtonRenderer,
46
47
  Show: null,
47
48
  Each: null,
48
49
  Choices: ChoicesRenderer,
@@ -6,6 +6,7 @@ export interface UiLink {
6
6
  state: 'chip' | 'plain';
7
7
  label?: string;
8
8
  reason?: string;
9
+ code?: string;
9
10
  }
10
11
  export interface UiAnswer {
11
12
  choiceId: string;
@@ -17,6 +18,7 @@ export interface UiLiveStatus {
17
18
  readAt: number | null;
18
19
  source?: string;
19
20
  reason?: string;
21
+ code?: string;
20
22
  }
21
23
  export interface UiLiveValue {
22
24
  previous?: unknown;
@@ -36,6 +38,7 @@ export interface UiRenderContext {
36
38
  openLink?(target: UiLinkTarget): void;
37
39
  openUrl?(url: string): void;
38
40
  onChoose?(nodeId: string): void;
41
+ choiceReason?: string | null;
39
42
  liveValue?(nodeId: string, prop: string, path?: readonly [row: number, key: string]): UiLiveValue | undefined;
40
43
  }
41
44
  export interface UiRendererProps<Props> {
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
5
5
  import { SegmentBar, useMeasuredWidth } from '@adecore/ui';
6
6
  import { formatRounded } from '@adecore/ui/format';
7
7
  import { niceCeiling, UI_CHART_ROWS, uiChartData } from '../chart-data.js';
8
+ import { uiWithUnit } from '../node-text.js';
8
9
  import { UiFallbackPart } from '../UiFallbackPart.js';
9
10
  const HEIGHT = 160;
10
11
  const HBAR_ROW = 24;
@@ -21,11 +22,7 @@ function seriesColor(index) {
21
22
  return `var(--chart-${index + 1})`;
22
23
  }
23
24
  function valueText(value, unit) {
24
- if (value === null) {
25
- return '';
26
- }
27
- const text = formatRounded(value, 2);
28
- return unit === undefined || unit === '' ? text : unit === '%' ? `${text}%` : `${text} ${unit}`;
25
+ return value === null ? '' : uiWithUnit(formatRounded(value, 2), unit);
29
26
  }
30
27
  /* The height a chart will take, which its place holds while the node streams. */
31
28
  function chartHeight(kind, rows) {
@@ -87,11 +84,12 @@ function VerticalChart({ chart, kind, unit, width }) {
87
84
  }, [])
88
85
  .join(' ');
89
86
  return (_jsxs(_Fragment, { children: [_jsxs("svg", { width: width, height: HEIGHT, "aria-hidden": true, className: "block overflow-visible", onPointerLeave: () => setHover(null), children: [hover !== null && (_jsx("rect", { x: Math.round(AXIS_WIDTH + band * hover), y: PLOT_TOP, width: Math.round(band), height: plotHeight, className: "fill-surface-hover" })), ticks.map((tick) => (_jsxs("g", { children: [_jsx("line", { x1: AXIS_WIDTH, x2: width, y1: y(tick), y2: y(tick), className: tick === 0 ? 'stroke-border' : 'stroke-border-soft', shapeRendering: "crispEdges" }), _jsx("text", { x: AXIS_WIDTH - 6, y: y(tick), dy: "0.32em", textAnchor: "end", className: "fill-text-faint text-xs tabular-nums", children: valueText(tick, unit) })] }, tick))), chart.labels.map((label, index) => index % labelEvery === 0 ? (_jsx("text", { x: center(index), y: HEIGHT - 4, textAnchor: "middle", className: "fill-text-faint text-xs", children: label }, index)) : null), kind === 'bar' ? (_jsx("g", { className: clsx(GROW, 'starting:scale-y-0'), style: { transformOrigin: `0px ${baseline}px` }, children: chart.series.map((series, at) => series.values.map((value, index) => value === null ? null : (_jsx("rect", { x: Math.round(AXIS_WIDTH + band * index + gap / 2 + barWidth * at), y: Math.min(y(value), baseline), width: Math.max(1, Math.round(barWidth) - 1), height: Math.abs(y(value) - baseline), rx: 2, style: { fill: seriesColor(at) } }, `${series.key}:${index}`)))) })) : (_jsx("g", { className: clsx(GROW, 'starting:opacity-0'), children: chart.series.map((series, at) => (_jsxs("g", { style: { color: seriesColor(at) }, children: [_jsx("path", { d: segments(series.values), fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }), chart.labels.length <= DOTS_UP_TO &&
90
- series.values.map((value, index) => value === null ? null : _jsx("circle", { cx: center(index), cy: y(value), r: 2.5, fill: "currentColor" }, index))] }, series.key))) })), chart.labels.map((_, index) => (_jsx("rect", { x: Math.round(AXIS_WIDTH + band * index), y: 0, width: Math.max(1, Math.round(band)), height: HEIGHT, fill: "transparent", onPointerEnter: () => setHover(index) }, index)))] }), hover !== null && _jsx(Readout, { chart: chart, index: hover, unit: unit, left: Math.min(Math.max(center(hover), 64), Math.max(64, width - 64)) })] }));
87
+ series.values.map((value, index) => value === null ? null : _jsx("circle", { cx: center(index), cy: y(value), r: 3, fill: "currentColor" }, index))] }, series.key))) })), chart.labels.map((_, index) => (_jsx("rect", { x: Math.round(AXIS_WIDTH + band * index), y: 0, width: Math.max(1, Math.round(band)), height: HEIGHT, fill: "transparent", onPointerEnter: () => setHover(index) }, index)))] }), hover !== null && _jsx(Readout, { chart: chart, index: hover, unit: unit, left: Math.min(Math.max(center(hover), 64), Math.max(64, width - 64)) })] }));
91
88
  }
92
89
  /* A row of 24px per category, its label beside the bars. */
93
90
  function HorizontalChart({ chart, unit }) {
94
- const high = niceCeiling(chart.max);
91
+ // Bars grow right from zero only, so a chart of only negative values has no length to share and keeps a unit scale.
92
+ const high = niceCeiling(chart.max) || 1;
95
93
  const single = chart.series.length === 1;
96
94
  return (_jsx("div", { "aria-hidden": true, className: "flex flex-col", children: chart.labels.map((label, index) => (_jsxs("div", { className: "flex h-6 items-center gap-2 text-xs", children: [_jsx("span", { className: "w-2/5 max-w-36 shrink-0 truncate text-text-muted", children: label }), _jsx("span", { className: "flex min-w-0 grow flex-col justify-center gap-px", children: chart.series.map((series, at) => (_jsx("span", { className: clsx(GROW, 'block origin-left rounded-sm starting:scale-x-0', single ? 'h-3' : 'h-1'), style: { width: `${(Math.max(0, series.values[index] ?? 0) / high) * 100}%`, backgroundColor: seriesColor(at) } }, series.key))) }), single && (_jsx("span", { className: "w-16 shrink-0 text-right text-text-faint tabular-nums", children: valueText(chart.series[0].values[index] ?? null, unit) }))] }, index))) }));
97
95
  }
@@ -30,12 +30,16 @@ export function ChoiceRenderer({ node, children, context }) {
30
30
  const failed = answer === null && context.failedChoiceId === node.id;
31
31
  const closed = waiting || unavailable || answer !== null;
32
32
  const slow = useLater(chosen && answer.state === 'sending', SPINNER_AFTER_MS);
33
- const reason = waiting ? t('blocks.choice.waiting') : answer === null && unavailable ? t('blocks.choice.unavailable') : null;
33
+ const reason = waiting
34
+ ? t('blocks.choice.waiting')
35
+ : answer === null && unavailable
36
+ ? ((disabled === true ? null : context.choiceReason) ?? t('blocks.choice.unavailable'))
37
+ : null;
34
38
  const described = detail !== undefined && detail !== '';
35
39
  const row = (_jsxs("button", { type: "button", "aria-disabled": closed || undefined, "aria-describedby": described ? descriptionId : undefined, className: clsx('flex w-full items-start gap-2 rounded-md border px-2 py-1.5 text-left transition-[opacity,scale,background-color] duration-150 motion-reduce:transition-none', chosen ? 'border-accent bg-accent-soft' : failed ? 'border-status-error bg-surface-hover' : 'border-border bg-surface-hover', !closed && 'hover:bg-surface-active active:scale-99', waiting ? 'opacity-60' : closed && !chosen && 'opacity-50'), onClick: () => {
36
40
  if (!closed) {
37
41
  onChoose?.(node.id);
38
42
  }
39
43
  }, children: [_jsx(Icon, { icon: ArrowRight, size: 14, className: clsx('mt-1 shrink-0', primary === true ? 'text-accent' : 'text-text-faint') }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsx("span", { className: "text-sm font-semibold text-text", children: children }), described && (_jsx("span", { id: descriptionId, className: "line-clamp-2 text-xs text-text-muted", children: detail }))] }), chosen && (_jsxs("span", { className: "mt-0.5 flex shrink-0 items-center gap-1 text-xs text-accent", children: [slow ? _jsx(Spinner, { size: 12 }) : _jsx(Icon, { icon: Check, size: 12 }), answer.state === 'queued' ? t('blocks.choice.queued') : t('blocks.choice.sent')] }))] }));
40
- return (_jsxs("div", { className: clsx('flex flex-col gap-1', primary === true && 'order-first'), children: [reason !== null ? (_jsx(DisabledReason, { reason: reason, children: row })) : described && detail.length > LONG_CONTEXT ? (_jsx(Tooltip, { label: detail, children: row })) : (row), failed && (_jsx("p", { role: "alert", className: "px-2 text-xs text-status-error", children: t('blocks.choice.failed') }))] }));
44
+ return (_jsxs("div", { className: "flex flex-col gap-1", children: [reason !== null ? (_jsx(DisabledReason, { reason: reason, children: row })) : described && detail.length > LONG_CONTEXT ? (_jsx(Tooltip, { label: detail, children: row })) : (row), failed && (_jsx("p", { role: "alert", className: "px-2 text-xs text-status-error", children: t('blocks.choice.failed') }))] }));
41
45
  }
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
- import { createContext, useContext } from 'react';
3
+ import { createContext, useContext, useId } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { PreviewCard } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
@@ -8,7 +8,7 @@ import { chatHost } from '../../../../host.js';
8
8
  import { CodeBlock } from '../../CodeBlock.js';
9
9
  import { CodeStreamingContext } from '../../code-streaming.js';
10
10
  import { ImageThumb } from '../../ImageView.js';
11
- import { uiChildrenOf, uiNodeLabel, uiNodeText } from '../node-text.js';
11
+ import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text.js';
12
12
  // The number of every source by its node id, which the list counts in its own order.
13
13
  const SourceNumbers = createContext(new Map());
14
14
  /* Plain text in the same measure until the node closes, so highlighting never makes it jump. */
@@ -33,16 +33,9 @@ export function ImageRenderer({ node, children, context }) {
33
33
  }
34
34
  export function SourcesRenderer({ node, children }) {
35
35
  const { t } = useTranslation('agent-chat');
36
+ const headingId = useId();
36
37
  const numbers = new Map(uiChildrenOf(node, 'Source').map((source, index) => [source.id, index + 1]));
37
- return (_jsx(SourceNumbers, { value: numbers, children: _jsxs("section", { "aria-label": t('blocks.sources'), className: "flex flex-col", children: [_jsx("span", { className: "px-2 pb-1 text-xs font-medium text-text-muted", children: t('blocks.sources') }), _jsx("ol", { className: "chat-ui-nodes flex flex-col", children: children })] }) }));
38
- }
39
- function domainOf(url) {
40
- try {
41
- return new URL(url).hostname.replace(/^www\./, '');
42
- }
43
- catch {
44
- return url;
45
- }
38
+ return (_jsx(SourceNumbers, { value: numbers, children: _jsxs("section", { "aria-labelledby": headingId, className: "flex flex-col", children: [_jsx("span", { id: headingId, className: "px-2 pb-1 text-xs font-medium text-text-muted", children: t('blocks.sources') }), _jsx("ol", { className: "chat-ui-nodes flex flex-col", children: children })] }) }));
46
39
  }
47
40
  /*
48
41
  * A source number in running text. Resting on it or focusing it shows the title and the domain, and
@@ -51,7 +44,7 @@ function domainOf(url) {
51
44
  */
52
45
  export function UiSourceCitation({ number, title, url, onReveal, className }) {
53
46
  const { t } = useTranslation('agent-chat');
54
- return (_jsxs(PreviewCard.Root, { children: [_jsx(PreviewCard.Trigger, { delay: 300, closeDelay: 150, render: onReveal === undefined ? _jsx("span", {}) : _jsx("button", { type: "button" }), "aria-label": onReveal === undefined ? undefined : t('blocks.citation', { number: formatNumber(number), title }), className: clsx('inline-flex h-4 min-w-4.5 items-center justify-center rounded-sm bg-text/7 px-1 align-[1px] text-2xs leading-4 text-text-muted tabular-nums', onReveal !== undefined && 'cursor-pointer hover:bg-text/12 hover:text-text', className), onClick: onReveal, children: formatNumber(number) }), _jsxs(PreviewCard.Popup, { side: "top", align: "center", sideOffset: 4, className: "flex max-w-72 flex-col px-2.5 py-1.5 text-xs", children: [_jsx("span", { className: "line-clamp-2 text-text", children: title }), _jsx("span", { className: "truncate text-text-faint", children: domainOf(url) })] })] }));
47
+ return (_jsxs(PreviewCard.Root, { children: [_jsx(PreviewCard.Trigger, { delay: 300, closeDelay: 150, render: onReveal === undefined ? _jsx("span", {}) : _jsx("button", { type: "button" }), "aria-label": onReveal === undefined ? undefined : t('blocks.citation', { number: formatNumber(number), title }), className: clsx('inline-flex h-4 min-w-4.5 items-center justify-center rounded-sm bg-text/7 px-1 align-[1px] text-2xs leading-4 text-text-muted tabular-nums', onReveal !== undefined && 'cursor-pointer hover:bg-text/12 hover:text-text', className), onClick: onReveal, children: formatNumber(number) }), _jsxs(PreviewCard.Popup, { side: "top", align: "center", sideOffset: 4, className: "flex max-w-72 flex-col px-2.5 py-1.5 text-xs", children: [_jsx("span", { className: "line-clamp-2 text-text", children: title }), _jsx("span", { className: "truncate text-text-faint", children: uiUrlDomain(url) })] })] }));
55
48
  }
56
49
  /*
57
50
  * Opens through the host and loads nothing before, not even an icon. The row itself takes focus
@@ -61,6 +54,6 @@ export function SourceRenderer({ node, context }) {
61
54
  const number = useContext(SourceNumbers).get(node.id);
62
55
  const { title, url } = node.props;
63
56
  const { openUrl } = context;
64
- const face = (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-5 shrink-0 text-right text-text-faint tabular-nums", children: number }), _jsx("span", { className: "min-w-0 truncate text-text", children: title }), _jsx("span", { className: "shrink-0 text-text-faint", children: domainOf(url) })] }));
57
+ const face = (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-5 shrink-0 text-right text-text-faint tabular-nums", children: number }), _jsx("span", { className: "min-w-0 truncate text-text", children: title }), _jsx("span", { className: "shrink-0 text-text-faint", children: uiUrlDomain(url) })] }));
65
58
  return (_jsx("li", { "data-ui-source": node.id, tabIndex: -1, className: "chat-ui-source scroll-my-2 rounded-md", children: openUrl === undefined ? (_jsx("span", { className: "flex h-8 items-center gap-2 px-2 text-xs", children: face })) : (_jsx("button", { type: "button", className: "flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-surface-hover", onClick: () => openUrl(url), children: face })) }));
66
59
  }
@@ -5,20 +5,12 @@ import { ArrowDown, ArrowRight, ArrowUp } from 'lucide-react';
5
5
  import { Button, Icon, KeyValueList, Pill } from '@adecore/ui';
6
6
  import { formatBytes, formatDuration, formatMoment, formatNumber, formatPercent, formatRounded } from '@adecore/ui/format';
7
7
  import { useBlockLocal } from '../block-local.js';
8
- import { uiChildrenOf } from '../node-text.js';
8
+ import { uiChildrenOf, uiWithUnit } from '../node-text.js';
9
9
  import { isNumericColumn, UI_TABLE_ROWS, uiTableCell, uiTableColumns } from '../table-data.js';
10
10
  import { UI_TONE_TEXT } from '../tones.js';
11
11
  import { UiFallbackPart } from '../UiFallbackPart.js';
12
- import { UiLinkChip } from './links.js';
13
12
  /* EntityList draws a hairline between its rows from this many on. */
14
13
  const DIVIDED_FROM = 7;
15
- /* A number with its unit, where a percent sign hugs the number and every other unit stands apart from it. */
16
- function withUnit(text, unit) {
17
- if (unit === undefined || unit === '') {
18
- return text;
19
- }
20
- return unit === '%' ? `${text}${unit}` : `${text} ${unit}`;
21
- }
22
14
  /*
23
15
  * A number that live readings keep up to date. Keyed by its text, so a value that changed mounts
24
16
  * again and lights up once, even when it changes again before the last light settled.
@@ -37,10 +29,10 @@ export function StatRenderer({ node, context }) {
37
29
  const live = context.liveValue?.(node.id, 'value');
38
30
  const change = previous === undefined ? null : value - previous;
39
31
  const arrow = change === null || change === 0 ? ArrowRight : change > 0 ? ArrowUp : ArrowDown;
40
- const from = previous === undefined ? '' : withUnit(formatRounded(previous, 2), unit);
32
+ const from = previous === undefined ? '' : uiWithUnit(formatRounded(previous, 2), unit);
41
33
  // A change from zero has no percentage.
42
34
  const percent = change === null || previous === undefined || previous === 0 ? null : formatPercent(Math.abs((change / previous) * 100));
43
- return (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5", children: [_jsx("span", { className: "truncate text-xs text-text-muted", children: label }), _jsx(LiveFigure, { text: withUnit(formatRounded(value, 2), unit), previous: typeof live?.previous === 'number' ? withUnit(formatRounded(live.previous, 2), unit) : undefined, live: live, className: "text-lg font-semibold tabular-nums" }), change !== null && (_jsxs("span", { className: clsx('flex items-center gap-1 text-xs tabular-nums', tone === undefined ? 'text-text-faint' : UI_TONE_TEXT[tone]), children: [_jsx(Icon, { icon: arrow, size: 12, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from }) })] }))] }));
35
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5", children: [_jsx("span", { className: "truncate text-xs text-text-muted", children: label }), _jsx(LiveFigure, { text: uiWithUnit(formatRounded(value, 2), unit), previous: typeof live?.previous === 'number' ? uiWithUnit(formatRounded(live.previous, 2), unit) : undefined, live: live, className: "text-lg font-semibold tabular-nums" }), change !== null && (_jsxs("span", { className: clsx('flex items-center gap-1 text-xs tabular-nums', tone === undefined ? 'text-text-faint' : UI_TONE_TEXT[tone]), children: [_jsx(Icon, { icon: arrow, size: 12, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from }) })] }))] }));
44
36
  }
45
37
  export function EntityListRenderer({ node, children }) {
46
38
  return (_jsx(KeyValueList.Root, { divided: uiChildrenOf(node, 'Entry').length >= DIVIDED_FROM, className: "chat-ui-nodes px-2", children: children }));
@@ -49,9 +41,9 @@ export function EntryRenderer({ node, children }) {
49
41
  return (_jsxs(KeyValueList.Item, { children: [_jsx(KeyValueList.Name, { children: node.props.label }), _jsx(KeyValueList.Value, { children: children })] }));
50
42
  }
51
43
  function numberText(cell, unit) {
52
- return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : withUnit(formatRounded(cell.value, 2), unit);
44
+ return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : uiWithUnit(formatRounded(cell.value, 2), unit);
53
45
  }
54
- function Cell({ value, column, live, context }) {
46
+ function Cell({ value, column, live }) {
55
47
  const cell = uiTableCell(value, column);
56
48
  switch (cell.kind) {
57
49
  case 'empty':
@@ -65,7 +57,8 @@ function Cell({ value, column, live, context }) {
65
57
  case 'date':
66
58
  return _jsx("time", { dateTime: new Date(cell.at).toISOString(), children: formatMoment(cell.at) });
67
59
  case 'file':
68
- return _jsx(UiLinkChip, { target: { type: 'File', path: cell.path }, context: context });
60
+ // A cell is no link target the host checked, so its path stays text.
61
+ return _jsx("span", { className: "font-mono text-code break-all", children: cell.path });
69
62
  case 'tag':
70
63
  return (_jsx(Pill, { shape: "tag", size: "sm", children: cell.text }));
71
64
  }
@@ -83,5 +76,5 @@ export function TableRenderer({ node, context }) {
83
76
  }
84
77
  const { rows } = node.props;
85
78
  const shown = all ? rows : rows.slice(0, UI_TABLE_ROWS);
86
- return (_jsxs("div", { className: "flex flex-col items-start gap-1.5", children: [_jsx("div", { className: "max-h-80 w-full overflow-auto rounded-md border border-border-soft", children: _jsxs("table", { className: "w-full border-collapse text-xs tabular-nums", children: [_jsx("thead", { className: "sticky top-0 z-1 bg-surface-hover", children: _jsx("tr", { children: columns.map((column) => (_jsx("th", { scope: "col", className: clsx('h-8 px-2 font-medium whitespace-nowrap text-text-muted', isNumericColumn(column) ? 'text-right' : 'text-left'), children: column.title }, column.key))) }) }), _jsx("tbody", { children: shown.map((row, index) => (_jsx("tr", { className: "border-t border-border-soft", children: columns.map((column) => (_jsx("td", { className: clsx('h-8 px-2 text-text', isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left'), children: _jsx(Cell, { value: row[column.key], column: column, live: isNumericColumn(column) ? context.liveValue?.(node.id, 'rows', [index, column.key]) : undefined, context: context }) }, column.key))) }, index))) })] }) }), !all && rows.length > UI_TABLE_ROWS && (_jsx(Button, { size: "xs", variant: "secondary", onClick: () => setAll(true), children: t('blocks.table.showAll', { total: formatNumber(rows.length) }) }))] }));
79
+ return (_jsxs("div", { className: "flex flex-col items-start gap-1.5", children: [_jsx("div", { className: "max-h-80 w-full overflow-auto rounded-md border border-border-soft", children: _jsxs("table", { "aria-label": t('blocks.table.label', { columns: columns.map((column) => column.title).join(', ') }), className: "w-full border-collapse text-xs tabular-nums", children: [_jsx("thead", { className: "sticky top-0 z-1 bg-surface-hover", children: _jsx("tr", { children: columns.map((column) => (_jsx("th", { scope: "col", className: clsx('h-8 px-2 font-medium whitespace-nowrap text-text-muted', isNumericColumn(column) ? 'text-right' : 'text-left'), children: column.title }, column.key))) }) }), _jsx("tbody", { children: shown.map((row, index) => (_jsx("tr", { className: "border-t border-border-soft", children: columns.map((column) => (_jsx("td", { className: clsx('h-8 px-2 text-text', isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left'), children: _jsx(Cell, { value: row[column.key], column: column, live: isNumericColumn(column) ? context.liveValue?.(node.id, 'rows', [index, column.key]) : undefined }) }, column.key))) }, index))) })] }) }), !all && rows.length > UI_TABLE_ROWS && (_jsx(Button, { size: "xs", variant: "secondary", onClick: () => setAll(true), children: t('blocks.table.showAll', { total: formatNumber(rows.length) }) }))] }));
87
80
  }