@iloveagents/foundry-web-ui 0.23.0 → 0.25.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/chart.d.ts +1 -1
- package/dist/chart.js +1 -1
- package/dist/components/chart/category-bar-chart.js +11 -23
- package/dist/components/chart/chart-config.d.ts +49 -5
- package/dist/components/chart/chart-config.js +95 -22
- package/dist/components/chart/chart.d.ts +5 -3
- package/dist/components/chart/chart.js +4 -4
- package/dist/components/chart/donut-chart.d.ts +6 -5
- package/dist/components/chart/donut-chart.js +19 -18
- package/dist/components/chart/stat-card.js +9 -2
- package/dist/components/chart/time-series-chart.js +13 -2
- package/dist/components/tool-call-card.d.ts +14 -3
- package/dist/components/tool-call-card.js +9 -2
- package/dist/index.d.ts +1 -1
- package/dist/lib/app-store.js +99 -4
- package/dist/lib/tool-panel-store.d.ts +19 -1
- package/dist/lib/tool-panel-store.js +12 -1
- package/package.json +3 -3
package/dist/chart.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `@iloveagents/foundry-agent/msal`); import from the subpath.
|
|
8
8
|
*/
|
|
9
9
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, type ChartContainerProps, type ChartTooltipContentProps, type ChartLegendContentProps, } from "./components/chart/chart.js";
|
|
10
|
-
export { chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, type ChartConfig, type ChartSeriesConfig, } from "./components/chart/chart-config.js";
|
|
10
|
+
export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, type ChartConfig, type ChartSeriesConfig, } from "./components/chart/chart-config.js";
|
|
11
11
|
export { StatCard, type StatCardProps } from "./components/chart/stat-card.js";
|
|
12
12
|
export { ChartCard, DashboardGrid, DashboardTile, type ChartCardProps, type DashboardGridProps, type DashboardTileProps, } from "./components/chart/chart-card.js";
|
|
13
13
|
export { TimeSeriesChart, type TimeSeriesChartProps, } from "./components/chart/time-series-chart.js";
|
package/dist/chart.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `@iloveagents/foundry-agent/msal`); import from the subpath.
|
|
8
8
|
*/
|
|
9
9
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, useChart, compactAxisTick, } from "./components/chart/chart.js";
|
|
10
|
-
export { chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, } from "./components/chart/chart-config.js";
|
|
10
|
+
export { FOLD_COLOR, OTHER_LABEL, categoryColorResolver, chartSlotColor, chartStyleVars, foldCategoryTail, formatBucketLabel, formatCompactNumber, seriesColor, seriesLabel, seriesVar, seriesVarName, CHART_SLOT_COUNT, } from "./components/chart/chart-config.js";
|
|
11
11
|
export { StatCard } from "./components/chart/stat-card.js";
|
|
12
12
|
export { ChartCard, DashboardGrid, DashboardTile, } from "./components/chart/chart-card.js";
|
|
13
13
|
export { TimeSeriesChart, } from "./components/chart/time-series-chart.js";
|
|
@@ -2,42 +2,30 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import * as RechartsPrimitive from "recharts";
|
|
4
4
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, compactAxisTick } from "./chart.js";
|
|
5
|
-
import { CHART_SLOT_COUNT,
|
|
6
|
-
|
|
5
|
+
import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, seriesColor, } from "./chart-config.js";
|
|
6
|
+
const EMPTY_CONFIG = {};
|
|
7
|
+
export function CategoryBarChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, horizontal = true, maxBars, colorByCategory = false, height, width, valueFormatter, categoryFormatter, className, }) {
|
|
7
8
|
// Identity coloring caps at the palette (5 + Other); a single-hue ranked
|
|
8
9
|
// list caps at what stays readable. Both fold rather than overflow.
|
|
9
|
-
const
|
|
10
|
+
const barDefault = colorByCategory ? CHART_SLOT_COUNT : 12;
|
|
11
|
+
// A non-finite cap keeps THIS chart's default, not the palette-wide one.
|
|
12
|
+
const effectiveMax = Number.isFinite(maxBars) ? maxBars : barDefault;
|
|
10
13
|
const rows = React.useMemo(() => {
|
|
11
14
|
// This is a RANKED breakdown by contract, and the fold keeps the head —
|
|
12
15
|
// rank first so an unsorted input never folds its biggest categories.
|
|
13
16
|
const ranked = [...data].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
|
|
14
17
|
return foldCategoryTail(ranked, { categoryKey, valueKey, max: effectiveMax });
|
|
15
18
|
}, [data, categoryKey, valueKey, effectiveMax]);
|
|
16
|
-
// Identity
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (explicit)
|
|
21
|
-
return explicit;
|
|
22
|
-
// Declared identity first: a category's position in the CONFIG is
|
|
23
|
-
// immune to the caller filtering the data. Data order is only the
|
|
24
|
-
// fallback for undeclared categories.
|
|
25
|
-
// The measure's own entry (labels/colors for the value column) is
|
|
26
|
-
// not a category — it must not shift the identity order.
|
|
27
|
-
const configIndex = Object.keys(config)
|
|
28
|
-
.filter((key) => key !== valueKey)
|
|
29
|
-
.indexOf(category);
|
|
30
|
-
if (configIndex !== -1)
|
|
31
|
-
return chartSlotColor(configIndex);
|
|
32
|
-
const declared = data.findIndex((row) => String(row[categoryKey]) === category);
|
|
33
|
-
return chartSlotColor(declared === -1 ? CHART_SLOT_COUNT - 1 : declared);
|
|
34
|
-
}, [config, data, categoryKey]);
|
|
19
|
+
// Identity resolved from the ORIGINAL categories, with the measure's own
|
|
20
|
+
// config entry excluded — it labels the value column, it is not a
|
|
21
|
+
// category and must not shift the order.
|
|
22
|
+
const categoryColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? "")), { exclude: valueKey }), [config, rows, categoryKey, valueKey]);
|
|
35
23
|
const fixed = width !== undefined;
|
|
36
24
|
const resolvedHeight = height ?? (horizontal ? Math.max(160, rows.length * 36 + 24) : 240);
|
|
37
25
|
const formatCategory = categoryFormatter ?? ((v) => String(v ?? ""));
|
|
38
26
|
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height: resolvedHeight, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.BarChart, { data: rows, layout: horizontal ? "vertical" : "horizontal", width: width, height: fixed ? resolvedHeight : undefined, margin: { top: 4, right: 12, bottom: 0, left: 0 }, children: [horizontal ? (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.XAxis, { type: "number", tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(RechartsPrimitive.YAxis, { type: "category", dataKey: categoryKey, width: 140, tickLine: false, axisLine: false, tickFormatter: formatCategory })] })) : (_jsxs(_Fragment, { children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: categoryKey, tickLine: false, axisLine: false, tickMargin: 8, tickFormatter: formatCategory }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick })] })), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: (label) => formatCategory(label), valueFormatter: valueFormatter, indicatorColor: colorByCategory ? (_key, label) => categoryColor(String(label)) : undefined }) }), _jsx(RechartsPrimitive.Bar, { dataKey: valueKey, fill: seriesColor(config, valueKey), maxBarSize: 28, radius: horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], isAnimationActive: false, children: colorByCategory &&
|
|
39
27
|
rows.map((row, i) => {
|
|
40
28
|
const category = String(row[categoryKey] ?? i);
|
|
41
|
-
return _jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, category);
|
|
29
|
+
return (_jsx(RechartsPrimitive.Cell, { fill: categoryColor(category) }, `${i}-${category}`));
|
|
42
30
|
}) })] }) }));
|
|
43
31
|
}
|
|
@@ -37,6 +37,37 @@ export declare const CHART_SLOT_COUNT = 5;
|
|
|
37
37
|
export declare function chartSlotColor(index: number): string;
|
|
38
38
|
/** Resolved color for a series: explicit config first, theme slot second. */
|
|
39
39
|
export declare function seriesColor(config: ChartConfig, key: string): string;
|
|
40
|
+
/** The label a folded tail wears, and the muted color that marks it. */
|
|
41
|
+
export declare const OTHER_LABEL = "Other";
|
|
42
|
+
export declare const FOLD_COLOR = "var(--muted-foreground)";
|
|
43
|
+
/**
|
|
44
|
+
* The color resolver for a CATEGORICAL chart, where every category is an
|
|
45
|
+
* identity rather than a series.
|
|
46
|
+
*
|
|
47
|
+
* Two requirements pull against each other, and both are real:
|
|
48
|
+
* - a DECLARED category must keep its color when a filter removes its
|
|
49
|
+
* neighbours (identity follows the entity, never its rank), and
|
|
50
|
+
* - a DRAWN category must never take the muted past-the-palette color
|
|
51
|
+
* just because the config also names categories this chart does not
|
|
52
|
+
* draw.
|
|
53
|
+
*
|
|
54
|
+
* Resolved by giving each side its own claim on the palette. A declared
|
|
55
|
+
* key OWNS its declared index — reserved whether or not it is drawn, which
|
|
56
|
+
* is what makes it stable — and undeclared drawn categories fill the slots
|
|
57
|
+
* no declaration claimed, in drawn order. Past the palette, or with every
|
|
58
|
+
* slot spoken for, a category takes the muted fold color rather than
|
|
59
|
+
* wrapping modulo five onto a color another category already owns.
|
|
60
|
+
*
|
|
61
|
+
* So: declare the categories whose colors must not move, and the rest sort
|
|
62
|
+
* themselves out.
|
|
63
|
+
*
|
|
64
|
+
* `exclude` drops the measure's own config entry (a bar chart labels its
|
|
65
|
+
* value column there); it is not a category and must not shift the order.
|
|
66
|
+
*/
|
|
67
|
+
export declare function categoryColorResolver(config: ChartConfig, categories: Iterable<string>, options?: {
|
|
68
|
+
exclude?: string;
|
|
69
|
+
otherLabel?: string;
|
|
70
|
+
}): (category: string) => string;
|
|
40
71
|
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
41
72
|
export declare function seriesLabel(config: ChartConfig, key: string): React.ReactNode;
|
|
42
73
|
/**
|
|
@@ -46,8 +77,14 @@ export declare function seriesLabel(config: ChartConfig, key: string): React.Rea
|
|
|
46
77
|
* two sides can never disagree.
|
|
47
78
|
*/
|
|
48
79
|
export declare function seriesVarName(key: string): string;
|
|
49
|
-
/**
|
|
50
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The `var()` reference marks use for their series color.
|
|
82
|
+
*
|
|
83
|
+
* A key with no configured series resolves to an UNDEFINED custom property,
|
|
84
|
+
* which makes `fill` fall back to black and `stroke` to none — a silent
|
|
85
|
+
* black band. The fallback keeps an undeclared series visible.
|
|
86
|
+
*/
|
|
87
|
+
export declare function seriesVar(key: string, fallback?: string): string;
|
|
51
88
|
/**
|
|
52
89
|
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
53
90
|
* marks can reference {@link seriesVar} and pick up theme changes live.
|
|
@@ -63,9 +100,16 @@ export declare function formatCompactNumber(value: number): string;
|
|
|
63
100
|
*/
|
|
64
101
|
export declare function formatBucketLabel(bucket: unknown): string;
|
|
65
102
|
/**
|
|
66
|
-
* Fold a
|
|
67
|
-
*
|
|
68
|
-
*
|
|
103
|
+
* Fold a category list's tail into a single "Other" row once it exceeds
|
|
104
|
+
* `max` rows — the discipline for categorical charts: a 9th slice is never
|
|
105
|
+
* a 9th color.
|
|
106
|
+
*
|
|
107
|
+
* Ranks by value FIRST. The fold keeps the head, so on an unranked list it
|
|
108
|
+
* would swallow whatever happened to be sorted late — a donut of six
|
|
109
|
+
* equal-ish categories plus one dominant one at the end rendered as four
|
|
110
|
+
* slivers and an unlabeled 96% "Other". Ranking here rather than trusting
|
|
111
|
+
* callers makes that unreachable, including for charts a builder agent
|
|
112
|
+
* writes.
|
|
69
113
|
*/
|
|
70
114
|
export declare function foldCategoryTail<T extends Record<string, unknown>>(rows: T[], options: {
|
|
71
115
|
categoryKey: keyof T & string;
|
|
@@ -18,6 +18,64 @@ export function seriesColor(config, key) {
|
|
|
18
18
|
const declaredIndex = Object.keys(config).indexOf(key);
|
|
19
19
|
return chartSlotColor(declaredIndex === -1 ? 0 : declaredIndex);
|
|
20
20
|
}
|
|
21
|
+
/** The label a folded tail wears, and the muted color that marks it. */
|
|
22
|
+
export const OTHER_LABEL = "Other";
|
|
23
|
+
export const FOLD_COLOR = "var(--muted-foreground)";
|
|
24
|
+
/**
|
|
25
|
+
* The color resolver for a CATEGORICAL chart, where every category is an
|
|
26
|
+
* identity rather than a series.
|
|
27
|
+
*
|
|
28
|
+
* Two requirements pull against each other, and both are real:
|
|
29
|
+
* - a DECLARED category must keep its color when a filter removes its
|
|
30
|
+
* neighbours (identity follows the entity, never its rank), and
|
|
31
|
+
* - a DRAWN category must never take the muted past-the-palette color
|
|
32
|
+
* just because the config also names categories this chart does not
|
|
33
|
+
* draw.
|
|
34
|
+
*
|
|
35
|
+
* Resolved by giving each side its own claim on the palette. A declared
|
|
36
|
+
* key OWNS its declared index — reserved whether or not it is drawn, which
|
|
37
|
+
* is what makes it stable — and undeclared drawn categories fill the slots
|
|
38
|
+
* no declaration claimed, in drawn order. Past the palette, or with every
|
|
39
|
+
* slot spoken for, a category takes the muted fold color rather than
|
|
40
|
+
* wrapping modulo five onto a color another category already owns.
|
|
41
|
+
*
|
|
42
|
+
* So: declare the categories whose colors must not move, and the rest sort
|
|
43
|
+
* themselves out.
|
|
44
|
+
*
|
|
45
|
+
* `exclude` drops the measure's own config entry (a bar chart labels its
|
|
46
|
+
* value column there); it is not a category and must not shift the order.
|
|
47
|
+
*/
|
|
48
|
+
export function categoryColorResolver(config, categories, options = {}) {
|
|
49
|
+
const { exclude, otherLabel = OTHER_LABEL } = options;
|
|
50
|
+
const declared = Object.keys(config).filter((key) => key !== exclude && key !== otherLabel);
|
|
51
|
+
const slotOf = new Map();
|
|
52
|
+
declared.forEach((key, i) => slotOf.set(key, i));
|
|
53
|
+
// Whatever the declarations did not claim is free for the rest.
|
|
54
|
+
const claimed = new Set(slotOf.values());
|
|
55
|
+
const free = [];
|
|
56
|
+
for (let slot = 0; slot < CHART_SLOT_COUNT; slot++) {
|
|
57
|
+
if (!claimed.has(slot))
|
|
58
|
+
free.push(slot);
|
|
59
|
+
}
|
|
60
|
+
let next = 0;
|
|
61
|
+
for (const category of categories) {
|
|
62
|
+
if (category === otherLabel || slotOf.has(category))
|
|
63
|
+
continue;
|
|
64
|
+
const slot = free[next++];
|
|
65
|
+
slotOf.set(category, slot === undefined ? CHART_SLOT_COUNT : slot);
|
|
66
|
+
}
|
|
67
|
+
return (category) => {
|
|
68
|
+
const explicit = config[category]?.color;
|
|
69
|
+
if (explicit)
|
|
70
|
+
return explicit;
|
|
71
|
+
if (category === otherLabel)
|
|
72
|
+
return FOLD_COLOR;
|
|
73
|
+
const slot = slotOf.get(category);
|
|
74
|
+
if (slot === undefined || slot >= CHART_SLOT_COUNT)
|
|
75
|
+
return FOLD_COLOR;
|
|
76
|
+
return chartSlotColor(slot);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
21
79
|
/** Resolved label for a series: explicit config first, the key itself second. */
|
|
22
80
|
export function seriesLabel(config, key) {
|
|
23
81
|
return config[key]?.label ?? key;
|
|
@@ -31,18 +89,24 @@ export function seriesLabel(config, key) {
|
|
|
31
89
|
export function seriesVarName(key) {
|
|
32
90
|
const safe = key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
33
91
|
if (safe === key)
|
|
34
|
-
return `--color-${key}`;
|
|
92
|
+
return `--chart-color-${key}`;
|
|
35
93
|
// Two keys may sanitize to the same ident ("metrics.tokens" vs
|
|
36
94
|
// "metrics tokens") — a short stable hash of the RAW key keeps them
|
|
37
95
|
// apart without leaking invalid characters.
|
|
38
96
|
let hash = 5381;
|
|
39
97
|
for (let i = 0; i < key.length; i++)
|
|
40
98
|
hash = ((hash << 5) + hash + key.charCodeAt(i)) | 0;
|
|
41
|
-
return `--color-${safe}-${(hash >>> 0).toString(36)}`;
|
|
99
|
+
return `--chart-color-${safe}-${(hash >>> 0).toString(36)}`;
|
|
42
100
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The `var()` reference marks use for their series color.
|
|
103
|
+
*
|
|
104
|
+
* A key with no configured series resolves to an UNDEFINED custom property,
|
|
105
|
+
* which makes `fill` fall back to black and `stroke` to none — a silent
|
|
106
|
+
* black band. The fallback keeps an undeclared series visible.
|
|
107
|
+
*/
|
|
108
|
+
export function seriesVar(key, fallback = chartSlotColor(0)) {
|
|
109
|
+
return `var(${seriesVarName(key)}, ${fallback})`;
|
|
46
110
|
}
|
|
47
111
|
/**
|
|
48
112
|
* The per-series CSS custom properties a `ChartContainer` injects, so chart
|
|
@@ -88,27 +152,36 @@ export function formatBucketLabel(bucket) {
|
|
|
88
152
|
return bucket;
|
|
89
153
|
}
|
|
90
154
|
/**
|
|
91
|
-
* Fold a
|
|
92
|
-
*
|
|
93
|
-
*
|
|
155
|
+
* Fold a category list's tail into a single "Other" row once it exceeds
|
|
156
|
+
* `max` rows — the discipline for categorical charts: a 9th slice is never
|
|
157
|
+
* a 9th color.
|
|
158
|
+
*
|
|
159
|
+
* Ranks by value FIRST. The fold keeps the head, so on an unranked list it
|
|
160
|
+
* would swallow whatever happened to be sorted late — a donut of six
|
|
161
|
+
* equal-ish categories plus one dominant one at the end rendered as four
|
|
162
|
+
* slivers and an unlabeled 96% "Other". Ranking here rather than trusting
|
|
163
|
+
* callers makes that unreachable, including for charts a builder agent
|
|
164
|
+
* writes.
|
|
94
165
|
*/
|
|
95
166
|
export function foldCategoryTail(rows, options) {
|
|
96
|
-
const { categoryKey, valueKey, otherLabel =
|
|
97
|
-
// A cap below 2 cannot hold a fold (one real row + "Other" is the floor)
|
|
98
|
-
|
|
167
|
+
const { categoryKey, valueKey, otherLabel = OTHER_LABEL } = options;
|
|
168
|
+
// A cap below 2 cannot hold a fold (one real row + "Other" is the floor);
|
|
169
|
+
// a non-finite cap would fold everything into a single bucket.
|
|
170
|
+
const requested = options.max ?? CHART_SLOT_COUNT;
|
|
171
|
+
const max = Math.max(2, Number.isFinite(requested) ? requested : CHART_SLOT_COUNT);
|
|
99
172
|
if (rows.length <= max)
|
|
100
173
|
return rows;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
const
|
|
174
|
+
const ranked = [...rows].sort((a, b) => (Number(b[valueKey]) || 0) - (Number(a[valueKey]) || 0));
|
|
175
|
+
// A pre-existing "Other" row always joins the fold, so set it aside
|
|
176
|
+
// BEFORE taking the head: leaving it there would cost a real category
|
|
177
|
+
// its place for a row that gets folded away anyway.
|
|
178
|
+
const existingOther = ranked.filter((row) => String(row[categoryKey]) === otherLabel);
|
|
179
|
+
const real = ranked.filter((row) => String(row[categoryKey]) !== otherLabel);
|
|
180
|
+
const kept = real.slice(0, max - 1);
|
|
181
|
+
const folded = [...existingOther, ...real.slice(max - 1)];
|
|
182
|
+
if (!folded.length)
|
|
183
|
+
return kept;
|
|
107
184
|
const otherTotal = folded.reduce((sum, row) => sum + (Number(row[valueKey]) || 0), 0);
|
|
108
|
-
const other = {
|
|
109
|
-
...(tail[0] ?? folded[0]),
|
|
110
|
-
[categoryKey]: otherLabel,
|
|
111
|
-
[valueKey]: otherTotal,
|
|
112
|
-
};
|
|
185
|
+
const other = { ...folded[0], [categoryKey]: otherLabel, [valueKey]: otherTotal };
|
|
113
186
|
return [...kept, other];
|
|
114
187
|
}
|
|
@@ -3,9 +3,9 @@ import * as RechartsPrimitive from "recharts";
|
|
|
3
3
|
import { type ChartConfig } from "./chart-config.js";
|
|
4
4
|
/**
|
|
5
5
|
* The shadcn-style chart layer over recharts: `ChartContainer` injects one
|
|
6
|
-
* `--color-<key>` CSS variable per configured series (resolved from the
|
|
6
|
+
* `--chart-color-<key>` CSS variable per configured series (resolved from the
|
|
7
7
|
* theme's `--chart-1..5` slots unless overridden), so every mark inside can
|
|
8
|
-
* reference its series color with `
|
|
8
|
+
* reference its series color with `seriesVar(key)` and follow theme
|
|
9
9
|
* changes — light/dark and tenant themes alike — without re-rendering logic.
|
|
10
10
|
*
|
|
11
11
|
* Tooltip and legend CONTENT components read the same config through
|
|
@@ -48,6 +48,8 @@ export interface ChartTooltipContentProps {
|
|
|
48
48
|
valueFormatter?: (value: number) => React.ReactNode;
|
|
49
49
|
/** Hide the color indicator squares. */
|
|
50
50
|
hideIndicator?: boolean;
|
|
51
|
+
/** Drop the header row entirely (a donut names its slice on the row). */
|
|
52
|
+
hideLabel?: boolean;
|
|
51
53
|
/**
|
|
52
54
|
* Override the indicator color per row — for charts whose CELLS recolor
|
|
53
55
|
* the marks (colorByCategory) while the payload still carries the parent
|
|
@@ -59,7 +61,7 @@ export interface ChartTooltipContentProps {
|
|
|
59
61
|
* The hover layer's content. Rows are named from the chart config (the
|
|
60
62
|
* series key is a data column name — never show it raw when a label exists).
|
|
61
63
|
*/
|
|
62
|
-
declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
64
|
+
declare function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator, hideLabel, indicatorColor, }: ChartTooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
|
|
63
65
|
export interface ChartLegendContentProps {
|
|
64
66
|
payload?: Array<{
|
|
65
67
|
dataKey?: string | number;
|
|
@@ -27,11 +27,11 @@ const ChartLegend = RechartsPrimitive.Legend;
|
|
|
27
27
|
* The hover layer's content. Rows are named from the chart config (the
|
|
28
28
|
* series key is a data column name — never show it raw when a label exists).
|
|
29
29
|
*/
|
|
30
|
-
function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator = false, indicatorColor, }) {
|
|
30
|
+
function ChartTooltipContent({ active, label, payload, className, labelFormatter, valueFormatter, hideIndicator = false, hideLabel = false, indicatorColor, }) {
|
|
31
31
|
const { config } = useChart();
|
|
32
32
|
if (!active || !payload?.length)
|
|
33
33
|
return null;
|
|
34
|
-
return (_jsxs("div", { className: cn("min-w-[9rem] rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md", className), children: [_jsx("div", { className: "mb-1.5 font-medium text-foreground", children: labelFormatter ? labelFormatter(label) : String(label ?? "") }), _jsx("div", { className: "grid gap-1", children: payload.map((entry, i) => {
|
|
34
|
+
return (_jsxs("div", { className: cn("min-w-[9rem] rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-md", className), children: [!hideLabel && (_jsx("div", { className: "mb-1.5 font-medium text-foreground", children: labelFormatter ? labelFormatter(label) : String(label ?? "") })), _jsx("div", { className: "grid gap-1", children: payload.map((entry, i) => {
|
|
35
35
|
// `name` first: a Pie names entries by its nameKey (the category),
|
|
36
36
|
// while its dataKey is the measure column — the one label a donut
|
|
37
37
|
// row must NOT wear. Series charts set name = dataKey, so nothing
|
|
@@ -45,7 +45,7 @@ function ChartTooltipContent({ active, label, payload, className, labelFormatter
|
|
|
45
45
|
? valueFormatter(numeric)
|
|
46
46
|
: finite
|
|
47
47
|
? numeric.toLocaleString()
|
|
48
|
-
: String(entry.value ?? "") })] }, key));
|
|
48
|
+
: String(entry.value ?? "") })] }, `${i}-${key}`));
|
|
49
49
|
}) })] }));
|
|
50
50
|
}
|
|
51
51
|
/** Legend rows named from the chart config, color chips from the payload. */
|
|
@@ -55,7 +55,7 @@ function ChartLegendContent({ payload, className }) {
|
|
|
55
55
|
return null;
|
|
56
56
|
return (_jsx("div", { className: cn("flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", className), children: payload.map((entry, i) => {
|
|
57
57
|
const key = String(entry.dataKey ?? entry.value ?? i);
|
|
58
|
-
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: entry.color ?? seriesVar(key) } }), _jsx("span", { children: seriesLabel(config, key) })] }, key));
|
|
58
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: entry.color ?? seriesVar(key) } }), _jsx("span", { children: seriesLabel(config, key) })] }, `${i}-${key}`));
|
|
59
59
|
}) }));
|
|
60
60
|
}
|
|
61
61
|
/** Compact y-axis tick formatter shared by the chart wrappers. */
|
|
@@ -2,11 +2,12 @@ import * as React from "react";
|
|
|
2
2
|
import { type ChartConfig } from "./chart-config.js";
|
|
3
3
|
/**
|
|
4
4
|
* Share of a whole ("tokens per model"). Slices are identities, so each
|
|
5
|
-
* takes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* takes a theme slot resolved over the DRAWN slices — declared config keys
|
|
6
|
+
* first, the rest in drawn order — and there are only five slots,
|
|
7
|
+
* so `maxSlices` folds the tail into a muted "Other" (ranked first, so the
|
|
8
|
+
* fold can never swallow the largest slice) instead of inventing colors.
|
|
9
|
+
* The center carries the headline total; the legend carries names + shares,
|
|
10
|
+
* so the hover layer is confirmation, not the only way to read the chart.
|
|
10
11
|
*/
|
|
11
12
|
export interface DonutChartProps {
|
|
12
13
|
data: Array<Record<string, unknown>>;
|
|
@@ -3,35 +3,36 @@ import * as React from "react";
|
|
|
3
3
|
import * as RechartsPrimitive from "recharts";
|
|
4
4
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
5
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart.js";
|
|
6
|
-
import { CHART_SLOT_COUNT,
|
|
7
|
-
const
|
|
8
|
-
export function DonutChart({ data, categoryKey, valueKey, config =
|
|
6
|
+
import { CHART_SLOT_COUNT, categoryColorResolver, foldCategoryTail, formatCompactNumber, } from "./chart-config.js";
|
|
7
|
+
const EMPTY_CONFIG = {};
|
|
8
|
+
export function DonutChart({ data, categoryKey, valueKey, config = EMPTY_CONFIG, maxSlices = 5, centerLabel, centerValue, height = 240, width, valueFormatter, showLegend = true, className, }) {
|
|
9
9
|
// The donut's slices are identities and the palette has five slots —
|
|
10
10
|
// a cap above that would wrap colors modulo five.
|
|
11
11
|
const cappedSlices = Math.min(CHART_SLOT_COUNT, maxSlices);
|
|
12
|
+
// One expression for "this row's category", so the color resolver and
|
|
13
|
+
// the rendered cell can never disagree about a missing value.
|
|
14
|
+
const categoryOf = React.useCallback((row, i) => String(row[categoryKey] ?? i), [categoryKey]);
|
|
12
15
|
const rows = React.useMemo(() => foldCategoryTail(data, { categoryKey, valueKey, max: cappedSlices }), [data, categoryKey, valueKey, cappedSlices]);
|
|
13
|
-
const total = React.useMemo(() => rows.reduce((sum, row) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const configIndex = Object.keys(config).indexOf(category);
|
|
22
|
-
return chartSlotColor(configIndex !== -1 ? configIndex : index);
|
|
23
|
-
}, [config, rows.length, data.length]);
|
|
16
|
+
const total = React.useMemo(() => rows.reduce((sum, row) => {
|
|
17
|
+
const value = Number(row[valueKey]);
|
|
18
|
+
return sum + (Number.isFinite(value) ? value : 0);
|
|
19
|
+
}, 0), [rows, valueKey]);
|
|
20
|
+
// Identity resolved over the DRAWN slices: the fold ranks, so a category
|
|
21
|
+
// late in the input can be promoted, and resolving from input order would
|
|
22
|
+
// paint that visible slice the muted past-the-palette grey.
|
|
23
|
+
const sliceColor = React.useMemo(() => categoryColorResolver(config, rows.map((row) => String(row[categoryKey] ?? ""))), [config, rows, categoryOf]);
|
|
24
24
|
const fixed = width !== undefined;
|
|
25
25
|
const sliceLabel = (category) => config[category]?.label ?? category;
|
|
26
|
-
return (_jsxs("div", { className: cn("flex flex-col", className), children: [_jsxs("div", { className: "relative", children: [_jsx(ChartContainer, { config: config, responsive: !fixed, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.PieChart, { width: width, height: fixed ? height : undefined, children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, {
|
|
26
|
+
return (_jsxs("div", { className: cn("flex flex-col", className), children: [_jsxs("div", { className: "relative", children: [_jsx(ChartContainer, { config: config, responsive: !fixed, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(RechartsPrimitive.PieChart, { width: width, height: fixed ? height : undefined, children: [_jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { hideLabel: true, valueFormatter: valueFormatter }) }), _jsx(RechartsPrimitive.Pie, { data: rows, dataKey: valueKey, nameKey: categoryKey, innerRadius: "62%", outerRadius: "88%", paddingAngle: 2, strokeWidth: 0, isAnimationActive: false, children: rows.map((row, i) => {
|
|
27
27
|
const category = String(row[categoryKey] ?? i);
|
|
28
|
-
return _jsx(RechartsPrimitive.Cell, { fill: sliceColor(category
|
|
28
|
+
return (_jsx(RechartsPrimitive.Cell, { fill: sliceColor(category) }, `${i}-${category}`));
|
|
29
29
|
}) })] }) }), _jsxs("div", { className: "pointer-events-none absolute inset-0 flex flex-col items-center justify-center", children: [_jsx("span", { className: "text-xl font-semibold tabular-nums", "data-donut-center": "", children: centerValue ?? formatCompactNumber(total) }), centerLabel && _jsx("span", { className: "text-[11px] text-muted-foreground", children: centerLabel })] })] }), showLegend && (_jsx("div", { className: "flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3", children: rows.map((row, i) => {
|
|
30
30
|
const category = String(row[categoryKey] ?? i);
|
|
31
|
-
const
|
|
31
|
+
const raw = Number(row[valueKey]);
|
|
32
|
+
const value = Number.isFinite(raw) ? raw : 0;
|
|
32
33
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
|
33
34
|
// A real slice must never read as absent: below 1% says so.
|
|
34
35
|
const share = pct > 0 && pct < 1 ? "<1" : String(Math.round(pct));
|
|
35
|
-
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: sliceColor(category
|
|
36
|
+
return (_jsxs("div", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: sliceColor(category) } }), _jsx("span", { children: sliceLabel(category) }), _jsxs("span", { className: "tabular-nums", children: [share, "%"] })] }, `${i}-${category}`));
|
|
36
37
|
}) }))] }));
|
|
37
38
|
}
|
|
@@ -7,12 +7,19 @@ function formatValue(value, format) {
|
|
|
7
7
|
return "–";
|
|
8
8
|
if (typeof value === "string")
|
|
9
9
|
return value;
|
|
10
|
+
// Both branches take the same guard: a NaN must not render as "NaN" in
|
|
11
|
+
// one format and "–" in the other.
|
|
12
|
+
if (!Number.isFinite(value))
|
|
13
|
+
return "–";
|
|
10
14
|
return format === "plain" ? value.toLocaleString() : formatCompactNumber(value);
|
|
11
15
|
}
|
|
12
16
|
const StatCard = React.forwardRef(({ label, value, format = "compact", delta, deltaLabel, invertDelta = false, hint, icon, loading = false, className, ...props }, ref) => {
|
|
13
|
-
|
|
17
|
+
// A delta of Infinity is the natural result of dividing by a zero
|
|
18
|
+
// previous period, and "▲ ∞%" styled as GOOD is worse than no badge.
|
|
19
|
+
const hasDelta = delta !== undefined && Number.isFinite(delta);
|
|
20
|
+
const direction = !hasDelta || delta === 0 ? "flat" : delta > 0 ? "up" : "down";
|
|
14
21
|
const good = delta !== undefined && delta !== 0 && (invertDelta ? delta < 0 : delta > 0);
|
|
15
|
-
return (_jsxs("div", { ref: ref, "data-stat-card": "", className: cn("rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-xs font-medium text-muted-foreground", children: label }), icon && _jsx("span", { className: "shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })] }), _jsxs("div", { className: "mt-2 flex items-baseline gap-2", children: [loading ? (_jsx("span", { className: "h-7 w-20 animate-pulse rounded-md bg-muted", "data-stat-skeleton": "" })) : (_jsx("span", { className: "text-2xl font-semibold tabular-nums tracking-tight", "data-stat-value": "", children: formatValue(value, format) })), !loading &&
|
|
22
|
+
return (_jsxs("div", { ref: ref, "data-stat-card": "", className: cn("rounded-xl border border-border bg-card p-4 text-card-foreground", className), ...props, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-xs font-medium text-muted-foreground", children: label }), icon && _jsx("span", { className: "shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })] }), _jsxs("div", { className: "mt-2 flex items-baseline gap-2", children: [loading ? (_jsx("span", { className: "h-7 w-20 animate-pulse rounded-md bg-muted", "data-stat-skeleton": "" })) : (_jsx("span", { className: "text-2xl font-semibold tabular-nums tracking-tight", "data-stat-value": "", children: formatValue(value, format) })), !loading && hasDelta && (_jsxs("span", { "data-stat-delta": direction === "flat" ? "flat" : good ? "good" : "bad", className: cn("inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium tabular-nums", direction === "flat"
|
|
16
23
|
? "bg-muted text-muted-foreground"
|
|
17
24
|
: good
|
|
18
25
|
? "bg-success/10 text-success"
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as RechartsPrimitive from "recharts";
|
|
3
3
|
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, compactAxisTick, } from "./chart.js";
|
|
4
|
-
import { formatBucketLabel, seriesVar } from "./chart-config.js";
|
|
4
|
+
import { chartSlotColor, formatBucketLabel, seriesVar } from "./chart-config.js";
|
|
5
5
|
export function TimeSeriesChart({ data, config, xKey = "bucket", series, kind = "area", stacked = false, height = 240, width, valueFormatter, xTickFormatter = formatBucketLabel, showLegend, className, }) {
|
|
6
6
|
const keys = series ?? Object.keys(config);
|
|
7
|
+
// A drawn key with no config takes the next slot AFTER the configured
|
|
8
|
+
// ones — the rendered index would collide with a configured series when
|
|
9
|
+
// an undeclared key is drawn first.
|
|
10
|
+
const configured = Object.keys(config);
|
|
11
|
+
const fallbackSlot = (key) => {
|
|
12
|
+
const declared = configured.indexOf(key);
|
|
13
|
+
if (declared !== -1)
|
|
14
|
+
return declared;
|
|
15
|
+
const undeclared = keys.filter((k) => !configured.includes(k)).indexOf(key);
|
|
16
|
+
return configured.length + Math.max(0, undeclared);
|
|
17
|
+
};
|
|
7
18
|
const legend = showLegend ?? keys.length > 1;
|
|
8
19
|
const fixed = width !== undefined;
|
|
9
20
|
const ChartRoot = kind === "line"
|
|
@@ -11,7 +22,7 @@ export function TimeSeriesChart({ data, config, xKey = "bucket", series, kind =
|
|
|
11
22
|
: kind === "bar"
|
|
12
23
|
? RechartsPrimitive.BarChart
|
|
13
24
|
: RechartsPrimitive.AreaChart;
|
|
14
|
-
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(ChartRoot, { data: data, width: width, height: fixed ? height : undefined, margin: { top: 4, right: 8, bottom: 0, left: 0 }, children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: xKey, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 24, tickFormatter: xTickFormatter }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: xTickFormatter, valueFormatter: valueFormatter }) }), legend && _jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }), keys.map((key) => kind === "line" ? (_jsx(RechartsPrimitive.Line, { type: "monotone", dataKey: key, stroke: seriesVar(key), strokeWidth: 2, dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)) : kind === "bar" ? (_jsx(RechartsPrimitive.Bar, { dataKey: key, stackId: stacked ? "stack" : undefined, fill: seriesVar(key), maxBarSize: 48, radius: stacked ? 0 : [4, 4, 0, 0], isAnimationActive: false }, key)) : (_jsx(RechartsPrimitive.Area, { type: "monotone", dataKey: key, stackId: stacked ? "stack" : undefined, stroke: seriesVar(key), fill: seriesVar(key), fillOpacity: 0.25, strokeWidth: 2,
|
|
25
|
+
return (_jsx(ChartContainer, { config: config, responsive: !fixed, className: className, style: fixed ? undefined : { height, aspectRatio: "auto" }, children: _jsxs(ChartRoot, { data: data, width: width, height: fixed ? height : undefined, margin: { top: 4, right: 8, bottom: 0, left: 0 }, children: [_jsx(RechartsPrimitive.CartesianGrid, { vertical: false, strokeDasharray: "3 3" }), _jsx(RechartsPrimitive.XAxis, { dataKey: xKey, tickLine: false, axisLine: false, tickMargin: 8, minTickGap: 24, tickFormatter: xTickFormatter }), _jsx(RechartsPrimitive.YAxis, { width: 48, tickLine: false, axisLine: false, tickFormatter: compactAxisTick }), _jsx(ChartTooltip, { content: _jsx(ChartTooltipContent, { labelFormatter: xTickFormatter, valueFormatter: valueFormatter }) }), legend && _jsx(ChartLegend, { content: _jsx(ChartLegendContent, {}) }), keys.map((key) => kind === "line" ? (_jsx(RechartsPrimitive.Line, { type: "monotone", dataKey: key, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), strokeWidth: 2, dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)) : kind === "bar" ? (_jsx(RechartsPrimitive.Bar, { dataKey: key, stackId: stacked ? "stack" : undefined, fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), maxBarSize: 48, radius: stacked ? 0 : [4, 4, 0, 0], isAnimationActive: false }, key)) : (_jsx(RechartsPrimitive.Area, { type: "monotone", dataKey: key, stackId: stacked ? "stack" : undefined, stroke: seriesVar(key, chartSlotColor(fallbackSlot(key))), fill: seriesVar(key, chartSlotColor(fallbackSlot(key))), fillOpacity: 0.25, strokeWidth: 2,
|
|
15
26
|
// A one-or-two point window would otherwise draw a floating
|
|
16
27
|
// speck; dots make sparse data read as sparse, not broken.
|
|
17
28
|
dot: data.length <= 3 ? { r: 3 } : false, isAnimationActive: false }, key)))] }) }));
|
|
@@ -10,7 +10,7 @@ export type ToolCallStatus = {
|
|
|
10
10
|
type: "requires-action";
|
|
11
11
|
reason?: string;
|
|
12
12
|
};
|
|
13
|
-
interface ToolCallCardProps {
|
|
13
|
+
export interface ToolCallCardProps {
|
|
14
14
|
icon?: ReactNode;
|
|
15
15
|
title: string;
|
|
16
16
|
description?: string;
|
|
@@ -22,6 +22,17 @@ interface ToolCallCardProps {
|
|
|
22
22
|
};
|
|
23
23
|
children?: ReactNode;
|
|
24
24
|
className?: string;
|
|
25
|
+
/** Start expanded. Ignored when `open` is supplied. */
|
|
26
|
+
defaultOpen?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Controlled expansion.
|
|
29
|
+
*
|
|
30
|
+
* A card whose body is expensive to mount — a live force simulation, a media
|
|
31
|
+
* player — needs to know whether it is expanded so it can defer that cost,
|
|
32
|
+
* and needs to react when the user opens it. Left undefined, the card manages
|
|
33
|
+
* its own state exactly as before.
|
|
34
|
+
*/
|
|
35
|
+
open?: boolean;
|
|
36
|
+
onOpenChange?: (open: boolean) => void;
|
|
25
37
|
}
|
|
26
|
-
export declare function ToolCallCard({ icon, title, description, status, action, children, className, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
|
|
27
|
-
export {};
|
|
38
|
+
export declare function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen, open, onOpenChange, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -3,8 +3,15 @@ import { Loader2, CheckCircle2, XCircle, AlertCircle, ChevronDown } from "lucide
|
|
|
3
3
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
4
4
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
-
export function ToolCallCard({ icon, title, description, status, action, children, className, }) {
|
|
7
|
-
const [
|
|
6
|
+
export function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen = false, open, onOpenChange, }) {
|
|
7
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
8
|
+
const isControlled = open !== undefined;
|
|
9
|
+
const isOpen = isControlled ? open : uncontrolledOpen;
|
|
10
|
+
const setIsOpen = (next) => {
|
|
11
|
+
if (!isControlled)
|
|
12
|
+
setUncontrolledOpen(next);
|
|
13
|
+
onOpenChange?.(next);
|
|
14
|
+
};
|
|
8
15
|
const isSuccess = status.type === "complete";
|
|
9
16
|
const isError = status.type === "incomplete" && status.reason === "error";
|
|
10
17
|
const isRunning = status.type === "running";
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { ChatHeader } from "./components/chat-header.js";
|
|
|
14
14
|
export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
|
|
15
15
|
export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
|
|
16
16
|
export { ToolCallCard } from "./components/tool-call-card.js";
|
|
17
|
-
export type { ToolCallStatus } from "./components/tool-call-card.js";
|
|
17
|
+
export type { ToolCallStatus, ToolCallCardProps } from "./components/tool-call-card.js";
|
|
18
18
|
export { ToolFallback } from "./components/tool-fallback.js";
|
|
19
19
|
export { ConfirmationCard } from "./components/confirmation-card.js";
|
|
20
20
|
export { TooltipIconButton } from "./components/tooltip-icon-button.js";
|
package/dist/lib/app-store.js
CHANGED
|
@@ -50,6 +50,94 @@ function compactReferenceTarget(target) {
|
|
|
50
50
|
proposedValue: compactContextValue(target.proposedValue),
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Structural comparison that survives what a `payload` actually carries.
|
|
55
|
+
*
|
|
56
|
+
* Compared by WALKING the two values, not by serialising them. A payload is
|
|
57
|
+
* often a database row, and a `bigint` is ordinary there — the Neo4j driver
|
|
58
|
+
* returns them natively — which rules out `JSON.stringify` twice over. It
|
|
59
|
+
* throws on a bigint outright, and the obvious repair (a replacer that tags
|
|
60
|
+
* one) cannot be made correct: `10n` as `"10"` collides with the string
|
|
61
|
+
* `"10"`, as `"10n"` with the string `"10n"`, as `{__bigint:"10"}` with a
|
|
62
|
+
* payload field that happens to be that object. `payload` is arbitrary, so
|
|
63
|
+
* every encoding can be imitated by data shaped like the encoding, and a
|
|
64
|
+
* collision here makes a real change read as "unchanged" — leaving stale
|
|
65
|
+
* context attached to every later message.
|
|
66
|
+
*
|
|
67
|
+
* Comparing types directly has nothing to imitate: `typeof 10n` is not
|
|
68
|
+
* `typeof "10"`. It is also key-order independent (which the serialising
|
|
69
|
+
* version never actually was, despite its name) and cheaper, since it stops at
|
|
70
|
+
* the first difference instead of building two strings.
|
|
71
|
+
*
|
|
72
|
+
* Cycles are tracked as PAIRS. A single visited-set marks an object on first
|
|
73
|
+
* sight, so the second appearance of a shared but acyclic sub-object reads as
|
|
74
|
+
* a cycle — and two values differing only there would compare equal.
|
|
75
|
+
*/
|
|
76
|
+
function isDate(value) {
|
|
77
|
+
return Object.prototype.toString.call(value) === "[object Date]";
|
|
78
|
+
}
|
|
79
|
+
/** Own enumerable keys describe this object completely. */
|
|
80
|
+
function isPlainObject(value) {
|
|
81
|
+
const proto = Object.getPrototypeOf(value);
|
|
82
|
+
return proto === Object.prototype || proto === null;
|
|
83
|
+
}
|
|
84
|
+
function deepEqual(a, b, seen) {
|
|
85
|
+
if (Object.is(a, b))
|
|
86
|
+
return true;
|
|
87
|
+
if (typeof a !== typeof b)
|
|
88
|
+
return false;
|
|
89
|
+
if (typeof a !== "object" || a === null || b === null)
|
|
90
|
+
return false;
|
|
91
|
+
const left = a;
|
|
92
|
+
const right = b;
|
|
93
|
+
const pairs = seen ?? new Map();
|
|
94
|
+
const already = pairs.get(left);
|
|
95
|
+
// Assumed equal while in flight: two structures that recurse identically are
|
|
96
|
+
// equal, and re-entering would not terminate.
|
|
97
|
+
if (already?.has(right))
|
|
98
|
+
return true;
|
|
99
|
+
if (already)
|
|
100
|
+
already.add(right);
|
|
101
|
+
else
|
|
102
|
+
pairs.set(left, new Set([right]));
|
|
103
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
104
|
+
if (!Array.isArray(left) || !Array.isArray(right))
|
|
105
|
+
return false;
|
|
106
|
+
if (left.length !== right.length)
|
|
107
|
+
return false;
|
|
108
|
+
return left.every((item, index) => deepEqual(item, right[index], pairs));
|
|
109
|
+
}
|
|
110
|
+
// Enumerable keys only describe a PLAIN object. A `Date`, `Map`, `Set` or
|
|
111
|
+
// `RegExp` keeps its state internally and has none, so comparing key sets
|
|
112
|
+
// said two different dates were the same value — and this guard's entire job
|
|
113
|
+
// is deciding whether something changed. `Date` is worth spelling out
|
|
114
|
+
// because it is what a host or an adapter actually puts in a property; every
|
|
115
|
+
// other exotic object falls through to "not equal unless identical", which
|
|
116
|
+
// is the safe direction: a spurious change costs one redundant update, a
|
|
117
|
+
// missed one loses the user's data silently.
|
|
118
|
+
if (isDate(left) || isDate(right)) {
|
|
119
|
+
return isDate(left) && isDate(right) && Object.is(left.getTime(), right.getTime());
|
|
120
|
+
}
|
|
121
|
+
if (!isPlainObject(left) || !isPlainObject(right))
|
|
122
|
+
return false;
|
|
123
|
+
const leftKeys = Object.keys(left);
|
|
124
|
+
const rightKeys = new Set(Object.keys(right));
|
|
125
|
+
if (leftKeys.length !== rightKeys.size)
|
|
126
|
+
return false;
|
|
127
|
+
return leftKeys.every((key) => rightKeys.has(key) &&
|
|
128
|
+
deepEqual(left[key], right[key], pairs));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Do two keyed context items carry the same value?
|
|
132
|
+
*
|
|
133
|
+
* Compares everything the consumer sees, ignoring the identity fields the
|
|
134
|
+
* store owns (`id`, `createdAt`). `payload` is arbitrary — a selected row, a
|
|
135
|
+
* graph node and its properties — so it is compared structurally.
|
|
136
|
+
*/
|
|
137
|
+
function sameContextValue(current, next) {
|
|
138
|
+
const { id: _id, createdAt: _createdAt, ...rest } = current;
|
|
139
|
+
return deepEqual(rest, next);
|
|
140
|
+
}
|
|
53
141
|
export const useAppStore = create((set, get) => ({
|
|
54
142
|
// Thread state
|
|
55
143
|
threadActive: false,
|
|
@@ -135,11 +223,18 @@ export const useAppStore = create((set, get) => ({
|
|
|
135
223
|
const label = item.label.length > MAX_LABEL_LENGTH
|
|
136
224
|
? `${item.label.slice(0, MAX_LABEL_LENGTH)}...`
|
|
137
225
|
: item.label;
|
|
226
|
+
// Setting the same value twice is not a change. Without this the method
|
|
227
|
+
// mints a fresh `id` and a fresh array every call, so a UI that mirrors a
|
|
228
|
+
// selection into context — re-asserting the same item as it re-renders —
|
|
229
|
+
// churns the store, re-renders every subscriber, and can drive itself
|
|
230
|
+
// into an update loop. The null branch above already declines to write
|
|
231
|
+
// when nothing changes; this is the same rule for the write path.
|
|
232
|
+
const current = get().contextItems.find((existing) => existing.key === key);
|
|
233
|
+
const next = { ...item, key, label };
|
|
234
|
+
if (current && sameContextValue(current, next))
|
|
235
|
+
return;
|
|
138
236
|
set({
|
|
139
|
-
contextItems: [
|
|
140
|
-
...withoutKey,
|
|
141
|
-
{ ...item, key, label, id: crypto.randomUUID(), createdAt: Date.now() },
|
|
142
|
-
],
|
|
237
|
+
contextItems: [...withoutKey, { ...next, id: crypto.randomUUID(), createdAt: Date.now() }],
|
|
143
238
|
});
|
|
144
239
|
},
|
|
145
240
|
removeContextItem: (id) => set({ contextItems: get().contextItems.filter((i) => i.id !== id) }),
|
|
@@ -12,16 +12,34 @@ export interface PdfHighlightRef {
|
|
|
12
12
|
export interface ToolPanelContent {
|
|
13
13
|
title: string;
|
|
14
14
|
content: string;
|
|
15
|
-
type: "markdown" | "text" | "page" | "pdf";
|
|
15
|
+
type: "markdown" | "text" | "page" | "pdf" | "graph";
|
|
16
16
|
/** PDF highlights for citation overlays (only when type === "pdf") */
|
|
17
17
|
pdfHighlights?: PdfHighlightRef[];
|
|
18
18
|
/** Initial page to scroll to (only when type === "pdf") */
|
|
19
19
|
initialPage?: number;
|
|
20
20
|
/** SPACES entity ID — enables pin and navigate actions in panel header */
|
|
21
21
|
entityId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Structured payload for renderers that need more than a string — a graph,
|
|
24
|
+
* a chart, a table. `content` is still expected to carry a serialization, so
|
|
25
|
+
* the header's Copy action and the built-in text fallback keep working when
|
|
26
|
+
* no renderer matches.
|
|
27
|
+
*/
|
|
28
|
+
data?: unknown;
|
|
22
29
|
}
|
|
23
30
|
/** Pluggable content renderer for the tool panel — registered by feature modules. */
|
|
24
31
|
export interface PanelRendererEntry {
|
|
32
|
+
/**
|
|
33
|
+
* Stable identity. When present, re-registering replaces the previous entry
|
|
34
|
+
* with the same id instead of appending a second copy.
|
|
35
|
+
*
|
|
36
|
+
* Registration usually happens in a module's `useInit`, which runs again on
|
|
37
|
+
* every remount — a StrictMode double-render, a route change, a hot reload.
|
|
38
|
+
* Without an id those calls stack up, `renderers` grows without bound, and
|
|
39
|
+
* `find()` keeps matching the oldest (now stale) closure. Entries without an
|
|
40
|
+
* id keep the original append behaviour.
|
|
41
|
+
*/
|
|
42
|
+
id?: string;
|
|
25
43
|
/** Check if this renderer handles the given content */
|
|
26
44
|
match: (content: ToolPanelContent) => boolean;
|
|
27
45
|
/** Lazy component to render. Receives content as prop. */
|
|
@@ -55,7 +55,18 @@ export const useToolPanelStore = create((set, get) => ({
|
|
|
55
55
|
isFullscreen: false,
|
|
56
56
|
renderers: [],
|
|
57
57
|
openPanel: (content) => set({ isOpen: true, content }),
|
|
58
|
-
registerRenderer: (entry) => set((state) =>
|
|
58
|
+
registerRenderer: (entry) => set((state) => {
|
|
59
|
+
if (entry.id === undefined)
|
|
60
|
+
return { renderers: [...state.renderers, entry] };
|
|
61
|
+
const index = state.renderers.findIndex((existing) => existing.id === entry.id);
|
|
62
|
+
if (index === -1)
|
|
63
|
+
return { renderers: [...state.renderers, entry] };
|
|
64
|
+
// Replace in place: registration order decides which renderer wins a
|
|
65
|
+
// match, so re-registering must not silently reprioritise it.
|
|
66
|
+
const renderers = [...state.renderers];
|
|
67
|
+
renderers[index] = entry;
|
|
68
|
+
return { renderers };
|
|
69
|
+
}),
|
|
59
70
|
closePanel: () => set({ isOpen: false, isFullscreen: false }),
|
|
60
71
|
setContent: (content) => set({ content }),
|
|
61
72
|
setPanelWidth: (width) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iloveagents/foundry-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
|
|
6
6
|
"keywords": [
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
"recharts": "^3.10.1",
|
|
77
77
|
"remark-gfm": "^4.0.0",
|
|
78
78
|
"tailwind-merge": "^3.5.0",
|
|
79
|
-
"@iloveagents/foundry-agent": "^0.
|
|
80
|
-
"@iloveagents/foundry-web-primitives": "^0.
|
|
79
|
+
"@iloveagents/foundry-agent": "^0.25.0",
|
|
80
|
+
"@iloveagents/foundry-web-primitives": "^0.25.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@ag-ui/client": "^0.0.52",
|