@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.
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +19 -9
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +4 -2
- package/dist/chat/ui/intelligent-ui/UiReply.js +3 -3
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +2 -2
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +13 -8
- package/dist/chat/ui/intelligent-ui/renderers/content.js +15 -14
- package/dist/chat/ui/intelligent-ui/renderers/data.js +19 -6
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +10 -9
- package/dist/chat/ui/intelligent-ui/renderers/links.js +5 -3
- package/dist/chat/ui/intelligent-ui/renderers/map.js +7 -7
- package/dist/chat/ui/intelligent-ui/renderers/structure.js +5 -5
- package/dist/chat/ui/intelligent-ui/renderers/text.js +25 -14
- package/dist/chat/ui/intelligent-ui/tones.d.ts +2 -0
- package/dist/chat/ui/intelligent-ui/tones.js +16 -0
- package/dist/theme.css +38 -0
- package/package.json +8 -8
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +26 -13
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +7 -4
- package/src/chat/ui/intelligent-ui/UiReply.tsx +3 -3
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +2 -2
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +33 -19
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +36 -23
- package/src/chat/ui/intelligent-ui/renderers/data.tsx +61 -19
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +21 -14
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +7 -4
- package/src/chat/ui/intelligent-ui/renderers/map.tsx +15 -12
- package/src/chat/ui/intelligent-ui/renderers/structure.tsx +6 -6
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +63 -42
- package/src/chat/ui/intelligent-ui/tones.ts +18 -0
- 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
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
|
37
|
-
return (_jsxs("div", { className:
|
|
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('
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
71
|
-
"@adecore/editor": "0.
|
|
72
|
-
"@adecore/editor-react": "0.
|
|
73
|
-
"@adecore/intelligent-ui": "0.
|
|
74
|
-
"@adecore/merge": "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.
|
|
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.
|
|
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-
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
213
|
-
|
|
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
|
|
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-
|
|
15
|
-
<span className="text-
|
|
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]: '
|
|
72
|
-
Tab: '
|
|
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-
|
|
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
|
|
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="
|
|
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 {
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
<
|
|
74
|
-
|
|
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=
|
|
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=
|
|
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}>{
|
|
108
|
+
<DisabledReason reason={reason}>{button}</DisabledReason>
|
|
95
109
|
) : described && detail.length > LONG_CONTEXT ? (
|
|
96
|
-
<Tooltip label={detail}>{
|
|
110
|
+
<Tooltip label={detail}>{button}</Tooltip>
|
|
97
111
|
) : (
|
|
98
|
-
|
|
112
|
+
button
|
|
99
113
|
)}
|
|
100
114
|
{failed && (
|
|
101
|
-
<p role="alert" className="px-
|
|
115
|
+
<p role="alert" className="px-1 text-2xs text-status-error">
|
|
102
116
|
{t('blocks.choice.failed')}
|
|
103
117
|
</p>
|
|
104
118
|
)}
|