@adecore/agents-react 0.28.0 → 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 (30) 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/renderers/chart.js +2 -2
  5. package/dist/chat/ui/intelligent-ui/renderers/choices.js +13 -8
  6. package/dist/chat/ui/intelligent-ui/renderers/content.js +15 -14
  7. package/dist/chat/ui/intelligent-ui/renderers/data.js +19 -6
  8. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +10 -9
  9. package/dist/chat/ui/intelligent-ui/renderers/links.js +5 -3
  10. package/dist/chat/ui/intelligent-ui/renderers/map.js +7 -7
  11. package/dist/chat/ui/intelligent-ui/renderers/structure.js +5 -5
  12. package/dist/chat/ui/intelligent-ui/renderers/text.js +25 -14
  13. package/dist/chat/ui/intelligent-ui/tones.d.ts +2 -0
  14. package/dist/chat/ui/intelligent-ui/tones.js +16 -0
  15. package/dist/theme.css +38 -0
  16. package/package.json +8 -8
  17. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +26 -13
  18. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +7 -4
  19. package/src/chat/ui/intelligent-ui/UiReply.tsx +3 -3
  20. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +2 -2
  21. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +33 -19
  22. package/src/chat/ui/intelligent-ui/renderers/content.tsx +36 -23
  23. package/src/chat/ui/intelligent-ui/renderers/data.tsx +61 -19
  24. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +21 -14
  25. package/src/chat/ui/intelligent-ui/renderers/links.tsx +7 -4
  26. package/src/chat/ui/intelligent-ui/renderers/map.tsx +15 -12
  27. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +6 -6
  28. package/src/chat/ui/intelligent-ui/renderers/text.tsx +63 -42
  29. package/src/chat/ui/intelligent-ui/tones.ts +18 -0
  30. package/src/theme.css +38 -0
@@ -8,7 +8,7 @@ import { Icon, Meter, Pill, Spinner } from '@adecore/ui';
8
8
  import { formatNumber, formatPercent } from '@adecore/ui/format';
9
9
  import { UiHeadContext } from '../render-context.js';
10
10
  import { uiEventTime, uiNodeLabel } from '../node-text.js';
