@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
@@ -1,9 +1,10 @@
1
1
  import { useId, useState } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { ArrowRight, Check } from 'lucide-react';
4
+ import { Check } from 'lucide-react';
5
5
  import { uiChoiceLabel, type UiProps } from '@adecore/intelligent-ui';
6
- import { DisabledReason, Icon, Spinner, Tooltip } from '@adecore/ui';
6
+ import { DisabledReason, formatShortcut, Icon, KEY_SHORTCUTS, Spinner, Tooltip } from '@adecore/ui';
7
+ import { useChatHost } from '../../../../host';
7
8
  import type { UiRendererProps } from '../render-context';
8
9
  import { UiFallbackPart } from '../UiFallbackPart';
9
10
  import { useLater } from '../use-later';
@@ -17,7 +18,7 @@ export function ChoicesRenderer({ children, context }: UiRendererProps<UiProps<'
17
18
  const { t } = useTranslation('agent-chat');
18
19
  const [streamed] = useState(context.phase === 'streaming');
19
20
  return (
20
- <div role="group" aria-label={t('blocks.choices')} className="chat-ui-nodes flex flex-col gap-1.5">
21
+ <div role="group" aria-label={t('blocks.choices')} className="chat-ui-nodes @container flex flex-wrap items-start gap-2">
21
22
  {children}
22
23
  <span className="sr-only" role="status">
23
24
  {streamed && context.phase === 'final' ? t('blocks.choice.ready') : ''}
@@ -27,13 +28,15 @@ export function ChoicesRenderer({ children, context }: UiRendererProps<UiProps<'
27
28
  }
28
29
 
29
30
  /*
30
- * A row that shows what it sends: the label, and under it the context the agent receives. It stays
31
+ * A button that shows what it sends: the label, and under it the context the agent receives. It stays
31
32
  * closed while the reply streams, once the block is answered and without a host to send it, and
32
33
  * remains focusable then so a screen reader hears why. A finished Choice the host would refuse for
33
- * its label is no row at all, but its text.
34
+ * its label is no button at all, but its text. The primary one says the key that picks it while the
35
+ * focus is in the block (`UiBlockFrame`).
34
36
  */
35
37
  export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiProps<'Choice'>>) {
36
38
  const { t } = useTranslation('agent-chat');
39
+ const host = useChatHost();
37
40
  const descriptionId = useId();
38
41
  const { context: detail, primary, disabled } = node.props;
39
42
  const { answer, onChoose } = context;
@@ -52,16 +55,22 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
52
55
  ? ((disabled === true ? null : context.choiceReason) ?? t('blocks.choice.unavailable'))
53
56
  : null;
54
57
  const described = detail !== undefined && detail !== '';
58
+ const strong = primary === true;
55
59
 
56
- const row = (
60
+ const button = (
57
61
  <button
58
62
  type="button"
59
63
  aria-disabled={closed || undefined}
60
64
  aria-describedby={described ? descriptionId : undefined}
65
+ aria-keyshortcuts={strong && !closed ? 'Meta+Enter Control+Enter' : undefined}
66
+ data-ui-primary-choice={strong && !closed ? '' : undefined}
61
67
  className={clsx(
62
- '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',
63
- chosen ? 'border-accent bg-accent-soft' : failed ? 'border-status-error bg-surface-hover' : 'border-border bg-surface-hover',
64
- !closed && 'hover:bg-surface-active active:scale-99',
68
+ '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',
69
+ strong ? 'bg-accent text-accent-text' : 'bg-ui-tile text-text',
70
+ chosen && 'ring-2 ring-accent ring-offset-2 ring-offset-surface',
71
+ failed && 'ring-2 ring-status-error',
72
+ !closed && (strong ? 'hover:brightness-110' : 'hover:bg-surface-active'),
73
+ !closed && 'active:scale-99',
65
74
  waiting ? 'opacity-60' : closed && !chosen && 'opacity-50'
66
75
  )}
67
76
  onClick={() => {
@@ -70,17 +79,21 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
70
79
  }
71
80
  }}
72
81
  >
73
- <Icon icon={ArrowRight} size={14} className={clsx('mt-1 shrink-0', primary === true ? 'text-accent' : 'text-text-faint')} />
74
- <span className="flex min-w-0 grow flex-col">
75
- <span className="text-sm font-semibold wrap-anywhere text-text">{children}</span>
82
+ <span className="flex min-w-0 flex-col">
83
+ <span className={clsx('wrap-anywhere', strong ? 'font-semibold' : 'font-medium')}>{children}</span>
76
84
  {described && (
77
- <span id={descriptionId} className="line-clamp-2 text-xs wrap-anywhere text-text-muted">
85
+ <span id={descriptionId} className={clsx('line-clamp-2 text-2xs wrap-anywhere', strong ? 'text-accent-text/80' : 'text-text-muted')}>
78
86
  {detail}
79
87
  </span>
80
88
  )}
81
89
  </span>
90
+ {strong && !closed && (
91
+ <kbd aria-hidden className="shrink-0 rounded-md bg-accent-text/18 px-1.5 font-sans text-2xs font-medium">
92
+ {formatShortcut(KEY_SHORTCUTS.modEnter, host.isApplePlatform())}
93
+ </kbd>
94
+ )}
82
95
  {chosen && (
83
- <span className="mt-0.5 flex shrink-0 items-center gap-1 text-xs text-accent">
96
+ <span className={clsx('flex shrink-0 items-center gap-1 text-2xs', strong ? 'text-accent-text' : 'text-accent')}>
84
97
  {slow ? <Spinner size={12} /> : <Icon icon={Check} size={12} />}
85
98
  {answer.state === 'queued' ? t('blocks.choice.queued') : t('blocks.choice.sent')}
86
99
  </span>
@@ -88,17 +101,18 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
88
101
  </button>
89
102
  );
90
103
 
104
+ // 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.
91
105
  return (
92
- <div className="flex flex-col gap-1">
106
+ <div className="flex max-w-full flex-col gap-1 @min-[420px]:max-w-[calc(50%-4px)]">
93
107
  {reason !== null ? (
94
- <DisabledReason reason={reason}>{row}</DisabledReason>
108
+ <DisabledReason reason={reason}>{button}</DisabledReason>
95
109
  ) : described && detail.length > LONG_CONTEXT ? (
96
- <Tooltip label={detail}>{row}</Tooltip>
110
+ <Tooltip label={detail}>{button}</Tooltip>
97
111
  ) : (
98
- row
112
+ button
99
113
  )}
100
114
  {failed && (
101
- <p role="alert" className="px-2 text-xs text-status-error">
115
+ <p role="alert" className="px-1 text-2xs text-status-error">
102
116
  {t('blocks.choice.failed')}
103
117
  </p>
104
118
  )}
@@ -1,8 +1,9 @@
1
1
  import clsx from 'clsx';
2
2
  import { createContext, useContext, useId, useState, type ReactNode } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { Box } from 'lucide-react';
4
5
  import type { UiProps } from '@adecore/intelligent-ui';
5
- import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
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';
8
9
  import { CodeBlock } from '../../CodeBlock';
@@ -17,10 +18,18 @@ const SourceNumbers = createContext<ReadonlyMap<string, number>>(new Map());
17
18
 
18
19
  /* Plain text in the same measure until the node closes, so highlighting never makes it jump. */
19
20
  export function CodeBlockRenderer({ node }: UiRendererProps<UiProps<'CodeBlock'>>) {
21
+ const { language } = node.props;
20
22
  return (
21
- <CodeStreamingContext.Provider value={!node.complete}>
22
- <CodeBlock code={uiNodeText(node).replace(/^\n|\n$/g, '')} lang={node.props.language ?? 'text'} />
23
- </CodeStreamingContext.Provider>
23
+ <div className="chat-ui-code chat-ui-flat flex flex-col rounded-xl bg-ui-tile p-1.5">
24
+ {language !== undefined && language !== '' && (
25
+ <div className="chat-ui-flat-inner flex px-1.5 pt-0.5 pb-1.5">
26
+ <span className="rounded-md bg-ui-inset px-1.5 font-mono text-2xs text-text shadow-[inset_0_0_0_1px_var(--border)]">{language}</span>
27
+ </div>
28
+ )}
29
+ <CodeStreamingContext.Provider value={!node.complete}>
30
+ <CodeBlock code={uiNodeText(node).replace(/^\n|\n$/g, '')} lang={language ?? 'text'} />
31
+ </CodeStreamingContext.Provider>
32
+ </div>
24
33
  );
25
34
  }
26
35
 
@@ -49,7 +58,7 @@ function AttachedImage({
49
58
  const host = useChatHost();
50
59
  const source = host.attachments.useUrl(scopeId, chatId, attachmentId);
51
60
  if (!cell) {
52
- return <ImageThumb source={source} alt={alt} className="max-h-70 max-w-full object-contain" />;
61
+ return <ImageThumb source={source} alt={alt} className="max-h-70 max-w-full rounded-xl object-contain" />;
53
62
  }
54
63
  return (
55
64
  <>
@@ -76,7 +85,7 @@ export function ImageRenderer({ node, children, context }: UiRendererProps<UiPro
76
85
  ) : (
77
86
  <AttachedImage scopeId={context.scopeId} chatId={context.chatId} attachmentId={attachment} alt={name} cell={cell} unnamed={caption === ''} />
78
87
  )}
79
- {caption !== '' && <figcaption className={cell ? 'text-xs wrap-anywhere text-text-muted' : 'px-2 text-xs text-text-muted'}>{children}</figcaption>}
88
+ {caption !== '' && <figcaption className="px-0.5 text-2xs wrap-anywhere text-text-muted">{children}</figcaption>}
80
89
  {cell && caption === '' && attachment === undefined && (
81
90
  <figcaption className="text-xs wrap-anywhere text-text-faint">{alt ?? t('blocks.image.unavailable')}</figcaption>
82
91
  )}
@@ -89,7 +98,7 @@ export function GalleryRenderer({ node, children }: UiRendererProps<UiProps<'Gal
89
98
  const { t } = useTranslation('agent-chat');
90
99
  return (
91
100
  <GalleryContext value>
92
- <div role="group" aria-label={t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length })} className="@container px-2">
101
+ <div role="group" aria-label={t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length })} className="@container">
93
102
  <div className="chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3">{children}</div>
94
103
  </div>
95
104
  </GalleryContext>
@@ -128,7 +137,7 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
128
137
  body = (
129
138
  // The frame keeps the picture's shape inside its tallest height: past it the width follows, so a portrait picture is not letterboxed.
130
139
  <div
131
- className="relative max-h-96 overflow-hidden rounded-lg border border-border bg-surface-sunken"
140
+ className="relative max-h-96 overflow-hidden rounded-xl bg-ui-tile"
132
141
  style={{ aspectRatio: aspect, width: `min(100%, ${COMPARE_MAX_HEIGHT * aspect}px)` }}
133
142
  >
134
143
  <Wipe
@@ -162,14 +171,14 @@ export function CompareRenderer({ node, children, context }: UiRendererProps<UiP
162
171
  body = (
163
172
  <>
164
173
  <ImageThumb source={shown.source} alt={shown.name} className="max-h-70 max-w-full object-contain" />
165
- <span className="px-2 text-xs text-text-faint">{t('blocks.compare.missing')}</span>
174
+ <span className="text-2xs text-text-faint">{t('blocks.compare.missing')}</span>
166
175
  </>
167
176
  );
168
177
  }
169
178
  return (
170
- <figure className="flex flex-col items-start gap-1.5 px-2">
179
+ <figure className="flex w-full flex-col items-start gap-1.5">
171
180
  {body}
172
- {caption !== '' && <figcaption className="text-xs text-text-muted">{children}</figcaption>}
181
+ {caption !== '' && <figcaption className="px-0.5 text-2xs text-text-muted">{children}</figcaption>}
173
182
  </figure>
174
183
  );
175
184
  }
@@ -187,14 +196,63 @@ function CompareChip({ side, label }: { side: 'left' | 'right'; label: string })
187
196
  );
188
197
  }
189
198
 
199
+ /*
200
+ * The host draws the model, since the chat knows no 3D format. The frame has a fixed height in the reply column,
201
+ * so a view that loads late never moves the thread. Without a view of the host, or without the bytes, the model
202
+ * stays a quiet row that names it.
203
+ */
204
+ export function ModelRenderer({ node, children, context }: UiRendererProps<UiProps<'Model'>>) {
205
+ const { t } = useTranslation('agent-chat');
206
+ const host = useChatHost();
207
+ const { attachment, alt } = node.props;
208
+ const source = host.attachments.useUrl(context.scopeId, context.chatId, attachment);
209
+ const caption = uiNodeLabel(node);
210
+ const label = alt ?? (caption === '' ? t('blocks.model.label') : caption);
211
+ const View = host.ModelView;
212
+ let body: ReactNode;
213
+ if (View !== undefined && source.url !== null) {
214
+ body = (
215
+ <div role="group" aria-label={label} className="relative h-80 w-full overflow-hidden rounded-xl bg-ui-tile">
216
+ <ErrorBoundary label={t('blocks.model.crashed')} resetKeys={[attachment, source.url]} compact>
217
+ <View
218
+ scopeId={context.scopeId}
219
+ chatId={context.chatId}
220
+ attachmentId={attachment}
221
+ url={source.url}
222
+ label={label}
223
+ alt={alt}
224
+ caption={caption}
225
+ />
226
+ </ErrorBoundary>
227
+ </div>
228
+ );
229
+ } else if (View !== undefined && source.failure === null) {
230
+ body = <div aria-busy="true" aria-label={label} className="h-80 w-full rounded-xl bg-ui-tile" />;
231
+ } else {
232
+ body = (
233
+ <div className="flex w-full min-w-0 items-center gap-2 rounded-xl bg-ui-tile px-3 py-2 text-xs">
234
+ <Icon icon={Box} size={14} className="shrink-0 text-text-muted" />
235
+ <span className="min-w-0 truncate text-text">{alt ?? attachment}</span>
236
+ <span className="ml-auto shrink-0 text-text-faint">{source.failure === null ? t('blocks.model.label') : t('blocks.model.unavailable')}</span>
237
+ </div>
238
+ );
239
+ }
240
+ return (
241
+ <figure className="flex w-full flex-col items-start gap-1.5">
242
+ {body}
243
+ {caption !== '' && <figcaption className="px-0.5 text-2xs text-text-muted">{children}</figcaption>}
244
+ </figure>
245
+ );
246
+ }
247
+
190
248
  export function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>) {
191
249
  const { t } = useTranslation('agent-chat');
192
250
  const headingId = useId();
193
251
  const numbers = new Map(uiChildrenOf(node, 'Source').map((source, index) => [source.id, index + 1]));
194
252
  return (
195
253
  <SourceNumbers value={numbers}>
196
- <section aria-labelledby={headingId} className="flex flex-col">
197
- <span id={headingId} className="px-2 pb-1 text-xs font-medium text-text-muted">
254
+ <section aria-labelledby={headingId} className="chat-ui-flat flex flex-col rounded-xl bg-ui-tile p-1.5">
255
+ <span id={headingId} className="chat-ui-flat-inner px-1.5 pt-0.5 pb-1 text-2xs font-medium text-text-muted">
198
256
  {t('blocks.sources')}
199
257
  </span>
200
258
  <ol className="chat-ui-nodes flex flex-col">{children}</ol>
@@ -255,10 +313,12 @@ export function SourceRenderer({ node, context }: UiRendererProps<UiProps<'Sourc
255
313
  const [measureDomain, domainCut] = useTruncated<HTMLSpanElement>();
256
314
  const face = (
257
315
  <>
258
- <span className="w-5 shrink-0 self-start text-right text-text-faint tabular-nums">{number}</span>
259
- <span className="min-w-0 grow wrap-anywhere text-text">{title}</span>
260
- <span ref={measureDomain} className="max-w-1/2 min-w-0 shrink-0 self-start truncate text-text-faint">
261
- {uiUrlDomain(url)}
316
+ <span className="grid size-5 shrink-0 place-items-center rounded-md bg-ui-inset text-2xs font-semibold text-text tabular-nums">{number}</span>
317
+ <span className="flex min-w-0 grow flex-col">
318
+ <span className="font-medium wrap-anywhere text-text">{title}</span>
319
+ <span ref={measureDomain} className="truncate text-text-muted">
320
+ {uiUrlDomain(url)}
321
+ </span>
262
322
  </span>
263
323
  </>
264
324
  );
@@ -274,13 +334,16 @@ export function SourceRenderer({ node, context }: UiRendererProps<UiProps<'Sourc
274
334
  }
275
335
  >
276
336
  {openUrl === undefined ? (
277
- <span tabIndex={domainCut ? 0 : undefined} className="flex min-h-8 items-start gap-2 rounded-md px-2 py-2 text-xs">
337
+ <span
338
+ tabIndex={domainCut ? 0 : undefined}
339
+ className="chat-ui-flat-inner flex min-h-8 items-start gap-2.5 rounded-lg px-1.5 py-1.5 text-2xs"
340
+ >
278
341
  {face}
279
342
  </span>
280
343
  ) : (
281
344
  <button
282
345
  type="button"
283
- 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"
346
+ 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"
284
347
  onClick={() => openUrl(url)}
285
348
  >
286
349
  {face}
@@ -1,19 +1,25 @@
1
+ import { createContext, useContext } from 'react';
1
2
  import clsx from 'clsx';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import { ArrowDown, ArrowRight, ArrowUp } from 'lucide-react';
4
5
  import type { UiProps } from '@adecore/intelligent-ui';
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';
8
9
  import { uiChildrenOf, uiWithUnit } from '../node-text';
9
10
  import type { UiLiveValue, UiRendererProps } from '../render-context';
10
11
  import { isNumericColumn, UI_TABLE_ROWS, uiTableCell, uiTableColumns, type UiTableCell, type UiTableColumn } from '../table-data';
11
- import { UI_TONE_TEXT } from '../tones';
12
+ import { UI_TONE_PILL } from '../tones';
12
13
  import { UiFallbackPart } from '../UiFallbackPart';
13
14
 
14
15
  /* EntityList draws a hairline between its rows from this many on. */
15
16
  const DIVIDED_FROM = 7;
16
17
 
18
+ /* Up to this many entries an EntityList is a row of tiles; a longer one is a list in one tile. */
19
+ const TILED_UP_TO = 4;
20
+
21
+ const EntityTiles = createContext(false);
22
+
17
23
  /*
18
24
  * A number that live readings keep up to date. Keyed by its text, so a value that changed mounts
19
25
  * again and lights up once, even when it changes again before the last light settled.
@@ -31,7 +37,7 @@ export function StatsRenderer({ children }: UiRendererProps<UiProps<'Stats'>>) {
31
37
  return <div className="chat-ui-nodes grid grid-cols-[repeat(auto-fit,minmax(128px,1fr))] gap-2">{children}</div>;
32
38
  }
33
39
 
34
- /* The arrow and the number say which way a value went; only a tone says whether that is good. */
40
+ /* 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. */
35
41
  export function StatRenderer({ node, context }: UiRendererProps<UiProps<'Stat'>>) {
36
42
  const { t } = useTranslation('agent-chat');
37
43
  const { label, value, previous, unit, tone } = node.props;
@@ -41,36 +47,63 @@ export function StatRenderer({ node, context }: UiRendererProps<UiProps<'Stat'>>
41
47
  const from = previous === undefined ? '' : uiWithUnit(formatRounded(previous, 2), unit);
42
48
  // A change from zero has no percentage.
43
49
  const percent = change === null || previous === undefined || previous === 0 ? null : formatPercent(Math.abs((change / previous) * 100));
50
+ const said = percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from });
44
51
  return (
45
- <div className="flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5">
46
- <span className="truncate text-xs text-text-muted">{label}</span>
52
+ <div className="flex min-w-0 flex-col items-start gap-1.5 rounded-xl bg-ui-tile p-3">
53
+ <span className="max-w-full truncate text-2xs text-text-muted">{label}</span>
47
54
  <LiveFigure
48
55
  text={uiWithUnit(formatRounded(value, 2), unit)}
49
56
  previous={typeof live?.previous === 'number' ? uiWithUnit(formatRounded(live.previous, 2), unit) : undefined}
50
57
  live={live}
51
- className="text-lg font-semibold tabular-nums"
58
+ className="max-w-full truncate text-lg font-semibold tabular-nums"
52
59
  />
53
60
  {change !== null && (
54
- <span className={clsx('flex items-center gap-1 text-xs tabular-nums', tone === undefined ? 'text-text-faint' : UI_TONE_TEXT[tone])}>
55
- <Icon icon={arrow} size={12} className="shrink-0" />
56
- <span className="min-w-0 truncate">
57
- {percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from })}
61
+ <Tooltip label={said}>
62
+ <span tabIndex={0} className="max-w-full rounded-md">
63
+ <Pill
64
+ shape="tag"
65
+ size="sm"
66
+ tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]}
67
+ icon={<Icon icon={arrow} size={12} className="shrink-0" />}
68
+ >
69
+ <span aria-hidden className="tabular-nums">
70
+ {percent ?? from}
71
+ </span>
72
+ <span className="sr-only">{said}</span>
73
+ </Pill>
58
74
  </span>
59
- </span>
75
+ </Tooltip>
60
76
  )}
61
77
  </div>
62
78
  );
63
79
  }
64
80
 
65
81
  export function EntityListRenderer({ node, children }: UiRendererProps<UiProps<'EntityList'>>) {
82
+ const count = uiChildrenOf(node, 'Entry').length;
83
+ if (count <= TILED_UP_TO) {
84
+ return (
85
+ <EntityTiles value>
86
+ <div className="chat-ui-nodes grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2">{children}</div>
87
+ </EntityTiles>
88
+ );
89
+ }
66
90
  return (
67
- <KeyValueList.Root divided={uiChildrenOf(node, 'Entry').length >= DIVIDED_FROM} className="chat-ui-nodes px-2">
91
+ <KeyValueList.Root divided={count >= DIVIDED_FROM} className="chat-ui-nodes chat-ui-flat rounded-xl bg-ui-tile px-3 py-2">
68
92
  {children}
69
93
  </KeyValueList.Root>
70
94
  );
71
95
  }
72
96
 
73
97
  export function EntryRenderer({ node, children }: UiRendererProps<UiProps<'Entry'>>) {
98
+ const tiled = useContext(EntityTiles);
99
+ if (tiled) {
100
+ return (
101
+ <div className="flex min-w-0 flex-col gap-0.5 rounded-xl bg-ui-tile px-3 py-2">
102
+ <span className="truncate text-2xs text-text-muted">{node.props.label}</span>
103
+ <span className="text-2xs font-semibold wrap-break-word text-text">{children}</span>
104
+ </div>
105
+ );
106
+ }
74
107
  return (
75
108
  <KeyValueList.Item>
76
109
  <KeyValueList.Name>{node.props.label}</KeyValueList.Name>
@@ -129,19 +162,23 @@ export function TableRenderer({ node, context }: UiRendererProps<UiProps<'Table'
129
162
  const shown = all ? rows : rows.slice(0, UI_TABLE_ROWS);
130
163
  return (
131
164
  <div className="flex flex-col items-start gap-1.5">
132
- <div className="max-h-80 w-full overflow-auto rounded-md border border-border-soft">
165
+ <div className="chat-ui-flat max-h-80 w-full overflow-auto rounded-xl bg-ui-tile px-1.5 pb-1.5">
133
166
  <table
134
167
  aria-label={t('blocks.table.label', { columns: columns.map((column) => column.title).join(', ') })}
135
- className="w-full border-collapse text-xs tabular-nums"
168
+ className={clsx(
169
+ 'w-full border-separate border-spacing-0 text-xs tabular-nums',
170
+ '[&_tbody_tr:first-child_td:first-child]:rounded-tl-lg [&_tbody_tr:first-child_td:last-child]:rounded-tr-lg',
171
+ '[&_tbody_tr:last-child_td:first-child]:rounded-bl-lg [&_tbody_tr:last-child_td:last-child]:rounded-br-lg'
172
+ )}
136
173
  >
137
- <thead className="sticky top-0 z-1 bg-surface-hover">
174
+ <thead className="sticky top-0 z-1 bg-ui-tile">
138
175
  <tr>
139
176
  {columns.map((column) => (
140
177
  <th
141
178
  key={column.key}
142
179
  scope="col"
143
180
  className={clsx(
144
- 'h-8 px-2 font-medium whitespace-nowrap text-text-muted',
181
+ 'h-8 px-2 text-2xs font-medium whitespace-nowrap text-text-muted',
145
182
  isNumericColumn(column) ? 'text-right' : 'text-left'
146
183
  )}
147
184
  >
@@ -153,11 +190,16 @@ export function TableRenderer({ node, context }: UiRendererProps<UiProps<'Table'
153
190
  <tbody>
154
191
  {shown.map((row, index) => (
155
192
  // Rows only ever grow at the end while a block streams, so the place of a row is a stable key.
156
- <tr key={index} className="border-t border-border-soft">
157
- {columns.map((column) => (
193
+ <tr key={index}>
194
+ {columns.map((column, at) => (
158
195
  <td
159
196
  key={column.key}
160
- className={clsx('h-8 px-2 text-text', isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left')}
197
+ className={clsx(
198
+ 'h-9 bg-ui-inset px-2 text-text',
199
+ index > 0 && 'border-t border-border-soft',
200
+ at === 0 && 'font-medium',
201
+ isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left'
202
+ )}
161
203
  >
162
204
  <Cell
163
205
  value={row[column.key]}
@@ -23,7 +23,7 @@ const ChecklistContext = createContext<ChecklistState>({ values: [], enabled: fa
23
23
  function ChecklistGroup({ pending, children }: { pending: boolean; children: ReactNode }) {
24
24
  const { t } = useTranslation('agent-chat');
25
25
  return (
26
- <div role="group" aria-label={t('blocks.checklist')} className={clsx('chat-ui-nodes flex flex-col', pending && PENDING)}>
26
+ <div role="group" aria-label={t('blocks.checklist')} className={clsx('chat-ui-nodes flex flex-col gap-1.5', pending && PENDING)}>
27
27
  {children}
28
28
  </div>
29
29
  );
@@ -42,10 +42,11 @@ export function ChecklistRenderer({ node, children, context }: UiRendererProps<U
42
42
  );
43
43
  }
44
44
 
45
- /* The whole row is the label; a link inside it keeps its own click and checks nothing. */
45
+ /* The whole tile is the label, ringed in the accent while checked; a link inside it keeps its own click and checks nothing. */
46
46
  export function ItemRenderer({ node, children }: UiRendererProps<UiProps<'Item'>>) {
47
47
  const list = useContext(ChecklistContext);
48
48
  const { value } = node.props;
49
+ const checked = list.values.includes(value);
49
50
  const toggleRow = (event: MouseEvent<HTMLDivElement>): void => {
50
51
  if (list.enabled && !(event.target as Element).closest('button, a')) {
51
52
  list.toggle(value);
@@ -53,10 +54,14 @@ export function ItemRenderer({ node, children }: UiRendererProps<UiProps<'Item'>
53
54
  };
54
55
  return (
55
56
  <div
56
- className={clsx('flex min-h-8 items-center gap-2 rounded-md px-2 text-sm text-text', list.enabled && 'hover:bg-surface-hover')}
57
+ className={clsx(
58
+ 'flex min-h-10 items-start gap-3 rounded-xl bg-ui-tile px-3 py-2.5 text-xs text-text',
59
+ checked && 'ring-2 ring-accent ring-inset',
60
+ list.enabled && 'hover:bg-surface-active'
61
+ )}
57
62
  onClick={toggleRow}
58
63
  >
59
- <Checkbox checked={list.values.includes(value)} onCheckedChange={() => list.toggle(value)} label={uiNodeLabel(node)} disabled={!list.enabled} />
64
+ <Checkbox className="mt-0.5" checked={checked} onCheckedChange={() => list.toggle(value)} label={uiNodeLabel(node)} disabled={!list.enabled} />
60
65
  <div className="min-w-0 grow">{children}</div>
61
66
  </div>
62
67
  );
@@ -65,8 +70,10 @@ export function ItemRenderer({ node, children }: UiRendererProps<UiProps<'Item'>
65
70
  export function SwitchRenderer({ node, children, context }: UiRendererProps<UiProps<'Switch'>>) {
66
71
  const binding = bindingOf(node, context);
67
72
  return (
68
- <div className={clsx('flex min-h-7 items-center justify-between gap-3 px-2 text-sm', !node.complete && PENDING)}>
69
- <span className="min-w-0 text-text">{children}</span>
73
+ <div
74
+ 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)}
75
+ >
76
+ <span className="min-w-0 font-medium text-text">{children}</span>
70
77
  <Switch
71
78
  checked={binding?.value ?? node.props.value}
72
79
  onCheckedChange={(next) => binding?.onValueChange(next)}
@@ -92,7 +99,7 @@ export function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slide
92
99
  unit={uiWithUnit('', unit)}
93
100
  label={uiNodeLabel(node)}
94
101
  disabled={binding === null}
95
- className={clsx('px-2', !node.complete && PENDING)}
102
+ className={clsx('chat-ui-flat rounded-xl bg-ui-tile p-3', !node.complete && PENDING)}
96
103
  />
97
104
  );
98
105
  }
@@ -108,7 +115,7 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
108
115
  const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
109
116
  const picked = options.find((option) => option.value === current)?.id ?? '';
110
117
  return (
111
- <div className={clsx('px-2', !node.complete && PENDING)}>
118
+ <div className={clsx(!node.complete && PENDING)}>
112
119
  <Segmented
113
120
  value={picked}
114
121
  options={options}
@@ -145,8 +152,8 @@ export function TextFieldRenderer({ node, children, context }: UiRendererProps<U
145
152
  const countId = `${id}-count`;
146
153
  const counting = value.length >= maxLength * COUNT_FROM;
147
154
  return (
148
- <div className={clsx('flex flex-col gap-1 px-2', !node.complete && PENDING)}>
149
- <label htmlFor={id} className="text-sm text-text-muted">
155
+ <div className={clsx('flex flex-col gap-1.5', !node.complete && PENDING)}>
156
+ <label htmlFor={id} className="px-0.5 text-2xs text-text-muted">
150
157
  {children}
151
158
  </label>
152
159
  <Input
@@ -157,7 +164,7 @@ export function TextFieldRenderer({ node, children, context }: UiRendererProps<U
157
164
  placeholder={placeholder}
158
165
  disabled={binding === null}
159
166
  aria-describedby={counting ? countId : undefined}
160
- className="max-w-96 bg-surface-sunken"
167
+ className="border-transparent bg-ui-tile"
161
168
  onChange={(event) => binding?.onValueChange(event.target.value)}
162
169
  />
163
170
  {counting && (
@@ -180,8 +187,8 @@ export function SelectRenderer({ node, context }: UiRendererProps<UiProps<'Selec
180
187
  const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
181
188
  const label = uiOwnLabel(node);
182
189
  return (
183
- <div className={clsx('flex flex-col items-start gap-1 px-2', !node.complete && PENDING)}>
184
- {label !== '' && <span className="text-sm text-text-muted">{label}</span>}
190
+ <div className={clsx('flex flex-col items-start gap-1.5', !node.complete && PENDING)}>
191
+ {label !== '' && <span className="px-0.5 text-2xs text-text-muted">{label}</span>}
185
192
  <Select
186
193
  value={options.find((option) => option.value === current)?.id ?? null}
187
194
  items={options.map((option) => ({ value: option.id, label: option.label }))}
@@ -204,7 +211,7 @@ export function ButtonRenderer({ node, children, context }: UiRendererProps<UiPr
204
211
  const label = uiNodeLabel(node);
205
212
  const [measure, truncated] = useTruncated<HTMLSpanElement>();
206
213
  return (
207
- <div className={clsx('px-2', !node.complete && PENDING)}>
214
+ <div className={clsx(!node.complete && PENDING)}>
208
215
  <Tooltip label={label} disabled={!truncated}>
209
216
  <Button variant="secondary" size="sm" className="max-w-full" disabled={!enabled} onClick={() => node.onAction?.()}>
210
217
  <span ref={measure} className="min-w-0 truncate">
@@ -4,12 +4,15 @@ import { useTranslation } from 'react-i18next';
4
4
  import { FileDiff, GitCommitHorizontal, SquareDashed } from 'lucide-react';
5
5
  import type { UiProps } from '@adecore/intelligent-ui';
6
6
  import { FileIcon, Icon, Tooltip } from '@adecore/ui';
7
- import { CHIP_IN_MESSAGE, MENTION_TONE } from '../../chips';
7
+ import { CHIP_IN_MESSAGE } from '../../chips';
8
8
  import { uiReasonText } from '../node-text';
9
9
  import type { UiLink, UiLinkTarget, UiRenderContext, UiRendererProps } from '../render-context';
10
10
 
11
11
  const PLAIN: UiLink = { state: 'plain' };
12
12
 
13
+ /* A link in a block is a quiet card on its tile, unlike a mention in a message, which wears the accent. */
14
+ const UI_CHIP = 'bg-ui-inset font-mono text-text shadow-[inset_0_0_0_1px_var(--border)]';
15
+
13
16
  function baseName(path: string): string {
14
17
  const trimmed = path.replace(/\/+$/, '');
15
18
  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || path;
@@ -96,11 +99,11 @@ export function UiLinkChip({ target, context }: { target: UiLinkTarget; context:
96
99
  const { openLink } = context;
97
100
  const chip =
98
101
  openLink === undefined ? (
99
- <span className={clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'transition-opacity duration-120 starting:opacity-0')}>{face}</span>
102
+ <span className={clsx(CHIP_IN_MESSAGE, UI_CHIP, 'transition-opacity duration-120 starting:opacity-0')}>{face}</span>
100
103
  ) : (
101
104
  <button
102
105
  type="button"
103
- className={clsx(CHIP_IN_MESSAGE, MENTION_TONE, 'transition-opacity duration-120 hover:brightness-95 starting:opacity-0')}
106
+ className={clsx(CHIP_IN_MESSAGE, UI_CHIP, 'transition-opacity duration-120 hover:bg-surface-hover starting:opacity-0')}
104
107
  onClick={() => openLink(target)}
105
108
  >
106
109
  {face}
@@ -115,7 +118,7 @@ function LinkRow({ target, context, described, children }: { target: UiLinkTarge
115
118
  return <UiLinkChip target={target} context={context} />;
116
119
  }
117
120
  return (
118
- <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 px-2 text-sm">
121
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-xs">
119
122
  <span className="max-w-full min-w-0">
120
123
  <UiLinkChip target={target} context={context} />
121
124
  </span>