@adecore/agents-react 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/chat/logic/generated-image.d.ts +27 -0
  2. package/dist/chat/logic/generated-image.js +32 -0
  3. package/dist/chat/logic/timeline-target.d.ts +13 -1
  4. package/dist/chat/logic/timeline-target.js +26 -0
  5. package/dist/chat/logic/timeline.js +16 -10
  6. package/dist/chat/subagent-conversation.d.ts +2 -1
  7. package/dist/chat/subagent-conversation.js +7 -1
  8. package/dist/chat/timeline-flash.d.ts +3 -1
  9. package/dist/chat/timeline-flash.js +2 -2
  10. package/dist/chat/ui/ImageView.d.ts +8 -2
  11. package/dist/chat/ui/ImageView.js +12 -6
  12. package/dist/chat/ui/SubagentTimeline.js +6 -2
  13. package/dist/chat/ui/Timeline.js +67 -7
  14. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +23 -0
  15. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +103 -0
  16. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  17. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  18. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +15 -0
  19. package/dist/chat/ui/intelligent-ui/UiReply.js +329 -0
  20. package/dist/chat/ui/intelligent-ui/block-local.d.ts +11 -0
  21. package/dist/chat/ui/intelligent-ui/block-local.js +42 -0
  22. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  23. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  24. package/dist/chat/ui/intelligent-ui/node-text.d.ts +15 -0
  25. package/dist/chat/ui/intelligent-ui/node-text.js +106 -0
  26. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  27. package/dist/chat/ui/intelligent-ui/registry.js +51 -0
  28. package/dist/chat/ui/intelligent-ui/render-context.d.ts +53 -0
  29. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  30. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  31. package/dist/chat/ui/intelligent-ui/renderers/chart.js +123 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/choices.js +45 -0
  34. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/content.js +59 -0
  36. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  37. package/dist/chat/ui/intelligent-ui/renderers/data.js +80 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +8 -0
  39. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +72 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/links.js +87 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  46. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  47. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  48. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  49. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  50. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  51. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  52. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  53. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  54. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  55. package/dist/chat/ui/intelligent-ui/use-ui-links.js +126 -0
  56. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +21 -0
  57. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +272 -0
  58. package/dist/chat/ui/reply-context.d.ts +8 -0
  59. package/dist/chat/ui/reply-context.js +1 -0
  60. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  61. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  62. package/dist/chat/ui/rows/MessageRows.js +21 -4
  63. package/dist/chat/ui/rows/Rows.js +4 -2
  64. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  65. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  66. package/dist/chat/ui/rows/WorkRows.js +4 -3
  67. package/dist/host.d.ts +11 -1
  68. package/dist/locales/en/agent-chat.json +101 -0
  69. package/dist/locales/nl/agent-chat.json +101 -0
  70. package/dist/state/chats.js +13 -0
  71. package/dist/theme.css +19 -0
  72. package/package.json +128 -7
  73. package/src/chat/logic/generated-image.ts +48 -0
  74. package/src/chat/logic/timeline-target.ts +41 -1
  75. package/src/chat/logic/timeline.ts +19 -10
  76. package/src/chat/subagent-conversation.ts +13 -2
  77. package/src/chat/timeline-flash.ts +5 -3
  78. package/src/chat/ui/ImageView.tsx +51 -8
  79. package/src/chat/ui/SubagentTimeline.tsx +24 -1
  80. package/src/chat/ui/Timeline.tsx +97 -21
  81. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +219 -0
  82. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  83. package/src/chat/ui/intelligent-ui/UiReply.tsx +541 -0
  84. package/src/chat/ui/intelligent-ui/block-local.ts +51 -0
  85. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  86. package/src/chat/ui/intelligent-ui/node-text.ts +119 -0
  87. package/src/chat/ui/intelligent-ui/registry.ts +53 -0
  88. package/src/chat/ui/intelligent-ui/render-context.ts +84 -0
  89. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +319 -0
  90. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +102 -0
  91. package/src/chat/ui/intelligent-ui/renderers/content.tsx +140 -0
  92. package/src/chat/ui/intelligent-ui/renderers/data.tsx +181 -0
  93. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +144 -0
  94. package/src/chat/ui/intelligent-ui/renderers/links.tsx +158 -0
  95. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  96. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  97. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  98. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  99. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  100. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  101. package/src/chat/ui/intelligent-ui/use-ui-links.ts +143 -0
  102. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +308 -0
  103. package/src/chat/ui/reply-context.ts +7 -0
  104. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  105. package/src/chat/ui/rows/MessageRows.tsx +60 -4
  106. package/src/chat/ui/rows/Rows.tsx +4 -2
  107. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  108. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  109. package/src/host.ts +27 -1
  110. package/src/locales/en/agent-chat.json +101 -0
  111. package/src/locales/nl/agent-chat.json +101 -0
  112. package/src/state/chats.ts +13 -0
  113. package/src/theme.css +19 -0