11
- import { UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones.js';
11
+ import { UI_STEP_PILL, UI_TONE_FILL, UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones.js';
12
12
  const STEP_ICONS = {
13
13
  done: CircleCheck,
14
14
  pending: Circle,
@@ -22,23 +22,32 @@ const STEP_TONES = {
22
22
  failed: 'text-status-error',
23
23
  skipped: 'text-text-faint'
24
24
  };
25
+ /* A detail longer than this reads as a sentence, which a tag cannot wrap. */
26
+ const DETAIL_AS_TAG = 24;
25
27
  /* Whether the block still changes: while it streams, or while it reads live data. Only then does anything spin. */
26
28
  function isLive(context) {
27
29
  return context.phase === 'streaming' || (context.live !== undefined && context.live !== null && context.live.state !== 'refused');
28
30
  }
29
- /* A badge longer than this is a sentence, which a tag cannot wrap; it stands under the title as text instead. */
30
- const BADGE_AS_TAG = 32;
31
- /* The head of the block when it is the first node, a subheading without an icon further down. */
31
+ /* A Summary and a Callout share one row: the icon in a square, and beside it text that centers on the square while it is shorter and starts at its top once it is taller. */
32
+ const ICON_ROW = 'flex items-start gap-3 rounded-xl p-3';
33
+ const ICON_SQUARE = 'grid size-8.5 shrink-0 place-items-center rounded-lg';
34
+ const ICON_TEXT = 'flex min-h-8.5 min-w-0 grow flex-col justify-center gap-0.5';
35
+ /*
36
+ * A tile in its tone; the badge stands under the title in the tone's color. The head of the block
37
+ * carries its icon in a square of its own, a Summary further down is a subheading without one. The
38
+ * icon is the ground's ink in the dark theme, where its tones are too light for white.
39
+ */
32
40
  export function SummaryRenderer({ node, children }) {
33
41
  const headId = useContext(UiHeadContext);
34
42
  const head = headId === undefined || headId === node.id;
35
43
  const { tone, badge } = node.props;
36
- const tag = badge !== undefined && badge !== '' && badge.length <= BADGE_AS_TAG;
37
- return (_jsxs("div", { className: "flex items-start gap-2 px-2", children: [head && tone !== undefined && _jsx(Icon, { icon: UI_TONE_ICONS[tone], size: 16, className: clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone]) }), _jsxs("div", { className: "flex min-w-0 grow flex-wrap items-start gap-x-2 gap-y-1", children: [_jsx("div", { className: "max-w-full min-w-0 grow basis-48 text-sm font-semibold text-balance wrap-break-word text-text", children: children }), tag && (_jsx(Pill, { shape: "tag", tone: tone === undefined ? 'muted' : UI_TONE_PILL[tone], children: badge })), !tag && badge !== undefined && badge !== '' && _jsx("p", { className: "w-full text-xs wrap-anywhere text-text-muted", children: badge })] })] }));
44
+ const shown = tone ?? 'neutral';
45
+ return (_jsxs("div", { className: clsx(ICON_ROW, tone === undefined ? 'bg-ui-tile' : UI_TONE_SURFACE[tone]), children: [head && (_jsx("span", { "aria-hidden": true, className: clsx(ICON_SQUARE, 'text-accent-text dark:text-surface', UI_TONE_FILL[shown]), children: _jsx(Icon, { icon: UI_TONE_ICONS[shown], size: 16 }) })), _jsxs("div", { className: ICON_TEXT, children: [_jsx("div", { className: "text-base font-semibold text-balance wrap-break-word text-text", children: children }), badge !== undefined && badge !== '' && (_jsx("p", { className: clsx('text-2xs font-medium wrap-anywhere', tone === undefined ? 'text-text-muted' : UI_TONE_TEXT[tone]), children: badge }))] })] }));
38
46
  }
47
+ /* A quiet tile whatever its tone: only the square of its icon wears the tone. */
39
48
  export function CalloutRenderer({ node, children }) {
40
49
  const { tone, title } = node.props;
41
- return (_jsxs("div", { className: clsx('flex gap-2 rounded-md p-2 text-sm', UI_TONE_SURFACE[tone]), children: [_jsx(Icon, { icon: UI_TONE_ICONS[tone], size: 16, className: clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone]) }), _jsxs("div", { className: "min-w-0 grow", children: [title !== undefined && title !== '' && _jsx("div", { className: "font-medium text-text", children: title }), _jsx("div", { className: "text-text-muted", children: children })] })] }));
50
+ return (_jsxs("div", { className: clsx('chat-ui-flat bg-ui-tile text-xs', ICON_ROW), children: [_jsx("span", { "aria-hidden": true, className: clsx(ICON_SQUARE, UI_TONE_SURFACE[tone], UI_TONE_TEXT[tone]), children: _jsx(Icon, { icon: UI_TONE_ICONS[tone], size: 16 }) }), _jsxs("div", { className: ICON_TEXT, children: [title !== undefined && title !== '' && _jsx("div", { className: "font-semibold text-text", children: title }), _jsx("div", { className: "text-text-muted", children: children })] })] }));
42
51
  }
