@adecore/agents-react 0.27.5 → 0.29.0

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 (43) hide show
  1. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +19 -9
  2. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +4 -2
  3. package/dist/chat/ui/intelligent-ui/UiReply.js +3 -3
  4. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  5. package/dist/chat/ui/intelligent-ui/renderers/chart.js +2 -2
  6. package/dist/chat/ui/intelligent-ui/renderers/choices.js +13 -8
  7. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +1 -0
  8. package/dist/chat/ui/intelligent-ui/renderers/content.js +38 -11
  9. package/dist/chat/ui/intelligent-ui/renderers/data.js +19 -6
  10. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +10 -9
  11. package/dist/chat/ui/intelligent-ui/renderers/links.js +5 -3
  12. package/dist/chat/ui/intelligent-ui/renderers/map.js +7 -7
  13. package/dist/chat/ui/intelligent-ui/renderers/structure.js +5 -5
  14. package/dist/chat/ui/intelligent-ui/renderers/text.js +25 -14
  15. package/dist/chat/ui/intelligent-ui/tones.d.ts +2 -0
  16. package/dist/chat/ui/intelligent-ui/tones.js +16 -0
  17. package/dist/host.d.ts +10 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/locales/en/agent-chat.d.ts +5 -0
  20. package/dist/locales/en/agent-chat.js +5 -0
  21. package/dist/locales/nl/agent-chat.d.ts +5 -0
  22. package/dist/locales/nl/agent-chat.js +5 -0
  23. package/dist/theme.css +38 -0
  24. package/package.json +8 -8
  25. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +26 -13
  26. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +7 -4
  27. package/src/chat/ui/intelligent-ui/UiReply.tsx +3 -3
  28. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  29. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +2 -2
  30. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +33 -19
  31. package/src/chat/ui/intelligent-ui/renderers/content.tsx +82 -19
  32. package/src/chat/ui/intelligent-ui/renderers/data.tsx +61 -19
  33. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +21 -14
  34. package/src/chat/ui/intelligent-ui/renderers/links.tsx +7 -4
  35. package/src/chat/ui/intelligent-ui/renderers/map.tsx +15 -12
  36. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +6 -6
  37. package/src/chat/ui/intelligent-ui/renderers/text.tsx +63 -42
  38. package/src/chat/ui/intelligent-ui/tones.ts +18 -0
  39. package/src/host.ts +19 -0
  40. package/src/index.ts +1 -0
  41. package/src/locales/en/agent-chat.ts +5 -0
  42. package/src/locales/nl/agent-chat.ts +5 -0
  43. package/src/theme.css +38 -0
@@ -3,7 +3,8 @@ import { useId, useLayoutEffect, useRef, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { Info, Lock, Radio, TriangleAlert } from 'lucide-react';
6
- import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
6
+ import { Icon, KEY_SHORTCUTS, matchesShortcut, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
7
+ import { useChatHost } from '../../../host.js';
7
8
  import { formatAgo, formatMoment } from '@adecore/ui/format';
8
9
  import { uiBlockHead, uiReasonText } from './node-text.js';
9
10
  import { UiHeadContext } from './render-context.js';
@@ -24,7 +25,7 @@ function LivePart({ live }) {
24
25
  return fresh ? t('blocks.live.read', { time }) : time;
25
26
  });
26
27
  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 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-positive" }));
28
29
  const words = live.state === 'failed'
29
30
  ? t('blocks.live.failed', { source })
30
31
  : live.state === 'refused'
@@ -35,10 +36,7 @@ function LivePart({ live }) {
35
36
  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
37
  return hint === null ? body : _jsx(Tooltip, { label: hint, children: body });
37
38
  }
38
- /*
39
- * The line under a block, only when it has something to say: live, answered, noted or drawn as
40
- * text, in that order. A hairline of the soft border runs the full width of the card over it.
41
- */
39
+ /* The line under a block, only when it has something to say: live, answered, noted or drawn as text, in that order. */
42
40
  function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }) {
43
41
  const { t } = useTranslation('agent-chat');
44
42
  const [fixesOpen, setFixesOpen] = useState(false);
@@ -46,7 +44,7 @@ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAs
46
44
  if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
47
45
  return null;
48
46
  }
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: Info, size: 12 }), t('blocks.notes', { 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))) }))] }));
47
+ return (_jsxs("div", { className: "flex flex-col gap-1.5 text-2xs 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: Info, size: 12 }), t('blocks.notes', { 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
48
  }
51
49
  const RELEASE_MS = 200;
52
50
  /*
@@ -89,15 +87,27 @@ function useRisingFloor(active) {
89
87
  return [ref, floor];
90
88
  }
91
89
  /*
92
- * The card of one block, with its footer. Named by its Summary and busy while it streams, but never
90
+ * The band of one block, with its footer. Named by its Summary and busy while it streams, but never
93
91
  * live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
94
92
  * and for a person who asked for less motion it only fades; a node inside another only fades
95
93
  * (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
96
94
  */
97
95
  export function UiBlockFrame({ nodes, phase, children, className, ...footer }) {
98
96
  const { t } = useTranslation('agent-chat');
97
+ const host = useChatHost();
99
98
  const streaming = phase === 'streaming';
100
99
  const head = uiBlockHead(nodes);
101
100
  const [ref, floor] = useRisingFloor(streaming);
102
- return (_jsx(UiHeadContext, { value: head?.id ?? null, children: _jsxs("div", { ref: ref, role: "group", "aria-label": head === null || head.label === '' ? t('blocks.label') : head.label, "aria-busy": streaming || undefined, className: clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className), style: streaming && floor > 0 ? { minHeight: floor } : undefined, children: [_jsx("div", { className: "flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5", children: nodes.length === 0 && streaming ? _jsx(Skeleton, { className: "mx-2 my-1 w-2/5" }) : children }), _jsx(UiBlockFooter, { ...footer })] }) }));
101
+ // Only while the focus is inside the block, so the composer keeps the same keys for sending.
102
+ const pickPrimary = (event) => {
103
+ if (!matchesShortcut(KEY_SHORTCUTS.modEnter, event, host.isApplePlatform())) {
104
+ return;
105
+ }
106
+ const primary = event.currentTarget.querySelector('[data-ui-primary-choice]');
107
+ if (primary !== null) {
108
+ event.preventDefault();
109
+ primary.click();
110
+ }
111
+ };
112
+ return (_jsx(UiHeadContext, { value: head?.id ?? null, children: _jsx("div", { ref: ref, role: "group", "aria-label": head === null || head.label === '' ? t('blocks.label') : head.label, "aria-busy": streaming || undefined, className: clsx('chat-ui-band flex min-h-11 flex-col', className), style: streaming && floor > 0 ? { minHeight: floor } : undefined, onKeyDown: pickPrimary, children: _jsxs("div", { className: "flex flex-col gap-3 py-5", children: [_jsx("div", { className: "flex flex-col gap-3.5 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5", children: nodes.length === 0 && streaming ? _jsx(Skeleton, { className: "my-1 w-2/5" }) : children }), _jsx(UiBlockFooter, { ...footer })] }) }) }));
103
113
  }
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useTranslation } from 'react-i18next';
3
+ import { Info } from 'lucide-react';
4
+ import { Icon } from '@adecore/ui';
3
5
  import { Markdown } from '../Markdown.js';