@@ -0,0 +1,119 @@
1
+ import type { TFunction } from 'i18next';
2
+ import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
+ import type { UiLiveStatus } from './render-context';
4
+
5
+ /* The text a node holds, every `$text` below it joined as written, such as the code of a CodeBlock. */
6
+ export function uiNodeText(node: UiViewNode): string {
7
+ return node.children.map((child) => (child.type === '$text' ? String(child.props.text ?? '') : uiNodeText(child))).join('');
8
+ }
9
+
10
+ /* The same text on one line, for a name a control or a screen reader needs as a string. */
11
+ export function uiNodeLabel(node: UiViewNode): string {
12
+ return uiNodeText(node).replace(/\s+/g, ' ').trim();
13
+ }
14
+
15
+ /* The valid children of one kind, which a parent reads as metadata: the columns of a table, the tabs of a strip. */
16
+ export function uiChildrenOf<Name extends UiComponentName>(node: UiViewNode, type: Name): UiViewNode<UiProps<Name>>[] {
17
+ return node.children.filter((child) => child.type === type && child.error === undefined) as UiViewNode<UiProps<Name>>[];
18
+ }
19
+
20
+ /* A Summary draws its text as inline Markdown, so the name it gives its block leaves out the code ticks, the strong marks and the address of a link. */
21
+ function withoutMarks(text: string): string {
22
+ return text
23
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
24
+ .replace(/\*\*|`/g, '')
25
+ .trim();
26
+ }
27
+
28
+ /* The Summary that heads a block, which is its first node; a Summary further down is a subheading. */
29
+ export function uiBlockHead(nodes: readonly UiViewNode[]): { id: string; label: string } | null {
30
+ const first = nodes[0];
31
+ if (first === undefined || first.type !== 'Summary' || first.error !== undefined) {
32
+ return null;
33
+ }
34
+ return { id: first.id, label: withoutMarks(uiNodeLabel(first)) };
35
+ }
36
+
37
+ /*
38
+ * Scrolls the Source row of `nodeId` under `root` into view and focuses it, which lights the row once.
39
+ * Opens enclosing tabs and sections first; false when the source is absent.
40
+ */
41
+ export async function revealUiSource(root: ParentNode, nodeId: string, ancestors: readonly string[] = []): Promise<boolean> {
42
+ for (const id of ancestors) {
43
+ const control = [...root.querySelectorAll<HTMLButtonElement>('[data-ui-reveal]')].find((element) => element.dataset.uiReveal === id);
44
+ if (control && (control.getAttribute('aria-expanded') === 'false' || control.getAttribute('aria-selected') === 'false')) {
45
+ control.click();
46
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
47
+ }
48
+ }
49
+ const row = [...root.querySelectorAll<HTMLElement>('[data-ui-source]')].find((element) => element.dataset.uiSource === nodeId);
50
+ if (row === undefined) {
51
+ return false;
52
+ }
53
+ // The light runs when focus arrives, so a row that already has it lets go first.
54
+ if (row.ownerDocument.activeElement === row) {
55
+ row.blur();
56
+ }
57
+ row.focus({ preventScroll: true });
58
+ const reduced = row.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
59
+ row.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
60
+ return true;
61
+ }
62
+
63
+ /* The reason codes this version says in its own words; any other code reads as the reason the machine gave. */
64
+ const UI_REASON_CODES = new Set([
65
+ 'access-unavailable',
66
+ 'block-stale',
67
+ 'busy',
68
+ 'link-unchecked',
69
+ 'link-unsupported',
70
+ 'links-unsupported',
71
+ 'no-unique-match',
72
+ 'outside-project',
73
+ 'query-undeclared',
74
+ 'refresh-limit',
75
+ 'result-too-large',
76
+ 'snapshot-too-large',
77
+ 'source-unregistered',
78
+ 'stale-read',
79
+ 'target-unavailable',
80
+ 'timed-out',
81
+ 'unreadable'
82
+ ]);
83
+
84
+ /* Why a reading failed or a link stayed plain, in the person's language where the code is known. */
85
+ export function uiReasonText(t: TFunction<'agent-chat'>, code: string | undefined, reason: string | undefined): string | undefined {
86
+ return code !== undefined && UI_REASON_CODES.has(code) ? t(`blocks.live.reasons.${code}`) : reason;
87
+ }
88
+
89
+ /*
90
+ * A number with its unit, where a percent sign hugs the number and every other unit stands apart
91
+ * from it, by a space that never lets the two wrap apart.
92
+ */
93
+ export function uiWithUnit(text: string, unit: string | undefined): string {
94
+ if (unit === undefined || unit === '') {
95
+ return text;
96
+ }
97
+ return unit === '%' ? `${text}${unit}` : `${text}\u00a0${unit}`;
98
+ }
99
+
100
+ /* Why the choices of a live block are closed: its data is still being read, or the reason a reading failed. Null while nothing live holds them. */
101
+ export function uiLiveChoiceReason(t: TFunction<'agent-chat'>, live: UiLiveStatus | null | undefined): string | null {
102
+ if (live === null || live === undefined || live.state === 'fresh') {
103
+ return null;
104
+ }
105
+ if (live.state === 'reading') {
106
+ return t('blocks.choice.reading');
107
+ }
108
+ const source = live.source ?? live.sources[0] ?? '';
109
+ return uiReasonText(t, live.code, live.reason) ?? t(live.state === 'failed' ? 'blocks.live.failed' : 'blocks.live.refused', { source });
110
+ }
111
+
112
+ /* The host of an address without its `www.`, which is how a source or a link in prose says where it goes. */
113
+ export function uiUrlDomain(url: string): string {
114
+ try {
115
+ return new URL(url).hostname.replace(/^www\./, '');
116
+ } catch {
117
+ return url;
118
+ }
119
+ }
@@ -0,0 +1,53 @@
1
+ import type { UiRenderers } from './render-context';
2
+ import { ChartRenderer } from './renderers/chart';
3
+ import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices';
4
+ import { CodeBlockRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content';
5
+ import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data';
6
+ import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs';
7
+ import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links';
8
+ import { SectionRenderer, SectionsRenderer, TabRenderer, TabsRenderer } from './renderers/structure';
9
+ import { CalloutRenderer, ProgressRenderer, StepRenderer, StepsRenderer, SummaryRenderer, TagRenderer } from './renderers/text';
10
+
11
+ /*
12
+ * A renderer for every component of the catalog; a name missing here fails the typecheck. Null draws
13
+ * nothing for the node itself: the runtime resolves Show and Each before rendering, and Column and
14
+ * Option are metadata their parent reads from its node (a table its columns, Segmented its segments).
15
+ */
16
+ export const UI_RENDERERS: UiRenderers = {
17
+ Summary: SummaryRenderer,
18
+ Callout: CalloutRenderer,
19
+ Tag: TagRenderer,
20
+ Progress: ProgressRenderer,
21
+ Steps: StepsRenderer,
22
+ Step: StepRenderer,
23
+ Stats: StatsRenderer,
24
+ Stat: StatRenderer,
25
+ EntityList: EntityListRenderer,
26
+ Entry: EntryRenderer,
27
+ Table: TableRenderer,
28
+ Column: null,
29
+ Chart: ChartRenderer,
30
+ Tabs: TabsRenderer,
31
+ Tab: TabRenderer,
32
+ Sections: SectionsRenderer,
33
+ Section: SectionRenderer,
34
+ CodeBlock: CodeBlockRenderer,
35
+ Image: ImageRenderer,
36
+ Sources: SourcesRenderer,
37
+ Source: SourceRenderer,
38
+ File: FileRenderer,
39
+ Diff: DiffRenderer,
40
+ Commit: CommitRenderer,
41
+ Node: NodeRenderer,
42
+ Checklist: ChecklistRenderer,
43
+ Item: ItemRenderer,
44
+ Switch: SwitchRenderer,
45
+ Slider: SliderRenderer,
46
+ Segmented: SegmentedRenderer,
47
+ Option: null,
48
+ Button: ButtonRenderer,
49
+ Show: null,
50
+ Each: null,
51
+ Choices: ChoicesRenderer,
52
+ Choice: ChoiceRenderer
53
+ };
@@ -0,0 +1,84 @@
1
+ import { createContext, type ReactNode } from 'react';
2
+ import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
+
4
+ export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
5
+ import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
6
+
7
+ /* How the host lets a link be drawn: a chip it can open, or plain text it cannot. */
8
+ export interface UiLink {
9
+ state: 'chip' | 'plain';
10
+ /* What the host calls the target, such as the title of a node or the subject of a commit. */
11
+ label?: string;
12
+ /* Why a plain link is not a chip, in its tooltip. */
13
+ reason?: string;
14
+ /* The stable code of that reason, which a client words itself where it knows it. */
15
+ code?: string;
16
+ }
17
+
18
+ /* The Choice that answered a block. `sending` already reads as sent, so a second click does nothing. */
19
+ export interface UiAnswer {
20
+ choiceId: string;
21
+ state: 'sending' | 'sent' | 'queued';
22
+ }
23
+
24
+ /* What the footer of a block that reads live data says about its last reading. */
25
+ export interface UiLiveStatus {
26
+ state: 'fresh' | 'reading' | 'failed' | 'refused';
27
+ /* What the block reads, by the names a person knows them by. */
28
+ sources: readonly string[];
29
+ /* The last reading that succeeded, in milliseconds since the epoch; null when nothing was read yet. */
30
+ readAt: number | null;
31
+ /* The source that failed or was refused. */
32
+ source?: string;
33
+ /* Why a reading failed, in its tooltip. */
34
+ reason?: string;
35
+ /* The stable code of that reason, which a client words itself where it knows it. */
36
+ code?: string;
37
+ }
38
+
39
+ /* Live history is separate from the comparison the agent supplies in Stat.previous. */
40
+ export interface UiLiveValue {
41
+ previous?: unknown;
42
+ changed: boolean;
43
+ stale: boolean;
44
+ }
45
+
46
+ /*
47
+ * What every renderer of a block knows besides its node. The callbacks are the host's: left out, a link
48
+ * stays plain text, a source cannot be opened and a choice stays closed.
49
+ */
50
+ export interface UiRenderContext {
51
+ scopeId: string;
52
+ chatId: string;
53
+ itemId: string;
54
+ blockId: string;
55
+ phase: 'streaming' | 'final';
56
+ answer: UiAnswer | null;
57
+ /* The Choice whose last send failed, which opened the block again. */
58
+ failedChoiceId?: string | null;
59
+ live?: UiLiveStatus | null;
60
+ link?(target: UiLinkTarget): UiLink;
61
+ openLink?(target: UiLinkTarget): void;
62
+ openUrl?(url: string): void;
63
+ onChoose?(nodeId: string): void;
64
+ /* Why the choices are closed while `onChoose` is absent, such as live data still being read; without it they say they are not available. */
65
+ choiceReason?: string | null;
66
+ /* Table cells use the evaluated rows index and column key. */
67
+ liveValue?(nodeId: string, prop: string, path?: readonly [row: number, key: string]): UiLiveValue | undefined;
68
+ }
69
+
70
+ export interface UiRendererProps<Props> {
71
+ /* Valid and evaluated; its children are there as metadata, such as the columns of a table. */
72
+ node: UiViewNode<Props>;
73
+ /* The rendered children, in order, each inside its own error boundary. */
74
+ children: ReactNode;
75
+ context: UiRenderContext;
76
+ }
77
+
78
+ export type UiRenderer<Props> = (props: UiRendererProps<Props>) => ReactNode;
79
+
80
+ /* A renderer for every name in the catalog, or null where the runtime draws the node some other way. */
81
+ export type UiRenderers = { readonly [Name in UiComponentName]: UiRenderer<UiProps<Name>> | null };
82
+
83
+ /* The id of the Summary that heads the block. Undefined outside a frame, where every Summary heads. */
84
+ export const UiHeadContext = createContext<string | null | undefined>(undefined);
@@ -0,0 +1,319 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type { TFunction } from 'i18next';
3
+ import clsx from 'clsx';
4
+ import { useTranslation } from 'react-i18next';
5
+ import type { UiProps } from '@adecore/intelligent-ui';
6
+ import { SegmentBar, useMeasuredWidth } from '@adecore/ui';
7
+ import { formatRounded } from '@adecore/ui/format';
8
+ import { niceCeiling, UI_CHART_ROWS, uiChartData, type UiChartData } from '../chart-data';
9
+ import { uiWithUnit } from '../node-text';
10
+ import type { UiRendererProps } from '../render-context';
11
+ import { UiFallbackPart } from '../UiFallbackPart';
12
+
13
+ type ChartKind = UiProps<'Chart'>['kind'];
14
+
15
+ const HEIGHT = 160;
16
+ const HBAR_ROW = 24;
17
+ const STACKED_ROW = 28;
18
+ const AXIS_WIDTH = 44;
19
+ const PLOT_TOP = 8;
20
+ const LABEL_ROOM = 20;
21
+ /* The narrowest band a category label is written under; narrower bands skip labels. */
22
+ const LABEL_BAND = 56;
23
+ /* Points get a dot up to this many categories; more would crowd the line. */
24
+ const DOTS_UP_TO = 30;
25
+ const GROW = 'transition-[scale,opacity] duration-400 ease-out motion-reduce:transition-none';
26
+
27
+ function seriesColor(index: number): string {
28
+ return `var(--chart-${index + 1})`;
29
+ }
30
+
31
+ function valueText(value: number | null, unit: string | undefined): string {
32
+ return value === null ? '' : uiWithUnit(formatRounded(value, 2), unit);
33
+ }
34
+
35
+ /* The height a chart will take, which its place holds while the node streams. */
36
+ function chartHeight(kind: ChartKind, rows: number): number {
37
+ const count = Math.max(1, Math.min(rows, UI_CHART_ROWS));
38
+ return kind === 'hbar' ? count * HBAR_ROW : kind === 'stacked' ? count * STACKED_ROW : HEIGHT;
39
+ }
40
+
41
+ /* One sentence with the main point, which a screen reader hears in place of the drawing. */
42
+ function chartSummary(chart: UiChartData, unit: string | undefined, t: TFunction<'agent-chat'>): string {
43
+ let top: { label: string; series: string; value: number } | null = null;
44
+ for (const series of chart.series) {
45
+ for (let i = 0; i < series.values.length; i++) {
46
+ const value = series.values[i];
47
+ if (value !== null && value !== undefined && (top === null || value > top.value)) {
48
+ top = { label: chart.labels[i] ?? '', series: series.key, value };
49
+ }
50
+ }
51
+ }
52
+ if (top === null) {
53
+ return t('blocks.chart.values');
54
+ }
55
+ return chart.series.length > 1
56
+ ? t('blocks.chart.summarySeries', { count: chart.labels.length, series: top.series, label: top.label, value: valueText(top.value, unit) })
57
+ : t('blocks.chart.summary', { count: chart.labels.length, label: top.label, value: valueText(top.value, unit) });
58
+ }
59
+
60
+ function Legend({ chart }: { chart: UiChartData }) {
61
+ return (
62
+ <div aria-hidden className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted">
63
+ {chart.series.map((series, index) => (
64
+ <span key={series.key} className="flex items-center gap-1.5">
65
+ <span className="size-2 rounded-sm" style={{ backgroundColor: seriesColor(index) }} />
66
+ {series.key}
67
+ </span>
68
+ ))}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ /* The numbers behind the drawing, for a screen reader. */
74
+ function ValuesTable({ chart, unit }: { chart: UiChartData; unit: string | undefined }) {
75
+ const { t } = useTranslation('agent-chat');
76
+ return (
77
+ <table className="sr-only">
78
+ <caption>{t('blocks.chart.values')}</caption>
79
+ <thead>
80
+ <tr>
81
+ <td />
82
+ {chart.series.map((series) => (
83
+ <th key={series.key} scope="col">
84
+ {series.key}
85
+ </th>
86
+ ))}
87
+ </tr>
88
+ </thead>
89
+ <tbody>
90
+ {chart.labels.map((label, index) => (
91
+ <tr key={index}>
92
+ <th scope="row">{label}</th>
93
+ {chart.series.map((series) => (
94
+ <td key={series.key}>{valueText(series.values[index] ?? null, unit)}</td>
95
+ ))}
96
+ </tr>
97
+ ))}
98
+ </tbody>
99
+ </table>
100
+ );
101
+ }
102
+
103
+ /* The values of one category beside the pointer. */
104
+ function Readout({ chart, index, left, unit }: { chart: UiChartData; index: number; left: number; unit: string | undefined }) {
105
+ return (
106
+ <div
107
+ aria-hidden
108
+ className="pointer-events-none absolute top-0 z-1 flex -translate-x-1/2 flex-col gap-0.5 rounded-md bg-surface-raised px-2 py-1 text-xs whitespace-nowrap shadow-raised"
109
+ style={{ left }}
110
+ >
111
+ <span className="font-medium text-text">{chart.labels[index]}</span>
112
+ {chart.series.map((series, at) => (
113
+ <span key={series.key} className="flex items-center gap-1.5 text-text-muted tabular-nums">
114
+ <span className="size-2 rounded-sm" style={{ backgroundColor: seriesColor(at) }} />
115
+ {chart.series.length > 1 && <span>{series.key}</span>}
116
+ <span className="text-text">{valueText(series.values[index] ?? null, unit)}</span>
117
+ </span>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /* Bars and lines over a value axis, in SVG at the measured width. */
124
+ function VerticalChart({ chart, kind, unit, width }: { chart: UiChartData; kind: 'bar' | 'line'; unit: string | undefined; width: number }) {
125
+ const [hover, setHover] = useState<number | null>(null);
126
+ const low = chart.min < 0 ? -niceCeiling(-chart.min) : 0;
127
+ const high = chart.max > 0 ? niceCeiling(chart.max) : low === 0 ? 1 : 0;
128
+ const plotWidth = Math.max(0, width - AXIS_WIDTH);
129
+ const plotHeight = HEIGHT - PLOT_TOP - LABEL_ROOM;
130
+ const band = plotWidth / chart.labels.length;
131
+ const y = (value: number): number => Math.round(PLOT_TOP + ((high - value) / (high - low)) * plotHeight);
132
+ const baseline = y(0);
133
+ const ticks = low === 0 ? [0, high / 2, high] : [low, 0, high];
134
+ const labelEvery = Math.max(1, Math.ceil(LABEL_BAND / Math.max(1, band)));
135
+ const center = (index: number): number => Math.round(AXIS_WIDTH + band * index + band / 2);
136
+ const gap = band * 0.2;
137
+ const barWidth = Math.max(1, (band - gap) / chart.series.length);
138
+
139
+ const segments = (values: (number | null)[]): string =>
140
+ values
141
+ .map((value, index) => (value === null ? null : `${center(index)} ${y(value)}`))
142
+ .reduce<string[]>((paths, point, index, all) => {
143
+ if (point !== null) {
144
+ paths.push(`${index === 0 || all[index - 1] === null ? 'M' : 'L'}${point}`);
145
+ }
146
+ return paths;
147
+ }, [])
148
+ .join(' ');
149
+
150
+ return (
151
+ <>
152
+ <svg width={width} height={HEIGHT} aria-hidden className="block overflow-visible" onPointerLeave={() => setHover(null)}>
153
+ {hover !== null && (
154
+ <rect x={Math.round(AXIS_WIDTH + band * hover)} y={PLOT_TOP} width={Math.round(band)} height={plotHeight} className="fill-surface-hover" />
155
+ )}
156
+ {ticks.map((tick) => (
157
+ <g key={tick}>
158
+ <line
159
+ x1={AXIS_WIDTH}
160
+ x2={width}
161
+ y1={y(tick)}
162
+ y2={y(tick)}
163
+ className={tick === 0 ? 'stroke-border' : 'stroke-border-soft'}
164
+ shapeRendering="crispEdges"
165
+ />
166
+ <text x={AXIS_WIDTH - 6} y={y(tick)} dy="0.32em" textAnchor="end" className="fill-text-faint text-xs tabular-nums">
167
+ {valueText(tick, unit)}
168
+ </text>
169
+ </g>
170
+ ))}
171
+ {chart.labels.map((label, index) =>
172
+ index % labelEvery === 0 ? (
173
+ <text key={index} x={center(index)} y={HEIGHT - 4} textAnchor="middle" className="fill-text-faint text-xs">
174
+ {label}
175
+ </text>
176
+ ) : null
177
+ )}
178
+ {kind === 'bar' ? (
179
+ <g className={clsx(GROW, 'starting:scale-y-0')} style={{ transformOrigin: `0px ${baseline}px` }}>
180
+ {chart.series.map((series, at) =>
181
+ series.values.map((value, index) =>
182
+ value === null ? null : (
183
+ <rect
184
+ key={`${series.key}:${index}`}
185
+ x={Math.round(AXIS_WIDTH + band * index + gap / 2 + barWidth * at)}
186
+ y={Math.min(y(value), baseline)}
187
+ width={Math.max(1, Math.round(barWidth) - 1)}
188
+ height={Math.abs(y(value) - baseline)}
189
+ rx={2}
190
+ style={{ fill: seriesColor(at) }}
191
+ />
192
+ )
193
+ )
194
+ )}
195
+ </g>
196
+ ) : (
197
+ <g className={clsx(GROW, 'starting:opacity-0')}>
198
+ {chart.series.map((series, at) => (
199
+ <g key={series.key} style={{ color: seriesColor(at) }}>
200
+ <path
201
+ d={segments(series.values)}
202
+ fill="none"
203
+ stroke="currentColor"
204
+ strokeWidth={2}
205
+ strokeLinejoin="round"
206
+ strokeLinecap="round"
207
+ />
208
+ {chart.labels.length <= DOTS_UP_TO &&
209
+ series.values.map((value, index) =>
210
+ value === null ? null : <circle key={index} cx={center(index)} cy={y(value)} r={3} fill="currentColor" />
211
+ )}
212
+ </g>
213
+ ))}
214
+ </g>
215
+ )}
216
+ {chart.labels.map((_, index) => (
217
+ <rect
218
+ key={index}
219
+ x={Math.round(AXIS_WIDTH + band * index)}
220
+ y={0}
221
+ width={Math.max(1, Math.round(band))}
222
+ height={HEIGHT}
223
+ fill="transparent"
224
+ onPointerEnter={() => setHover(index)}
225
+ />
226
+ ))}
227
+ </svg>
228
+ {hover !== null && <Readout chart={chart} index={hover} unit={unit} left={Math.min(Math.max(center(hover), 64), Math.max(64, width - 64))} />}
229
+ </>
230
+ );
231
+ }
232
+
233
+ /* A row of 24px per category, its label beside the bars. */
234
+ function HorizontalChart({ chart, unit }: { chart: UiChartData; unit: string | undefined }) {
235
+ // Bars grow right from zero only, so a chart of only negative values has no length to share and keeps a unit scale.
236
+ const high = niceCeiling(chart.max) || 1;
237
+ const single = chart.series.length === 1;
238
+ return (
239
+ <div aria-hidden className="flex flex-col">
240
+ {chart.labels.map((label, index) => (
241
+ <div key={index} className="flex h-6 items-center gap-2 text-xs">
242
+ <span className="w-2/5 max-w-36 shrink-0 truncate text-text-muted">{label}</span>
243
+ <span className="flex min-w-0 grow flex-col justify-center gap-px">
244
+ {chart.series.map((series, at) => (
245
+ <span
246
+ key={series.key}
247
+ className={clsx(GROW, 'block origin-left rounded-sm starting:scale-x-0', single ? 'h-3' : 'h-1')}
248
+ style={{ width: `${(Math.max(0, series.values[index] ?? 0) / high) * 100}%`, backgroundColor: seriesColor(at) }}
249
+ />
250
+ ))}
251
+ </span>
252
+ {single && (
253
+ <span className="w-16 shrink-0 text-right text-text-faint tabular-nums">{valueText(chart.series[0]!.values[index] ?? null, unit)}</span>
254
+ )}
255
+ </div>
256
+ ))}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ /* A bar per category split into its series, on one scale so the rows compare. */
262
+ function StackedChart({ chart, unit }: { chart: UiChartData; unit: string | undefined }) {
263
+ return (
264
+ <div className="flex flex-col gap-2">
265
+ {chart.labels.map((label, index) => (
266
+ <div key={index} className="flex items-center gap-2 text-xs">
267
+ {chart.labels.length > 1 && <span className="w-2/5 max-w-36 shrink-0 truncate text-text-muted">{label}</span>}
268
+ <SegmentBar
269
+ size="sm"
270
+ className="grow"
271
+ label={label}
272
+ range={[0, chart.max]}
273
+ parts={chart.series.map((series, at) => ({
274
+ value: Math.max(0, series.values[index] ?? 0),
275
+ color: seriesColor(at),
276
+ label: `${series.key} ${valueText(series.values[index] ?? 0, unit)}`
277
+ }))}
278
+ />
279
+ </div>
280
+ ))}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ function DrawnChart({ chart, kind, unit }: { chart: UiChartData; kind: ChartKind; unit: string | undefined }) {
286
+ const { t } = useTranslation('agent-chat');
287
+ const [measure, width] = useMeasuredWidth();
288
+ return (
289
+ <figure className="flex flex-col gap-2 px-2">
290
+ {chart.series.length > 1 && <Legend chart={chart} />}
291
+ <div ref={measure} role="img" aria-label={chartSummary(chart, unit, t)} className="relative">
292
+ {kind === 'hbar' ? (
293
+ <HorizontalChart chart={chart} unit={unit} />
294
+ ) : kind === 'stacked' ? (
295
+ <StackedChart chart={chart} unit={unit} />
296
+ ) : (
297
+ <div style={{ height: HEIGHT }}>{width > 0 && <VerticalChart chart={chart} kind={kind} unit={unit} width={width} />}</div>
298
+ )}
299
+ </div>
300
+ <ValuesTable chart={chart} unit={unit} />
301
+ </figure>
302
+ );
303
+ }
304
+
305
+ /*
306
+ * Draws only once its node closed; until then a quiet surface of the height it will take holds its
307
+ * place. It grows in once, and not at all for a person who asked for less motion.
308
+ */
309
+ export function ChartRenderer({ node }: UiRendererProps<UiProps<'Chart'>>) {
310
+ const { kind, data, unit } = node.props;
311
+ const chart = useMemo(() => (node.complete ? uiChartData(data, kind) : null), [node.complete, data, kind]);
312
+ if (!node.complete) {
313
+ return <div aria-hidden className="mx-2 rounded-md bg-surface-hover" style={{ height: chartHeight(kind, data.length) }} />;
314
+ }
315
+ if (chart === null) {
316
+ return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'failed' }} />;
317
+ }
318
+ return <DrawnChart chart={chart} kind={kind} unit={unit} />;
319
+ }