43
52
  export function TagRenderer({ node, children }) {
44
53
  const { tone } = node.props;
@@ -51,22 +60,22 @@ export function ProgressRenderer({ node, children, context }) {
51
60
  const limit = max ?? 100;
52
61
  const reading = value === undefined ? null : limit === 100 ? formatPercent(value) : t('blocks.progress.of', { value: formatNumber(value), max: formatNumber(limit) });
53
62
  const label = uiNodeLabel(node);
54
- return (_jsxs("div", { className: "flex flex-col gap-1 px-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-xs", children: [_jsx("span", { className: "min-w-0 text-text", children: children }), reading !== null ? (_jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: reading })) : (isLive(context) && _jsx(Spinner, { size: 12, className: "shrink-0 self-center text-text-faint" }))] }), _jsx(Meter, { value: value ?? null, max: limit, label: label === '' ? t('blocks.progress.label') : label, valueText: reading ?? undefined })] }));
63
+ return (_jsxs("div", { className: "chat-ui-flat flex flex-col gap-2 rounded-xl bg-ui-tile p-3", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-xs", children: [_jsx("span", { className: "min-w-0 text-text", children: children }), reading !== null ? (_jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: reading })) : (isLive(context) && _jsx(Spinner, { size: 12, className: "shrink-0 self-center text-text-faint" }))] }), _jsx(Meter, { value: value ?? null, max: limit, label: label === '' ? t('blocks.progress.label') : label, valueText: reading ?? undefined })] }));
55
64
  }
56
65
  export function StepsRenderer({ children }) {
57
- return _jsx("ol", { className: "chat-ui-nodes flex flex-col px-2", children: children });
66
+ return _jsx("ol", { className: "chat-ui-nodes flex flex-col gap-1.5", children: children });
58
67
  }
59
- /* A running step spins only while the block is live; in a quiet block it is a still dot. */
68
+ /* A tile per step with its state in words at the end. A running step spins only while the block is live; in a quiet block it is a still dot. */
60
69
  export function StepRenderer({ node, children, context }) {
61
70
  const { t } = useTranslation('agent-chat');
62
71
  const { state, detail } = node.props;
63
72
  const quiet = state === 'pending' || state === 'skipped';
64
- return (_jsxs("li", { className: "flex min-h-7 items-center gap-2 text-sm", children: [_jsx("span", { className: clsx('grid size-4 shrink-0 place-items-center', STEP_TONES[state]), children: state === 'running' ? (isLive(context) ? (_jsx(Spinner, { size: 14 })) : (_jsx(Icon, { icon: CircleDot, size: 14 }))) : (_jsx(Icon, { icon: STEP_ICONS[state], size: 14 })) }), _jsx("span", { className: "sr-only", children: t(`blocks.step.${state}`) }), _jsxs("span", { className: "flex min-w-0 grow flex-wrap items-baseline justify-between gap-x-2", children: [_jsx("span", { className: clsx('max-w-full min-w-0 grow basis-48 wrap-break-word', quiet ? 'text-text-faint' : 'text-text'), children: children }), detail !== undefined && detail !== '' && _jsx("span", { className: "max-w-full text-xs wrap-anywhere text-text-faint tabular-nums", children: detail })] })] }));
73
+ return (_jsxs("li", { className: "flex min-h-10 items-start gap-3 rounded-xl bg-ui-tile px-3 py-2.5 text-xs", children: [_jsx("span", { className: clsx('mt-0.5 grid size-4 shrink-0 place-items-center', STEP_TONES[state]), children: state === 'running' ? (isLive(context) ? (_jsx(Spinner, { size: 14 })) : (_jsx(Icon, { icon: CircleDot, size: 14 }))) : (_jsx(Icon, { icon: STEP_ICONS[state], size: 14 })) }), _jsxs("span", { className: "flex min-w-0 grow flex-wrap items-baseline gap-x-2", children: [_jsx("span", { className: clsx('max-w-full min-w-0 font-medium wrap-break-word', quiet ? 'text-text-muted' : 'text-text'), children: children }), detail !== undefined && detail !== '' && _jsx("span", { className: "max-w-full text-2xs wrap-anywhere text-text-muted tabular-nums", children: detail })] }), _jsx(Pill, { shape: "tag", size: "sm", tone: UI_STEP_PILL[state], className: "shrink-0", children: t(`blocks.step.${state}`) })] }));
65
74
  }
66
75
  export function TimelineRenderer({ children }) {
67
- return _jsx("ol", { className: "chat-ui-nodes flex flex-col px-2", children: children });
76
+ return _jsx("ol", { className: "chat-ui-nodes chat-ui-flat flex flex-col rounded-xl bg-ui-tile px-3 py-1.5", children: children });
68
77
  }
69
- /* Each row draws its own stretch of the line, down to the next dot, so a row that failed to draw leaves the others whole. */
78
+ /* A row of the tile: the time, a dot in the tone, the title, and a short detail as a tag at the end or a longer one under the title. */
70
79
  export function EventRenderer({ node, children }) {
71
80
  const { t } = useTranslation('agent-chat');
72
81
  const { at, detail } = node.props;
@@ -74,5 +83,7 @@ export function EventRenderer({ node, children }) {
74
83
  const time = uiEventTime(at);
75
84
  // A neutral dot says nothing the title does not, and a detail is part of what the row says.
76
85
  const label = [time, uiNodeLabel(node), tone === 'neutral' ? '' : t(`blocks.event.tone.${tone}`), detail ?? ''].filter((part) => part !== '').join(', ');
77
- return (_jsxs("li", { "aria-label": label, className: "group/event relative flex min-h-7 gap-3 pb-3 last:pb-0", children: [_jsx("span", { "aria-hidden": true, className: clsx('mt-1 size-2 shrink-0 rounded-full bg-current', UI_TONE_TEXT[tone]) }), _jsx("span", { "aria-hidden": true, className: "absolute top-3.5 bottom-0 left-0.75 w-px bg-border group-last/event:hidden" }), _jsxs("div", { className: "flex min-w-0 grow flex-col gap-0.5", children: [parseUiEventTime(at) === null ? (_jsx("span", { className: "text-xs wrap-anywhere text-text-muted tabular-nums", children: time })) : (_jsx("time", { dateTime: at, className: "text-xs text-text-muted tabular-nums", children: time })), _jsx("div", { className: "text-sm wrap-break-word text-text", children: children }), detail !== undefined && detail !== '' && _jsx("div", { className: "text-xs wrap-anywhere text-text-muted", children: detail })] })] }));
86
+ const described = detail !== undefined && detail !== '';
87
+ const tagged = described && detail.length <= DETAIL_AS_TAG;
88
+ return (_jsxs("li", { "aria-label": label, className: "flex min-h-9 items-start gap-3 py-2 text-xs", children: [parseUiEventTime(at) === null ? (_jsx("span", { className: "w-24 shrink-0 text-2xs leading-5 wrap-anywhere text-text-muted tabular-nums", children: time })) : (_jsx("time", { dateTime: at, className: "w-24 shrink-0 text-2xs leading-5 text-text-muted tabular-nums", children: time })), _jsx("span", { "aria-hidden": true, className: clsx('mt-1.5 size-2 shrink-0 rounded-full bg-current', tone === 'neutral' ? 'text-text-faint' : UI_TONE_TEXT[tone]) }), _jsxs("div", { className: "flex min-w-0 grow flex-col gap-0.5", children: [_jsx("div", { className: "wrap-break-word text-text", children: children }), described && !tagged && _jsx("div", { className: "text-2xs wrap-anywhere text-text-muted", children: detail })] }), tagged && (_jsx(Pill, { shape: "tag", size: "sm", tone: UI_TONE_PILL[tone], className: "shrink-0", children: detail }))] }));
78
89
  }
@@ -5,3 +5,5 @@ export declare const UI_TONE_ICONS: Readonly<Record<UiTone, LucideIcon>>;
5
5
  export declare const UI_TONE_TEXT: Readonly<Record<UiTone, string>>;
6
6
  export declare const UI_TONE_SURFACE: Readonly<Record<UiTone, string>>;
7
7
  export declare const UI_TONE_PILL: Readonly<Record<UiTone, NonNullable<PillProps['tone']>>>;
8
+ export declare const UI_TONE_FILL: Readonly<Record<UiTone, string>>;
9
+ export declare const UI_STEP_PILL: Readonly<Record<'done' | 'running' | 'pending' | 'failed' | 'skipped', NonNullable<PillProps['tone']>>>;
@@ -29,3 +29,19 @@ export const UI_TONE_PILL = {
29
29
  warning: 'needsYou',
30
30
  danger: 'error'
31
31
  };
32
+ /* The tone at full strength, for the square that carries the icon of a Summary. */
33
+ export const UI_TONE_FILL = {
34
+ neutral: 'bg-text-muted',
35
+ info: 'bg-status-running',
36
+ success: 'bg-positive',
37
+ warning: 'bg-status-needs-you',
38
+ danger: 'bg-status-error'
39
+ };
40
+ /* The tag at the end of a step, in words, so a state never rests on its icon alone. */
41
+ export const UI_STEP_PILL = {
42
+ done: 'idle',
43
+ running: 'accent',
44
+ pending: 'muted',
45
+ failed: 'error',
46
+ skipped: 'muted'
47
+ };
package/dist/theme.css CHANGED
@@ -30,6 +30,12 @@
30
30
  /* The two grounds of the map a page draws without tiles. */
31
31
  --map-land: #f2f1ed;
32
32
  --map-water: #d3dde5;
33
+ /* The parts of an intelligent UI block, from the tokens an app's palette sets: a tile on the thread's
34
+ ground, a card inset in a tile, and the hatch of its band. The tile mixes the ink in so lightly that
35
+ muted text keeps 4.5 to 1 on it; the design's #f0f0f3 did not. */
36
+ --chat-ui-tile: color-mix(in srgb, var(--text) 4.5%, var(--surface));
37
+ --chat-ui-inset: var(--surface);
38
+ --chat-ui-hatch: var(--separator);
33
39
  }
34
40
 
35
41
  [data-theme="dark"] {
@@ -51,6 +57,9 @@
51
57
  --chart-6: #008300;
52
58
  --map-land: #2c3135;
53
59
  --map-water: #1a2026;
60
+ --chat-ui-tile: color-mix(in srgb, var(--text) 3%, var(--surface));
61
+ --chat-ui-inset: var(--surface);
62
+ --chat-ui-hatch: var(--separator);
54
63
  }
55
64
 
56
65
  /* Output and diffs take the terminal's colors when the app loads `@adecore/terminal/terminal.css`,
@@ -82,6 +91,8 @@
82
91
  --color-chat-added: var(--chat-added);
83
92
  --color-chat-removed: var(--chat-removed);
84
93
  --color-map-marker: var(--map-marker);
94
+ --color-ui-tile: var(--chat-ui-tile);
95
+ --color-ui-inset: var(--chat-ui-inset);
85
96
  --color-map-marker-text: var(--map-marker-text);
86
97
  }
87
98
 
@@ -155,6 +166,24 @@
155
166
  @media (prefers-reduced-motion: reduce) {
156
167
  .chat-ui-changed { animation: none; }
157
168
  }
169
+ /*
170
+ * A block stands in a band across the whole thread, hatched above and below, with its tiles at the
171
+ * reading width. The thread's scroller is the container the band measures, so a block anywhere
172
+ * else keeps the width of its column.
173
+ */
174
+ .chat-scroll { container-type: inline-size; }
175
+ .chat-ui-band { position: relative; padding-block: 14px; }
176
+ .chat-ui-band::before, .chat-ui-band::after {
177
+ content: "";
178
+ position: absolute;
179
+ inset-inline: 0;
180
+ height: 14px;
181
+ border-block: 1px solid var(--border);
182
+ background-image: repeating-linear-gradient(-45deg, var(--chat-ui-hatch) 0 1px, transparent 1px 7px);
183
+ }
184
+ .chat-ui-band::before { top: 0; }
185
+ .chat-ui-band::after { bottom: 0; }
186
+ .chat-scroll .chat-ui-band::before, .chat-scroll .chat-ui-band::after { inset-inline: calc(50% - 50cqw); }
158
187
  /* A source row a citation revealed. The row only takes focus from code, since its own button takes a click. */
159
188
  .chat-ui-source:focus { animation: chat-flash 1600ms ease-out both; }
160
189
  /* A visual still on its way says so only once it takes long enough to be noticed. */
@@ -225,6 +254,15 @@
225
254
 
226
255
  /* A code block is shiki's own `<pre>`, which brings a background of its own to override. */
227
256
  .chat-code { position: relative; min-height: 38px; border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: hidden; }
257
+ /* A code block in an intelligent UI block is a card inset in its tile. */
258
+ .chat-ui-code .chat-code { background: var(--chat-ui-inset); }
259
+ /* Inside a Section the section is the tile. A tile that only frames its parts goes flat, so they keep the
260
+ section's own margin, and a card among them takes the inset ground so it still reads as one. Unlayered, so it outweighs
261
+ the utilities it overrides. */
262
+ .chat-ui-section-body .bg-ui-tile { background-color: var(--chat-ui-inset); }
263
+ .chat-ui-section-body .chat-ui-flat { background-color: transparent; padding: 0; }
264
+ .chat-ui-section-body .chat-ui-flat .chat-ui-flat-inner { padding-inline: 0; }
265
+ .chat-ui-section-body .chat-ui-flat thead { background-color: var(--chat-ui-tile); }
228
266
  .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); overflow: auto; user-select: text; background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
229
267
  .chat-code code { font-family: inherit; }
230
268
  .chat-code-actions { position: absolute; top: 4px; right: 4px; border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); box-shadow: 0 0 0 1px var(--border); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.28.0",
71
- "@adecore/editor": "0.28.0",
72
- "@adecore/editor-react": "0.28.0",
73
- "@adecore/intelligent-ui": "0.28.0",
74
- "@adecore/merge": "0.28.0",
70
+ "@adecore/agent-contracts": "0.29.0",
71
+ "@adecore/editor": "0.29.0",
72
+ "@adecore/editor-react": "0.29.0",
73
+ "@adecore/intelligent-ui": "0.29.0",
74
+ "@adecore/merge": "0.29.0",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.28.0",
97
+ "@adecore/ui": "^0.29.0",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.28.0",
104
+ "@adecore/ui": "0.29.0",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -1,9 +1,10 @@
1
- import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
1
+ import { useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiViewNode } from '@adecore/intelligent-ui';
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';
7
8
  import { formatAgo, formatMoment } from '@adecore/ui/format';
8
9
  import { uiBlockHead, uiReasonText } from './node-text';
9
10
  import { UiHeadContext, type UiLiveStatus } from './render-context';
@@ -48,7 +49,7 @@ function LivePart({ live }: { live: UiLiveStatus }) {
48
49
  ) : live.state === 'refused' ? (
49
50
  <Icon icon={Lock} size={12} />
50
51
  ) : (
51
- <Icon icon={Radio} size={12} className="text-status-idle" />
52
+ <Icon icon={Radio} size={12} className="text-positive" />
52
53
  );
53
54
  const words =
54
55
  live.state === 'failed'
@@ -81,10 +82,7 @@ function LivePart({ live }: { live: UiLiveStatus }) {
81
82
  return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
82
83
  }
83
84
 
84
- /*
85
- * The line under a block, only when it has something to say: live, answered, noted or drawn as
86
- * text, in that order. A hairline of the soft border runs the full width of the card over it.
87
- */
85
+ /* The line under a block, only when it has something to say: live, answered, noted or drawn as text, in that order. */
88
86
  function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
89
87
  const { t } = useTranslation('agent-chat');
90
88
  const [fixesOpen, setFixesOpen] = useState(false);
@@ -93,7 +91,7 @@ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAs
93
91
  return null;
94
92
  }