4
6
  /*
5
- * One part of a block as the markdown it stands for, in a quiet surface with a line that says why.
7
+ * One part of a block as the markdown it stands for, on a card in a quiet tile under a line that says why.
6
8
  * A person sees it as a repair, never as an error, so nothing here is red.
7
9
  */
8
10
  export function UiFallbackPart({ fallback, problem }) {
9
11
  const { t } = useTranslation('agent-chat');
10
- return (_jsxs("div", { className: "flex flex-col gap-1 rounded-md bg-surface-hover p-2", children: [_jsx("span", { className: "text-xs text-text-faint", children: problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed') }), fallback !== '' && (_jsx("div", { className: "min-w-0 wrap-anywhere", children: _jsx(Markdown, { text: fallback, fileLinks: false }) }))] }));
12
+ return (_jsxs("div", { className: "chat-ui-flat flex flex-col gap-1.5 rounded-xl bg-ui-tile p-1.5", children: [_jsxs("span", { className: "chat-ui-flat-inner flex items-center gap-1.5 px-1.5 pt-0.5 text-2xs text-text-muted", children: [_jsx(Icon, { icon: Info, size: 12, className: "shrink-0" }), problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')] }), fallback !== '' && (_jsx("div", { className: "min-w-0 rounded-lg bg-ui-inset px-3 py-2.5 wrap-anywhere shadow-[inset_0_0_0_1px_var(--border)]", children: _jsx(Markdown, { text: fallback, fileLinks: false }) }))] }));
11
13
  }
@@ -40,8 +40,8 @@ const BLOCK = '$block';
40
40
  const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Button', 'Column']);
41
41
  /* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
42
42
  const PARAGRAPHS = {
43
- [BLOCK]: 'px-2 text-xs text-text',
44
- Tab: 'px-2 text-xs text-text',
43
+ [BLOCK]: 'text-xs text-text',
44
+ Tab: 'text-xs text-text',
45
45
  Section: 'text-xs text-text-muted'
46
46
  };
47
47
  const LINKS = new Set(['File', 'Diff', 'Commit', 'Node']);
@@ -138,7 +138,7 @@ function ProseLink({ href, children }) {
138
138
  }
139
139
  const PROSE_COMPONENTS = {
140
140
  p: ({ children }) => children,
141
- code: ({ children }) => _jsx("code", { className: "rounded-sm bg-surface-sunken px-1 py-px font-mono text-code", children: children }),
141
+ code: ({ children }) => _jsx("code", { className: "rounded-sm bg-ui-tile px-1 py-px font-mono text-code", children: children }),
142
142
  a: ({ href, children }) => _jsx(ProseLink, { href: href, children: children }),
143
143
  span: ({ node, children, className }) => node?.properties['data-ui-citation'] !== undefined ? (_jsx(ProseCitation, { number: Number(node.properties['data-ui-citation']), children: children })) : (_jsx("span", { className: className, children: children }))
144
144
  };
@@ -1,6 +1,6 @@
1
1
  import { ChartRenderer } from './renderers/chart.js';
2
2
  import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices.js';
3
- import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content.js';
3
+ import { CodeBlockRenderer, CompareRenderer, GalleryRenderer, ImageRenderer, ModelRenderer, SourceRenderer, SourcesRenderer } from './renderers/content.js';
4
4
  import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data.js';
5
5
  import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SelectRenderer, SliderRenderer, SwitchRenderer, TextFieldRenderer } from './renderers/inputs.js';
6
6
  import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links.js';
@@ -37,6 +37,7 @@ export const UI_RENDERERS = {
37
37
  Image: ImageRenderer,
38
38
  Gallery: GalleryRenderer,
39
39
  Compare: CompareRenderer,
40
+ Model: ModelRenderer,
40
41
  Map: MapRenderer,
41
42
  Place: null,
42
43
  Sources: SourcesRenderer,
@@ -168,7 +168,7 @@ function DrawnChart({ chart, requested, data, unit, context, nodeId }) {
168
168
  const [measureRows, rowsCut] = useTruncated(true);
169
169
  const values = useMemo(() => uiChartValues(data), [data]);
170
170
  const { kind } = chart;
171
- return (_jsxs("figure", { className: "flex flex-col gap-2 px-2", children: [chart.series.length > 1 && _jsx(Legend, { chart: chart }), kind !== 'stacked' && unitApart(unit) && _jsx("p", { className: "text-xs text-text-faint", children: unit }), _jsx("div", { ref: measure, role: "img", "aria-label": chartSummary(values, unit, t), className: "relative", children: kind === 'hbar' || kind === 'stacked' ? (_jsx("div", { ref: measureRows, children: kind === 'hbar' ? _jsx(HorizontalChart, { chart: chart, unit: unit }) : _jsx(StackedChart, { chart: chart, unit: unit }) })) : (_jsx("div", { style: { height: HEIGHT }, children: width > 0 && _jsx(VerticalChart, { chart: chart, kind: kind, unit: unit, width: width, character: character }) })) }), _jsx(ChartNotes, { chart: chart, requested: requested, cut: rowsCut || labelsCut(chart, unit, width, character), values: values, unit: unit, context: context, nodeId: nodeId })] }));
171
+ return (_jsxs("figure", { className: "chat-ui-flat flex flex-col gap-2.5 rounded-xl bg-ui-tile p-3.5", children: [chart.series.length > 1 && _jsx(Legend, { chart: chart }), kind !== 'stacked' && unitApart(unit) && _jsx("p", { className: "text-xs text-text-faint", children: unit }), _jsx("div", { ref: measure, role: "img", "aria-label": chartSummary(values, unit, t), className: "relative", children: kind === 'hbar' || kind === 'stacked' ? (_jsx("div", { ref: measureRows, children: kind === 'hbar' ? _jsx(HorizontalChart, { chart: chart, unit: unit }) : _jsx(StackedChart, { chart: chart, unit: unit }) })) : (_jsx("div", { style: { height: HEIGHT }, children: width > 0 && _jsx(VerticalChart, { chart: chart, kind: kind, unit: unit, width: width, character: character }) })) }), _jsx(ChartNotes, { chart: chart, requested: requested, cut: rowsCut || labelsCut(chart, unit, width, character), values: values, unit: unit, context: context, nodeId: nodeId })] }));
172
172
  }
173
173
  /*
174
174
  * Draws only once its node closed; until then a quiet surface of the height it will take holds its
@@ -178,7 +178,7 @@ export function ChartRenderer({ node, context }) {
178
178
  const { kind, data, unit } = node.props;
179
179
  const chart = useMemo(() => (node.complete ? uiChartData(data, kind) : null), [node.complete, data, kind]);
180
180
  if (!node.complete) {
181
- return _jsx("div", { "aria-hidden": true, className: "mx-2 rounded-md bg-surface-hover", style: { height: chartHeight(kind, data.length) } });
181
+ return _jsx("div", { "aria-hidden": true, className: "rounded-xl bg-ui-tile", style: { height: chartHeight(kind, data.length) } });
182
182
  }
183
183
  if (chart === null) {
184
184
  return _jsx(UiFallbackPart, { fallback: node.fallback, problem: { kind: 'failed' } });
@@ -2,9 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { ArrowRight, Check } from 'lucide-react';
5
+ import { Check } from 'lucide-react';
6
6
  import { uiChoiceLabel } from '@adecore/intelligent-ui';
7
- import { DisabledReason, Icon, Spinner, Tooltip } from '@adecore/ui';
7
+ import { DisabledReason, formatShortcut, Icon, KEY_SHORTCUTS, Spinner, Tooltip } from '@adecore/ui';
8
+ import { useChatHost } from '../../../../host.js';
8
9
  import { UiFallbackPart } from '../UiFallbackPart.js';
9
10
  import { useLater } from '../use-later.js';
10
11
  /* A context this long may run past its two lines, so the row offers all of it in a tooltip. */
@@ -14,16 +15,18 @@ const SPINNER_AFTER_MS = 300;
14
15
  export function ChoicesRenderer({ children, context }) {
15
16
  const { t } = useTranslation('agent-chat');
16
17
  const [streamed] = useState(context.phase === 'streaming');
17
- return (_jsxs("div", { role: "group", "aria-label": t('blocks.choices'), className: "chat-ui-nodes flex flex-col gap-1.5", children: [children, _jsx("span", { className: "sr-only", role: "status", children: streamed && context.phase === 'final' ? t('blocks.choice.ready') : '' })] }));
18
+ return (_jsxs("div", { role: "group", "aria-label": t('blocks.choices'), className: "chat-ui-nodes @container flex flex-wrap items-start gap-2", children: [children, _jsx("span", { className: "sr-only", role: "status", children: streamed && context.phase === 'final' ? t('blocks.choice.ready') : '' })] }));
18
19
  }
19
20
  /*
20
- * A row that shows what it sends: the label, and under it the context the agent receives. It stays
21
+ * A button that shows what it sends: the label, and under it the context the agent receives. It stays
21
22
  * closed while the reply streams, once the block is answered and without a host to send it, and
22
23
  * remains focusable then so a screen reader hears why. A finished Choice the host would refuse for
23
- * its label is no row at all, but its text.
24
+ * its label is no button at all, but its text. The primary one says the key that picks it while the
25
+ * focus is in the block (`UiBlockFrame`).
24
26
  */
25
27
  export function ChoiceRenderer({ node, children, context }) {
26
28
  const { t } = useTranslation('agent-chat');
29
+ const host = useChatHost();
27
30
  const descriptionId = useId();
28
31
  const { context: detail, primary, disabled } = node.props;
29
32
  const { answer, onChoose } = context;
@@ -42,10 +45,12 @@ export function ChoiceRenderer({ node, children, context }) {
42
45
  ? ((disabled === true ? null : context.choiceReason) ?? t('blocks.choice.unavailable'))
43
46
  : null;
44
47
  const described = detail !== undefined && detail !== '';
45
- 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: () => {
48
+ const strong = primary === true;
49
+ const button = (_jsxs("button", { type: "button", "aria-disabled": closed || undefined, "aria-describedby": described ? descriptionId : undefined, "aria-keyshortcuts": strong && !closed ? 'Meta+Enter Control+Enter' : undefined, "data-ui-primary-choice": strong && !closed ? '' : undefined, className: clsx('flex max-w-full min-h-9.5 items-center gap-2.5 rounded-xl px-4 py-2 text-left text-xs transition-[opacity,scale,background-color,filter] duration-150 motion-reduce:transition-none', strong ? 'bg-accent text-accent-text' : 'bg-ui-tile text-text', chosen && 'ring-2 ring-accent ring-offset-2 ring-offset-surface', failed && 'ring-2 ring-status-error', !closed && (strong ? 'hover:brightness-110' : 'hover:bg-surface-active'), !closed && 'active:scale-99', waiting ? 'opacity-60' : closed && !chosen && 'opacity-50'), onClick: () => {
46
50
  if (!closed) {
47
51
  onChoose?.(node.id);
48
52
  }
49
- }, 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 wrap-anywhere text-text", children: children }), described && (_jsx("span", { id: descriptionId, className: "line-clamp-2 text-xs wrap-anywhere 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')] }))] }));
50
- 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') }))] }));
53
+ }, children: [_jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: clsx('wrap-anywhere', strong ? 'font-semibold' : 'font-medium'), children: children }), described && (_jsx("span", { id: descriptionId, className: clsx('line-clamp-2 text-2xs wrap-anywhere', strong ? 'text-accent-text/80' : 'text-text-muted'), children: detail }))] }), strong && !closed && (_jsx("kbd", { "aria-hidden": true, className: "shrink-0 rounded-md bg-accent-text/18 px-1.5 font-sans text-2xs font-medium", children: formatShortcut(KEY_SHORTCUTS.modEnter, host.isApplePlatform()) })), chosen && (_jsxs("span", { className: clsx('flex shrink-0 items-center gap-1 text-2xs', strong ? 'text-accent-text' : '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')] }))] }));
54
+ // At most half the row less half the gap, so two long choices stand side by side and end on one edge; a narrow block gives each the row.
55
+ return (_jsxs("div", { className: "flex max-w-full flex-col gap-1 @min-[420px]:max-w-[calc(50%-4px)]", children: [reason !== null ? (_jsx(DisabledReason, { reason: reason, children: button })) : described && detail.length > LONG_CONTEXT ? (_jsx(Tooltip, { label: detail, children: button })) : (button), failed && (_jsx("p", { role: "alert", className: "px-1 text-2xs text-status-error", children: t('blocks.choice.failed') }))] }));
51
56
  }
@@ -4,6 +4,7 @@ export declare function CodeBlockRenderer({ node }: UiRendererProps<UiProps<'Cod
4
4
  export declare function ImageRenderer({ node, children, context }: UiRendererProps<UiProps<'Image'>>): import("react").JSX.Element;
5
5
  export declare function GalleryRenderer({ node, children }: UiRendererProps<UiProps<'Gallery'>>): import("react").JSX.Element;
6
6
  export declare function CompareRenderer({ node, children, context }: UiRendererProps<UiProps<'Compare'>>): import("react").JSX.Element;
7
+ export declare function ModelRenderer({ node, children, context }: UiRendererProps<UiProps<'Model'>>): import("react").JSX.Element;
7
8
  export declare function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>): import("react").JSX.Element;
8
9
  export interface UiSourceCitationProps {
9
10
  number: number;
@@ -2,7 +2,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import clsx from 'clsx';
3
3
  import { createContext, useContext, useId, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
5
+ import { Box } from 'lucide-react';
6
+ import { ErrorBoundary, Icon, Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
7
  import { formatNumber } from '@adecore/ui/format';
7
8
  import { useChatHost } from '../../../../host.js';
8
9
  import { CodeBlock } from '../../CodeBlock.js';
@@ -14,7 +15,8 @@ import { useTruncated } from '../use-truncated.js';
14
15
  const SourceNumbers = createContext(new Map());
15
16
  /* Plain text in the same measure until the node closes, so highlighting never makes it jump. */
16
17
  export function CodeBlockRenderer({ node }) {
17
- return (_jsx(CodeStreamingContext.Provider, { value: !node.complete, children: _jsx(CodeBlock, { code: uiNodeText(node).replace(/^\n|\n$/g, ''), lang: node.props.language ?? 'text' }) }));
18
+ const { language } = node.props;
19
+ return (_jsxs("div", { className: "chat-ui-code chat-ui-flat flex flex-col rounded-xl bg-ui-tile p-1.5", children: [language !== undefined && language !== '' && (_jsx("div", { className: "chat-ui-flat-inner flex px-1.5 pt-0.5 pb-1.5", children: _jsx("span", { className: "rounded-md bg-ui-inset px-1.5 font-mono text-2xs text-text shadow-[inset_0_0_0_1px_var(--border)]", children: language }) })), _jsx(CodeStreamingContext.Provider, { value: !node.complete, children: _jsx(CodeBlock, { code: uiNodeText(node).replace(/^\n|\n$/g, ''), lang: language ?? 'text' }) })] }));
18
20
  }
19
21
  // Set by a Gallery, so the images inside it draw as cells of its grid.
20
22
  const GalleryContext = createContext(false);
@@ -26,7 +28,7 @@ function AttachedImage({ scopeId, chatId, attachmentId, alt, cell, unnamed }) {
26
28
  const host = useChatHost();
27
29
  const source = host.attachments.useUrl(scopeId, chatId, attachmentId);
28
30
  if (!cell) {
29
- return _jsx(ImageThumb, { source: source, alt: alt, className: "max-h-70 max-w-full object-contain" });
31
+ return _jsx(ImageThumb, { source: source, alt: alt, className: "max-h-70 max-w-full rounded-xl object-contain" });
30
32
  }
31
33
  return (_jsxs(_Fragment, { children: [_jsx(ImageThumb, { source: source, alt: alt, aspect: 1 }), unnamed && source.failure !== null && _jsx("figcaption", { className: "text-xs wrap-anywhere text-text-faint", children: alt })] }));
32
34
  }
@@ -40,12 +42,12 @@ export function ImageRenderer({ node, children, context }) {
40
42
  const { attachment, alt } = node.props;
41
43
  const caption = uiNodeLabel(node);
42
44
  const name = alt ?? (caption === '' ? t('blocks.image.label') : caption);
43
- return (_jsxs("figure", { className: cell ? 'flex min-w-0 flex-col gap-1' : 'flex flex-col items-start gap-1.5', children: [attachment === undefined ? (_jsx(ImageThumb, { source: { url: null, failure: t('blocks.image.unavailable') }, alt: name, aspect: cell ? 1 : undefined })) : (_jsx(AttachedImage, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, alt: name, cell: cell, unnamed: caption === '' })), caption !== '' && _jsx("figcaption", { className: cell ? 'text-xs wrap-anywhere text-text-muted' : 'px-2 text-xs text-text-muted', children: children }), cell && caption === '' && attachment === undefined && (_jsx("figcaption", { className: "text-xs wrap-anywhere text-text-faint", children: alt ?? t('blocks.image.unavailable') }))] }));
45
+ return (_jsxs("figure", { className: cell ? 'flex min-w-0 flex-col gap-1' : 'flex flex-col items-start gap-1.5', children: [attachment === undefined ? (_jsx(ImageThumb, { source: { url: null, failure: t('blocks.image.unavailable') }, alt: name, aspect: cell ? 1 : undefined })) : (_jsx(AttachedImage, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, alt: name, cell: cell, unnamed: caption === '' })), caption !== '' && _jsx("figcaption", { className: "px-0.5 text-2xs wrap-anywhere text-text-muted", children: children }), cell && caption === '' && attachment === undefined && (_jsx("figcaption", { className: "text-xs wrap-anywhere text-text-faint", children: alt ?? t('blocks.image.unavailable') }))] }));
44
46
  }
45
47
  /* Three columns, two in a narrow block; the container decides, not the window, since a chat can be any width. */
46
48
  export function GalleryRenderer({ node, children }) {
47
49
  const { t } = useTranslation('agent-chat');
48
- return (_jsx(GalleryContext, { value: true, children: _jsx("div", { role: "group", "aria-label": t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length }), className: "@container px-2", children: _jsx("div", { className: "chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3", children: children }) }) }));
50
+ return (_jsx(GalleryContext, { value: true, children: _jsx("div", { role: "group", "aria-label": t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length }), className: "@container", children: _jsx("div", { className: "chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3", children: children }) }) }));
49
51
  }
50
52
  // The shape of the frame until the first picture reports its own.
51
53
  const COMPARE_ASPECT = 4 / 3;
@@ -75,7 +77,7 @@ export function CompareRenderer({ node, children, context }) {
75
77
  body = _jsx(ImageThumb, { source: { url: null, failure: null }, alt: label, aspect: COMPARE_ASPECT });
76
78
  }
77
79
  else if (first.url !== null && second.url !== null) {
78
- body = (_jsxs("div", { className: "relative max-h-96 overflow-hidden rounded-lg border border-border bg-surface-sunken", style: { aspectRatio: aspect, width: `min(100%, ${COMPARE_MAX_HEIGHT * aspect}px)` }, children: [_jsx(Wipe, { value: split, onValueChange: setSplit, label: label, before: _jsx("img", { src: first.url, alt: describe(beforeLabel), draggable: false, className: "size-full object-contain select-none", onLoad: (event) => {
80
+ body = (_jsxs("div", { className: "relative max-h-96 overflow-hidden rounded-xl bg-ui-tile", style: { aspectRatio: aspect, width: `min(100%, ${COMPARE_MAX_HEIGHT * aspect}px)` }, children: [_jsx(Wipe, { value: split, onValueChange: setSplit, label: label, before: _jsx("img", { src: first.url, alt: describe(beforeLabel), draggable: false, className: "size-full object-contain select-none", onLoad: (event) => {
79
81
  const { naturalWidth, naturalHeight } = event.currentTarget;
80
82
  if (naturalWidth > 0 && naturalHeight > 0) {
81
83
  setAspect(naturalWidth / naturalHeight);
@@ -87,19 +89,44 @@ export function CompareRenderer({ node, children, context }) {
87
89
  }
88
90
  else {
89
91
  const shown = first.url !== null ? { source: first, name: describe(beforeLabel) } : { source: second, name: describe(afterLabel) };
90
- body = (_jsxs(_Fragment, { children: [_jsx(ImageThumb, { source: shown.source, alt: shown.name, className: "max-h-70 max-w-full object-contain" }), _jsx("span", { className: "px-2 text-xs text-text-faint", children: t('blocks.compare.missing') })] }));
92
+ body = (_jsxs(_Fragment, { children: [_jsx(ImageThumb, { source: shown.source, alt: shown.name, className: "max-h-70 max-w-full object-contain" }), _jsx("span", { className: "text-2xs text-text-faint", children: t('blocks.compare.missing') })] }));
91
93
  }
92
- return (_jsxs("figure", { className: "flex flex-col items-start gap-1.5 px-2", children: [body, caption !== '' && _jsx("figcaption", { className: "text-xs text-text-muted", children: children })] }));
94
+ return (_jsxs("figure", { className: "flex w-full flex-col items-start gap-1.5", children: [body, caption !== '' && _jsx("figcaption", { className: "px-0.5 text-2xs text-text-muted", children: children })] }));
93
95
  }
94
96
  /* A chip on the picture gets a ground of its own, since a pill's alpha ground would vanish over an image. */
95
97
  function CompareChip({ side, label }) {
96
98
  return (_jsx("span", { className: clsx('pointer-events-none absolute top-2 z-20 max-w-2/5 truncate rounded-md bg-surface-raised', side === 'left' ? 'left-2' : 'right-2'), children: _jsx(Pill, { shape: "tag", size: "sm", tone: "muted", children: label }) }));
97
99
  }
100
+ /*
101
+ * The host draws the model, since the chat knows no 3D format. The frame has a fixed height in the reply column,
102
+ * so a view that loads late never moves the thread. Without a view of the host, or without the bytes, the model
103
+ * stays a quiet row that names it.
104
+ */
105
+ export function ModelRenderer({ node, children, context }) {
106
+ const { t } = useTranslation('agent-chat');
107
+ const host = useChatHost();
108
+ const { attachment, alt } = node.props;
109
+ const source = host.attachments.useUrl(context.scopeId, context.chatId, attachment);
110
+ const caption = uiNodeLabel(node);
111
+ const label = alt ?? (caption === '' ? t('blocks.model.label') : caption);
112
+ const View = host.ModelView;
113
+ let body;
114
+ if (View !== undefined && source.url !== null) {
115
+ body = (_jsx("div", { role: "group", "aria-label": label, className: "relative h-80 w-full overflow-hidden rounded-xl bg-ui-tile", children: _jsx(ErrorBoundary, { label: t('blocks.model.crashed'), resetKeys: [attachment, source.url], compact: true, children: _jsx(View, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, url: source.url, label: label, alt: alt, caption: caption }) }) }));
116
+ }
117
+ else if (View !== undefined && source.failure === null) {
118
+ body = _jsx("div", { "aria-busy": "true", "aria-label": label, className: "h-80 w-full rounded-xl bg-ui-tile" });
119
+ }
120
+ else {
121
+ body = (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-2 rounded-xl bg-ui-tile px-3 py-2 text-xs", children: [_jsx(Icon, { icon: Box, size: 14, className: "shrink-0 text-text-muted" }), _jsx("span", { className: "min-w-0 truncate text-text", children: alt ?? attachment }), _jsx("span", { className: "ml-auto shrink-0 text-text-faint", children: source.failure === null ? t('blocks.model.label') : t('blocks.model.unavailable') })] }));
122
+ }
123
+ return (_jsxs("figure", { className: "flex w-full flex-col items-start gap-1.5", children: [body, caption !== '' && _jsx("figcaption", { className: "px-0.5 text-2xs text-text-muted", children: children })] }));
124
+ }
98
125
  export function SourcesRenderer({ node, children }) {
99
126
  const { t } = useTranslation('agent-chat');
100
127
  const headingId = useId();
101
128
  const numbers = new Map(uiChildrenOf(node, 'Source').map((source, index) => [source.id, index + 1]));
102
- 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 })] }) }));
129
+ return (_jsx(SourceNumbers, { value: numbers, children: _jsxs("section", { "aria-labelledby": headingId, className: "chat-ui-flat flex flex-col rounded-xl bg-ui-tile p-1.5", children: [_jsx("span", { id: headingId, className: "chat-ui-flat-inner px-1.5 pt-0.5 pb-1 text-2xs font-medium text-text-muted", children: t('blocks.sources') }), _jsx("ol", { className: "chat-ui-nodes flex flex-col", children: children })] }) }));
103
130
  }
104
131
  /*
105
132
  * A source number in running text. Resting on it or focusing it shows the title and the domain, and
@@ -119,6 +146,6 @@ export function SourceRenderer({ node, context }) {
119
146
  const { title, url } = node.props;
120
147
  const { openUrl } = context;
121
148
  const [measureDomain, domainCut] = useTruncated();
122
- const face = (_jsxs(_Fragment, { children: [_jsx("span", { className: "w-5 shrink-0 self-start text-right text-text-faint tabular-nums", children: number }), _jsx("span", { className: "min-w-0 grow wrap-anywhere text-text", children: title }), _jsx("span", { ref: measureDomain, className: "max-w-1/2 min-w-0 shrink-0 self-start truncate text-text-faint", children: uiUrlDomain(url) })] }));
123
- return (_jsx("li", { "data-ui-source": node.id, tabIndex: -1, className: "chat-ui-source scroll-my-2 rounded-md", children: _jsx(Tooltip, { label: _jsxs(_Fragment, { children: [_jsx("span", { className: "block", children: title }), _jsx("span", { className: "block text-text-muted", children: url })] }), children: openUrl === undefined ? (_jsx("span", { tabIndex: domainCut ? 0 : undefined, className: "flex min-h-8 items-start gap-2 rounded-md px-2 py-2 text-xs", children: face })) : (_jsx("button", { type: "button", className: "flex min-h-8 w-full items-start gap-2 rounded-md px-2 py-2 text-left text-xs hover:bg-surface-hover", onClick: () => openUrl(url), children: face })) }) }));
149
+ const face = (_jsxs(_Fragment, { children: [_jsx("span", { className: "grid size-5 shrink-0 place-items-center rounded-md bg-ui-inset text-2xs font-semibold text-text tabular-nums", children: number }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsx("span", { className: "font-medium wrap-anywhere text-text", children: title }), _jsx("span", { ref: measureDomain, className: "truncate text-text-muted", children: uiUrlDomain(url) })] })] }));
150
+ return (_jsx("li", { "data-ui-source": node.id, tabIndex: -1, className: "chat-ui-source scroll-my-2 rounded-md", children: _jsx(Tooltip, { label: _jsxs(_Fragment, { children: [_jsx("span", { className: "block", children: title }), _jsx("span", { className: "block text-text-muted", children: url })] }), children: openUrl === undefined ? (_jsx("span", { tabIndex: domainCut ? 0 : undefined, className: "chat-ui-flat-inner flex min-h-8 items-start gap-2.5 rounded-lg px-1.5 py-1.5 text-2xs", children: face })) : (_jsx("button", { type: "button", className: "chat-ui-flat-inner flex min-h-8 w-full items-start gap-2.5 rounded-lg px-1.5 py-1.5 text-left text-2xs hover:bg-text/4", onClick: () => openUrl(url), children: face })) }) }));
124
151
  }
@@ -1,16 +1,20 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
2
3
  import clsx from 'clsx';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import { ArrowDown, ArrowRight, ArrowUp } from 'lucide-react';
5
- import { Button, Icon, KeyValueList, Pill } from '@adecore/ui';
6
+ import { Button, Icon, KeyValueList, Pill, Tooltip } from '@adecore/ui';
6
7
  import { formatBytes, formatDuration, formatMoment, formatNumber, formatPercent, formatRounded } from '@adecore/ui/format';
7
8
  import { useBlockLocal } from '../block-local.js';
8
9
  import { uiChildrenOf, uiWithUnit } from '../node-text.js';
9
10
  import { isNumericColumn, UI_TABLE_ROWS, uiTableCell, uiTableColumns } from '../table-data.js';
10
- import { UI_TONE_TEXT } from '../tones.js';
11
+ import { UI_TONE_PILL } from '../tones.js';
11
12
  import { UiFallbackPart } from '../UiFallbackPart.js';
12
13
  /* EntityList draws a hairline between its rows from this many on. */
13
14
  const DIVIDED_FROM = 7;
15
+ /* Up to this many entries an EntityList is a row of tiles; a longer one is a list in one tile. */
16
+ const TILED_UP_TO = 4;
17
+ const EntityTiles = createContext(false);
14
18
  /*
15
19
  * A number that live readings keep up to date. Keyed by its text, so a value that changed mounts
16
20
  * again and lights up once, even when it changes again before the last light settled.
@@ -22,7 +26,7 @@ function LiveFigure({ text, previous, live, className }) {
22
26
  export function StatsRenderer({ children }) {
23
27
  return _jsx("div", { className: "chat-ui-nodes grid grid-cols-[repeat(auto-fit,minmax(128px,1fr))] gap-2", children: children });
24
28
  }
25
- /* The arrow and the number say which way a value went; only a tone says whether that is good. */
29
+ /* The arrow and the number say which way a value went; only a tone says whether that is good. The tag says the change, its tooltip from what. */
26
30
  export function StatRenderer({ node, context }) {
27
31
  const { t } = useTranslation('agent-chat');
28
32
  const { label, value, previous, unit, tone } = node.props;
@@ -32,12 +36,21 @@ export function StatRenderer({ node, context }) {
32
36
  const from = previous === undefined ? '' : uiWithUnit(formatRounded(previous, 2), unit);
33
37
  // A change from zero has no percentage.
34
38
  const percent = change === null || previous === undefined || previous === 0 ? null : formatPercent(Math.abs((change / previous) * 100));
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 }) })] }))] }));
39
+ const said = percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from });
40
+ return (_jsxs("div", { className: "flex min-w-0 flex-col items-start gap-1.5 rounded-xl bg-ui-tile p-3", children: [_jsx("span", { className: "max-w-full truncate text-2xs 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: "max-w-full truncate text-lg font-semibold tabular-nums" }), change !== null && (_jsx(Tooltip, { label: said, children: _jsx("span", { tabIndex: 0, className: "max-w-full rounded-md", children: _jsxs(Pill, { shape: "tag", size: "sm", tone: tone === undefined ? 'muted' : UI_TONE_PILL[tone], icon: _jsx(Icon, { icon: arrow, size: 12, className: "shrink-0" }), children: [_jsx("span", { "aria-hidden": true, className: "tabular-nums", children: percent ?? from }), _jsx("span", { className: "sr-only", children: said })] }) }) }))] }));
36
41
  }
37
42
  export function EntityListRenderer({ node, children }) {
38
- return (_jsx(KeyValueList.Root, { divided: uiChildrenOf(node, 'Entry').length >= DIVIDED_FROM, className: "chat-ui-nodes px-2", children: children }));
43
+ const count = uiChildrenOf(node, 'Entry').length;
44
+ if (count <= TILED_UP_TO) {
45
+ return (_jsx(EntityTiles, { value: true, children: _jsx("div", { className: "chat-ui-nodes grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2", children: children }) }));
46
+ }
47
+ return (_jsx(KeyValueList.Root, { divided: count >= DIVIDED_FROM, className: "chat-ui-nodes chat-ui-flat rounded-xl bg-ui-tile px-3 py-2", children: children }));
39
48
  }
40
49
  export function EntryRenderer({ node, children }) {
50
+ const tiled = useContext(EntityTiles);
51
+ if (tiled) {
52
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 rounded-xl bg-ui-tile px-3 py-2", children: [_jsx("span", { className: "truncate text-2xs text-text-muted", children: node.props.label }), _jsx("span", { className: "text-2xs font-semibold wrap-break-word text-text", children: children })] }));
53
+ }
41
54
  return (_jsxs(KeyValueList.Item, { children: [_jsx(KeyValueList.Name, { children: node.props.label }), _jsx(KeyValueList.Value, { children: children })] }));
42
55
  }
43
56
  function numberText(cell, unit) {
@@ -76,5 +89,5 @@ export function TableRenderer({ node, context }) {
76
89
  }
77
90
  const { rows } = node.props;
78
91
  const shown = all ? rows : rows.slice(0, UI_TABLE_ROWS);
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) }) }))] }));
92
+ return (_jsxs("div", { className: "flex flex-col items-start gap-1.5", children: [_jsx("div", { className: "chat-ui-flat max-h-80 w-full overflow-auto rounded-xl bg-ui-tile px-1.5 pb-1.5", children: _jsxs("table", { "aria-label": t('blocks.table.label', { columns: columns.map((column) => column.title).join(', ') }), className: clsx('w-full border-separate border-spacing-0 text-xs tabular-nums', '[&_tbody_tr:first-child_td:first-child]:rounded-tl-lg [&_tbody_tr:first-child_td:last-child]:rounded-tr-lg', '[&_tbody_tr:last-child_td:first-child]:rounded-bl-lg [&_tbody_tr:last-child_td:last-child]:rounded-br-lg'), children: [_jsx("thead", { className: "sticky top-0 z-1 bg-ui-tile", children: _jsx("tr", { children: columns.map((column) => (_jsx("th", { scope: "col", className: clsx('h-8 px-2 text-2xs 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", { children: columns.map((column, at) => (_jsx("td", { className: clsx('h-9 bg-ui-inset px-2 text-text', index > 0 && 'border-t border-border-soft', at === 0 && 'font-medium', 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) }) }))] }));
80
93
  }
@@ -11,7 +11,7 @@ const ChecklistContext = createContext({ values: [], enabled: false, toggle: ()
11
11
  /* The rows of a checklist as one named group. */
12
12
  function ChecklistGroup({ pending, children }) {
13
13
  const { t } = useTranslation('agent-chat');
14
- return (_jsx("div", { role: "group", "aria-label": t('blocks.checklist'), className: clsx('chat-ui-nodes flex flex-col', pending && PENDING), children: children }));
14
+ return (_jsx("div", { role: "group", "aria-label": t('blocks.checklist'), className: clsx('chat-ui-nodes flex flex-col gap-1.5', pending && PENDING), children: children }));
15
15
  }
16
16
  export function ChecklistRenderer({ node, children, context }) {
17
17
  const binding = bindingOf(node, context);
@@ -21,20 +21,21 @@ export function ChecklistRenderer({ node, children, context }) {
21
21
  };
22
22
  return (_jsx(ChecklistContext, { value: { values, enabled: binding !== null, toggle }, children: _jsx(ChecklistGroup, { pending: !node.complete, children: children }) }));
23
23
  }
24
- /* The whole row is the label; a link inside it keeps its own click and checks nothing. */
24
+ /* The whole tile is the label, ringed in the accent while checked; a link inside it keeps its own click and checks nothing. */
25
25
  export function ItemRenderer({ node, children }) {
26
26
  const list = useContext(ChecklistContext);
27
27
  const { value } = node.props;
28
+ const checked = list.values.includes(value);
28
29
  const toggleRow = (event) => {
29
30
  if (list.enabled && !event.target.closest('button, a')) {
30
31
  list.toggle(value);
31
32
  }
32
33
  };
33
- return (_jsxs("div", { className: clsx('flex min-h-8 items-center gap-2 rounded-md px-2 text-sm text-text', list.enabled && 'hover:bg-surface-hover'), onClick: toggleRow, children: [_jsx(Checkbox, { checked: list.values.includes(value), onCheckedChange: () => list.toggle(value), label: uiNodeLabel(node), disabled: !list.enabled }), _jsx("div", { className: "min-w-0 grow", children: children })] }));
34
+ return (_jsxs("div", { className: clsx('flex min-h-10 items-start gap-3 rounded-xl bg-ui-tile px-3 py-2.5 text-xs text-text', checked && 'ring-2 ring-accent ring-inset', list.enabled && 'hover:bg-surface-active'), onClick: toggleRow, children: [_jsx(Checkbox, { className: "mt-0.5", checked: checked, onCheckedChange: () => list.toggle(value), label: uiNodeLabel(node), disabled: !list.enabled }), _jsx("div", { className: "min-w-0 grow", children: children })] }));
34
35
  }
35
36
  export function SwitchRenderer({ node, children, context }) {
36
37
  const binding = bindingOf(node, context);
37
- return (_jsxs("div", { className: clsx('flex min-h-7 items-center justify-between gap-3 px-2 text-sm', !node.complete && PENDING), children: [_jsx("span", { className: "min-w-0 text-text", children: children }), _jsx(Switch, { checked: binding?.value ?? node.props.value, onCheckedChange: (next) => binding?.onValueChange(next), label: uiNodeLabel(node), disabled: binding === null })] }));
38
+ return (_jsxs("div", { className: clsx('chat-ui-flat flex min-h-11 items-center justify-between gap-3 rounded-xl bg-ui-tile px-3 py-2 text-xs', !node.complete && PENDING), children: [_jsx("span", { className: "min-w-0 font-medium text-text", children: children }), _jsx(Switch, { checked: binding?.value ?? node.props.value, onCheckedChange: (next) => binding?.onValueChange(next), label: uiNodeLabel(node), disabled: binding === null })] }));
38
39
  }
39
40
  /* Its children are the label, read as text: the slider writes its own label line. */
40
41
  export function SliderRenderer({ node, context }) {
@@ -42,7 +43,7 @@ export function SliderRenderer({ node, context }) {
42
43
  const { value, min, max, step, unit } = node.props;
43
44
  return (_jsx(Slider, { value: binding?.value ?? value, onValueChange: (next) => binding?.onValueChange(next), min: min, max: max, step: step,
44
45
  // The slider prints its unit right after the number, so the space a unit stands apart with comes from here.
45
- unit: uiWithUnit('', unit), label: uiNodeLabel(node), disabled: binding === null, className: clsx('px-2', !node.complete && PENDING) }));
46
+ unit: uiWithUnit('', unit), label: uiNodeLabel(node), disabled: binding === null, className: clsx('chat-ui-flat rounded-xl bg-ui-tile p-3', !node.complete && PENDING) }));
46
47
  }
47
48
  /* A label longer than this reads as a sentence, which wraps badly beside another; the options then stand in a column. */
48
49
  const SIDE_BY_SIDE_LABEL = 24;
@@ -53,7 +54,7 @@ export function SegmentedRenderer({ node, context }) {
53
54
  const current = binding?.value ?? node.props.value;
54
55
  const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
55
56
  const picked = options.find((option) => option.value === current)?.id ?? '';
56
- return (_jsx("div", { className: clsx('px-2', !node.complete && PENDING), children: _jsx(Segmented, { value: picked, options: options, label: t('blocks.options'), orientation: options.some((option) => option.label.length > SIDE_BY_SIDE_LABEL) ? 'vertical' : 'horizontal', disabled: binding === null, onValueChange: (id) => {
57
+ return (_jsx("div", { className: clsx(!node.complete && PENDING), children: _jsx(Segmented, { value: picked, options: options, label: t('blocks.options'), orientation: options.some((option) => option.label.length > SIDE_BY_SIDE_LABEL) ? 'vertical' : 'horizontal', disabled: binding === null, onValueChange: (id) => {
57
58
  const option = options[Number(id)];
58
59
  if (option !== undefined) {
59
60
  binding?.onValueChange(option.value);
@@ -76,7 +77,7 @@ export function TextFieldRenderer({ node, children, context }) {
76
77
  const value = binding?.value ?? node.props.value;
77
78
  const countId = `${id}-count`;
78
79
  const counting = value.length >= maxLength * COUNT_FROM;
79
- return (_jsxs("div", { className: clsx('flex flex-col gap-1 px-2', !node.complete && PENDING), children: [_jsx("label", { htmlFor: id, className: "text-sm text-text-muted", children: children }), _jsx(Input, { id: id, value: value, size: "sm", maxLength: maxLength, placeholder: placeholder, disabled: binding === null, "aria-describedby": counting ? countId : undefined, className: "max-w-96 bg-surface-sunken", onChange: (event) => binding?.onValueChange(event.target.value) }), counting && (_jsx("span", { id: countId, className: "text-xs text-text-muted tabular-nums", children: t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) }) }))] }));
80
+ return (_jsxs("div", { className: clsx('flex flex-col gap-1.5', !node.complete && PENDING), children: [_jsx("label", { htmlFor: id, className: "px-0.5 text-2xs text-text-muted", children: children }), _jsx(Input, { id: id, value: value, size: "sm", maxLength: maxLength, placeholder: placeholder, disabled: binding === null, "aria-describedby": counting ? countId : undefined, className: "border-transparent bg-ui-tile", onChange: (event) => binding?.onValueChange(event.target.value) }), counting && (_jsx("span", { id: countId, className: "text-xs text-text-muted tabular-nums", children: t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) }) }))] }));
80
81
  }
81
82
  /*
82
83
  * Left aligned, never the full width. Like Segmented, each Option is an item by its place, since a value may be any scalar.
@@ -88,7 +89,7 @@ export function SelectRenderer({ node, context }) {
88
89
  const current = binding?.value ?? node.props.value;
89
90
  const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
90
91
  const label = uiOwnLabel(node);
91
- return (_jsxs("div", { className: clsx('flex flex-col items-start gap-1 px-2', !node.complete && PENDING), children: [label !== '' && _jsx("span", { className: "text-sm text-text-muted", children: label }), _jsx(Select, { value: options.find((option) => option.value === current)?.id ?? null, items: options.map((option) => ({ value: option.id, label: option.label })), label: label === '' ? t('blocks.options') : label, disabled: binding === null || options.length === 0, onValueChange: (id) => {
92
+ return (_jsxs("div", { className: clsx('flex flex-col items-start gap-1.5', !node.complete && PENDING), children: [label !== '' && _jsx("span", { className: "px-0.5 text-2xs text-text-muted", children: label }), _jsx(Select, { value: options.find((option) => option.value === current)?.id ?? null, items: options.map((option) => ({ value: option.id, label: option.label })), label: label === '' ? t('blocks.options') : label, disabled: binding === null || options.length === 0, onValueChange: (id) => {
92
93
  const option = options[Number(id)];
93
94
  if (option !== undefined) {
94
95
  binding?.onValueChange(option.value);
@@ -100,5 +101,5 @@ export function ButtonRenderer({ node, children, context }) {
100
101
  const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
101
102
  const label = uiNodeLabel(node);
102
103
  const [measure, truncated] = useTruncated();
103
- return (_jsx("div", { className: clsx('px-2', !node.complete && PENDING), children: _jsx(Tooltip, { label: label, disabled: !truncated, children: _jsx(Button, { variant: "secondary", size: "sm", className: "max-w-full", disabled: !enabled, onClick: () => node.onAction?.(), children: _jsx("span", { ref: measure, className: "min-w-0 truncate", children: children }) }) }) }));
104
+ return (_jsx("div", { className: clsx(!node.complete && PENDING), children: _jsx(Tooltip, { label: label, disabled: !truncated, children: _jsx(Button, { variant: "secondary", size: "sm", className: "max-w-full", disabled: !enabled, onClick: () => node.onAction?.(), children: _jsx("span", { ref: measure, className: "min-w-0 truncate", children: children }) }) }) }));
104
105
  }
@@ -3,9 +3,11 @@ import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { FileDiff, GitCommitHorizontal, SquareDashed } from 'lucide-react';
5
5
  import { FileIcon, Icon, Tooltip } from '@adecore/ui';
6
- import { CHIP_IN_MESSAGE, MENTION_TONE } from '../../chips.js';
6
+ import { CHIP_IN_MESSAGE } from '../../chips.js';
7
7
  import { uiReasonText } from '../node-text.js';
8
8
  const PLAIN = { state: 'plain' };
9
+ /* A link in a block is a quiet card on its tile, unlike a mention in a message, which wears the accent. */
10
+ const UI_CHIP = 'bg-ui-inset font-mono text-text shadow-[inset_0_0_0_1px_var(--border)]';
9
11
  function baseName(path) {
10
12
  const trimmed = path.replace(/\/+$/, '');
11
13
  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || path;
@@ -62,7 +64,7 @@ export function UiLinkChip({ target, context }) {
62
64
  }
63
65
  const { face, hint } = chipFace(target, link);
64
66
  const { openLink } = context;
65
- const chip = openLink === undefined ? (_jsx("span", { className: clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'transition-opacity duration-120 starting:opacity-0'), children: face })) : (_jsx("button", { type: "button", className: clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'transition-opacity duration-120 hover:brightness-95 starting:opacity-0'), onClick: () => openLink(target), children: face }));
67
+ const chip = openLink === undefined ? (_jsx("span", { className: clsx(CHIP_IN_MESSAGE, UI_CHIP, 'transition-opacity duration-120 starting:opacity-0'), children: face })) : (_jsx("button", { type: "button", className: clsx(CHIP_IN_MESSAGE, UI_CHIP, 'transition-opacity duration-120 hover:bg-surface-hover starting:opacity-0'), onClick: () => openLink(target), children: face }));
66
68
  return hint === null ? chip : _jsx(Tooltip, { label: hint, children: chip });
67
69
  }
68
70
  /* A link with children is a row: the chip and what the agent says about it. */
@@ -70,7 +72,7 @@ function LinkRow({ target, context, described, children }) {
70
72
  if (!described) {
71
73
  return _jsx(UiLinkChip, { target: target, context: context });
72
74
  }
73
- return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-baseline gap-x-2 px-2 text-sm", children: [_jsx("span", { className: "max-w-full min-w-0", children: _jsx(UiLinkChip, { target: target, context: context }) }), _jsx("span", { className: "min-w-0 wrap-break-word text-text-muted", children: children })] }));
75
+ return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-baseline gap-x-2 text-xs", children: [_jsx("span", { className: "max-w-full min-w-0", children: _jsx(UiLinkChip, { target: target, context: context }) }), _jsx("span", { className: "min-w-0 wrap-break-word text-text-muted", children: children })] }));
74
76
  }
75
77
  export function FileRenderer({ node, children, context }) {
76
78
  const { path, line } = node.props;