95
93
  return (
96
- <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">
94
+ <div className="flex flex-col gap-1.5 text-2xs text-text-faint">
97
95
  <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
98
96
  {live !== null && <LivePart live={live} />}
99
97
  {answered !== null && (
@@ -189,16 +187,28 @@ function useRisingFloor(active: boolean): [RefObject<HTMLDivElement | null>, num
189
187
  }
190
188
 
191
189
  /*
192
- * The card of one block, with its footer. Named by its Summary and busy while it streams, but never
190
+ * The band of one block, with its footer. Named by its Summary and busy while it streams, but never
193
191
  * live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
194
192
  * and for a person who asked for less motion it only fades; a node inside another only fades
195
193
  * (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
196
194
  */
197
195
  export function UiBlockFrame({ nodes, phase, children, className, ...footer }: UiBlockFrameProps) {
198
196
  const { t } = useTranslation('agent-chat');
197
+ const host = useChatHost();
199
198
  const streaming = phase === 'streaming';
200
199
  const head = uiBlockHead(nodes);
201
200
  const [ref, floor] = useRisingFloor(streaming);
201
+ // Only while the focus is inside the block, so the composer keeps the same keys for sending.
202
+ const pickPrimary = (event: KeyboardEvent<HTMLDivElement>): void => {
203
+ if (!matchesShortcut(KEY_SHORTCUTS.modEnter, event, host.isApplePlatform())) {
204
+ return;
205
+ }
206
+ const primary = event.currentTarget.querySelector<HTMLButtonElement>('[data-ui-primary-choice]');
207
+ if (primary !== null) {
208
+ event.preventDefault();
209
+ primary.click();
210
+ }
211
+ };
202
212
  return (
203
213
  <UiHeadContext value={head?.id ?? null}>
204
214
  <div
@@ -206,13 +216,16 @@ export function UiBlockFrame({ nodes, phase, children, className, ...footer }: U
206
216
  role="group"
207
217
  aria-label={head === null || head.label === '' ? t('blocks.label') : head.label}
208
218
  aria-busy={streaming || undefined}
209
- className={clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className)}
219
+ className={clsx('chat-ui-band flex min-h-11 flex-col', className)}
210
220
  style={streaming && floor > 0 ? { minHeight: floor } : undefined}
221
+ onKeyDown={pickPrimary}
211
222
  >
212
- <div className="flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5">
213
- {nodes.length === 0 && streaming ? <Skeleton className="mx-2 my-1 w-2/5" /> : children}
223
+ <div className="flex flex-col gap-3 py-5">
224
+ <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">
225
+ {nodes.length === 0 && streaming ? <Skeleton className="my-1 w-2/5" /> : children}
226
+ </div>
227
+ <UiBlockFooter {...footer} />
214
228
  </div>
215
- <UiBlockFooter {...footer} />
216
229
  </div>
217
230
  </UiHeadContext>
218
231
  );
@@ -1,23 +1,26 @@
1
1
  import { useTranslation } from 'react-i18next';
2
+ import { Info } from 'lucide-react';
3
+ import { Icon } from '@adecore/ui';
2
4
  import { Markdown } from '../Markdown';
3
5
 
4
6
  /* Why a part is drawn as its text: a component this version does not know, or one it could not draw. */
5
7
  export type UiFallbackProblem = { kind: 'unknown'; component: string } | { kind: 'failed' };
6
8
 
7
9
  /*
8
- * One part of a block as the markdown it stands for, in a quiet surface with a line that says why.
10
+ * One part of a block as the markdown it stands for, on a card in a quiet tile under a line that says why.
9
11
  * A person sees it as a repair, never as an error, so nothing here is red.
10
12
  */
11
13
  export function UiFallbackPart({ fallback, problem }: { fallback: string; problem: UiFallbackProblem }) {
12
14
  const { t } = useTranslation('agent-chat');
13
15
  return (
14
- <div className="flex flex-col gap-1 rounded-md bg-surface-hover p-2">
15
- <span className="text-xs text-text-faint">
16
+ <div className="chat-ui-flat flex flex-col gap-1.5 rounded-xl bg-ui-tile p-1.5">
17
+ <span className="chat-ui-flat-inner flex items-center gap-1.5 px-1.5 pt-0.5 text-2xs text-text-muted">
18
+ <Icon icon={Info} size={12} className="shrink-0" />
16
19
  {problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')}
17
20
  </span>
18
21
  {fallback !== '' && (
19
22
  // A fallback is often data without a space in it, such as the JSON of a chart.
20
- <div className="min-w-0 wrap-anywhere">
23
+ <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)]">
21
24
  <Markdown text={fallback} fileLinks={false} />
22
25
  </div>
23
26
  )}
@@ -68,8 +68,8 @@ const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch',
68
68
 
69
69
  /* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
70
70
  const PARAGRAPHS: Readonly<Record<string, string>> = {
71
- [BLOCK]: 'px-2 text-xs text-text',
72
- Tab: 'px-2 text-xs text-text',
71
+ [BLOCK]: 'text-xs text-text',
72
+ Tab: 'text-xs text-text',
73
73
  Section: 'text-xs text-text-muted'
74
74
  };
75
75
 
@@ -198,7 +198,7 @@ function ProseLink({ href, children }: { href?: string; children?: ReactNode })
198
198
 
199
199
  const PROSE_COMPONENTS: Components = {
200
200
  p: ({ children }) => children,
201
- code: ({ children }) => <code className="rounded-sm bg-surface-sunken px-1 py-px font-mono text-code">{children}</code>,
201
+ code: ({ children }) => <code className="rounded-sm bg-ui-tile px-1 py-px font-mono text-code">{children}</code>,
202
202
  a: ({ href, children }) => <ProseLink href={href}>{children}</ProseLink>,
203
203
  span: ({ node, children, className }) =>
204
204
  node?.properties['data-ui-citation'] !== undefined ? (
@@ -442,7 +442,7 @@ function DrawnChart({
442
442
  const values = useMemo(() => uiChartValues(data), [data]);
443
443
  const { kind } = chart;
444
444
  return (
445
- <figure className="flex flex-col gap-2 px-2">
445
+ <figure className="chat-ui-flat flex flex-col gap-2.5 rounded-xl bg-ui-tile p-3.5">
446
446
  {chart.series.length > 1 && <Legend chart={chart} />}
447
447
  {kind !== 'stacked' && unitApart(unit) && <p className="text-xs text-text-faint">{unit}</p>}
448
448
  <div ref={measure} role="img" aria-label={chartSummary(values, unit, t)} className="relative">
@@ -475,7 +475,7 @@ export function ChartRenderer({ node, context }: UiRendererProps<UiProps<'Chart'
475
475
  const { kind, data, unit } = node.props;
476
476
  const chart = useMemo(() => (node.complete ? uiChartData(data, kind) : null), [node.complete, data, kind]);
477
477
  if (!node.complete) {
478
- return <div aria-hidden className="mx-2 rounded-md bg-surface-hover" style={{ height: chartHeight(kind, data.length) }} />;
478
+ return <div aria-hidden className="rounded-xl bg-ui-tile" style={{ height: chartHeight(kind, data.length) }} />;
479
479
  }
480
480
  if (chart === null) {
481
481
  return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'failed' }} />;
@@ -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
  )}