diff --git a/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx b/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx
index 9494f1516..494f37186 100644
--- a/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx
+++ b/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx
@@ -12,6 +12,7 @@ import remarkGfm from 'remark-gfm';
import { Prism, type SyntaxHighlighterProps } from 'react-syntax-highlighter';
import clsx from 'clsx';
+import { JsonChart } from './tools/charts/JsonChart';
import { SuspendedChart } from './tools/charts/SuspendedChart';
type CodeElementProps = {
@@ -198,7 +199,7 @@ export const AIMarkdown: AIMarkdown = (props) => {
const mergedToolComponents: ToolComponents = useMemo(
() => ({
chartjs: SuspendedChart,
- json: SuspendedChart,
+ json: JsonChart,
...props.toolComponents,
}),
[props.toolComponents],
diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx b/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx
index fefc147c8..8475f2956 100644
--- a/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx
+++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx
@@ -8,6 +8,7 @@ import {
LinearScale,
LineElement,
PointElement,
+ RadialLinearScale,
Title as TitlePlugin,
Tooltip,
} from 'chart.js';
@@ -25,12 +26,15 @@ import type { ToolComponentProps } from '../../AIMarkdown';
import { useTranslationContext } from '../../../../../context/TranslationContext';
import { chartJsSchema } from './chartJsSchema';
+// react-chartjs-2 registers each chart type's controller, but not the scales those controllers
+// default to: `radar` and `polarArea` need `RadialLinearScale`.
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
+ RadialLinearScale,
BarElement,
LineElement,
PointElement,
diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx b/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx
new file mode 100644
index 000000000..249ef9368
--- /dev/null
+++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import type { ToolComponentProps } from '../../AIMarkdown';
+import { looksLikeChartJsConfig } from './chartTypes';
+import { SuspendedChart } from './SuspendedChart';
+
+/**
+ * `json` fences render as a chart only when they hold a Chart.js config; any other JSON
+ * (an API response, a config file, a partial payload mid-stream) stays highlighted code.
+ */
+export const JsonChart = (props: ToolComponentProps) =>
+ looksLikeChartJsConfig(props.data) ? (
+
+ ) : (
+ <>{props.fallback}>
+ );
diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts
index d5891c957..e62dc2060 100644
--- a/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts
+++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts
@@ -1,5 +1,7 @@
import * as zod from 'zod';
+import { CHART_JS_TYPES } from './chartTypes';
+
export const chartJsSchema = zod.object({
data: zod.object({
datasets: zod.array(
@@ -34,14 +36,5 @@ export const chartJsSchema = zod.object({
xLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(),
yLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(),
}),
- type: zod.enum([
- 'pie',
- 'bar',
- 'line',
- 'bubble',
- 'doughnut',
- 'polarArea',
- 'radar',
- 'scatter',
- ]),
+ type: zod.enum(CHART_JS_TYPES),
});
diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts
new file mode 100644
index 000000000..e1d039166
--- /dev/null
+++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts
@@ -0,0 +1,33 @@
+export const CHART_JS_TYPES = [
+ 'pie',
+ 'bar',
+ 'line',
+ 'bubble',
+ 'doughnut',
+ 'polarArea',
+ 'radar',
+ 'scatter',
+] as const;
+
+const chartJsTypes = new Set(CHART_JS_TYPES);
+
+/**
+ * Cheap structural check (no zod, no chart.js) for whether a `json` fence holds a Chart.js
+ * config. Full validation still happens in the lazily loaded chart chunk.
+ */
+export const looksLikeChartJsConfig = (data: string): boolean => {
+ try {
+ const parsed: unknown = JSON.parse(data);
+ if (!parsed || typeof parsed !== 'object') return false;
+ const { data: chartData, type } = parsed as { data?: unknown; type?: unknown };
+ return (
+ typeof type === 'string' &&
+ chartJsTypes.has(type) &&
+ !!chartData &&
+ typeof chartData === 'object' &&
+ Array.isArray((chartData as { datasets?: unknown }).datasets)
+ );
+ } catch {
+ return false;
+ }
+};
diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md
index 38cc66cdb..75fe053b7 100644
--- a/src/plugins/AIComponents/README.md
+++ b/src/plugins/AIComponents/README.md
@@ -619,5 +619,5 @@ function AIResponseStream({ response }) {
-
+
diff --git a/src/plugins/AIComponents/__tests__/AIMarkdown.test.tsx b/src/plugins/AIComponents/__tests__/AIMarkdown.test.tsx
index c3e6b5ffc..e7cf79849 100644
--- a/src/plugins/AIComponents/__tests__/AIMarkdown.test.tsx
+++ b/src/plugins/AIComponents/__tests__/AIMarkdown.test.tsx
@@ -55,6 +55,25 @@ describe('AIMarkdown', () => {
expect(screen.getByText('Loading chart...')).toBeInTheDocument();
});
+ it('renders a plain json fence as highlighted code, not as a chart', () => {
+ const { container } = render(
+ {'```json\n{ "status": "ok", "items": [1, 2] }\n```'},
+ );
+ expect(screen.queryByText('Loading chart...')).toBeNull();
+ expect(
+ container.querySelector('.str-chat__ai-syntax-highlighter-pre'),
+ ).toHaveTextContent('"status"');
+ });
+
+ it('renders a json fence that holds a Chart.js config as a chart', () => {
+ const chartConfig = JSON.stringify({
+ data: { datasets: [{ data: [1, 2, 3] }], labels: ['a', 'b', 'c'] },
+ type: 'bar',
+ });
+ render({`\`\`\`json\n${chartConfig}\n\`\`\``});
+ expect(screen.getByText('Loading chart...')).toBeInTheDocument();
+ });
+
it('has no a11y violations', async () => {
const { container } = render({'# Title\n\n- a\n- b'});
expect(await axe(container)).toHaveNoViolations();
diff --git a/src/plugins/AIComponents/__tests__/chartRegistration.test.ts b/src/plugins/AIComponents/__tests__/chartRegistration.test.ts
new file mode 100644
index 000000000..d1ab3d5ef
--- /dev/null
+++ b/src/plugins/AIComponents/__tests__/chartRegistration.test.ts
@@ -0,0 +1,28 @@
+import { Chart as ChartJS } from 'chart.js';
+
+import { CHART_JS_TYPES } from '../AIMarkdown/tools/charts/chartTypes';
+
+// jsdom has no canvas context, so Chart.js bails out before building scales and a render test
+// cannot see an unregistered scale. Check the registry instead: every chart type the schema
+// accepts must have its controller and the scales that controller defaults to registered once
+// the chart module has loaded.
+describe('Chart.js registration', () => {
+ beforeAll(async () => {
+ await import('../AIMarkdown/tools/charts/Chart');
+ });
+
+ it.each(CHART_JS_TYPES)(
+ 'registers the controller and default scales for %s',
+ (type) => {
+ expect(() => ChartJS.registry.getController(type)).not.toThrow();
+
+ const scales = Object.values(ChartJS.overrides[type]?.scales ?? {});
+ for (const scale of scales) {
+ const scaleType = (scale as { type?: string } | undefined)?.type;
+ if (scaleType) {
+ expect(() => ChartJS.registry.getScale(scaleType)).not.toThrow();
+ }
+ }
+ },
+ );
+});
diff --git a/src/plugins/AIComponents/styling/streaming-message.scss b/src/plugins/AIComponents/styling/streaming-message.scss
index a66c14391..bcfa6858f 100644
--- a/src/plugins/AIComponents/styling/streaming-message.scss
+++ b/src/plugins/AIComponents/styling/streaming-message.scss
@@ -81,13 +81,13 @@
hyphens: none;
}
- code[class*='language-'] {
- &::selection,
- &::-moz-selection {
- background: ai-token(syntax-selection-bg);
- color: inherit;
- text-shadow: none;
- }
+ // `::selection` and `::-moz-selection` must not share a selector list: a browser that
+ // does not recognise one pseudo-element drops the whole rule (Chromium/WebKit drop
+ // `::-moz-selection`). Current Firefox supports `::selection`, so only that is needed.
+ code[class*='language-']::selection {
+ background: ai-token(syntax-selection-bg);
+ color: inherit;
+ text-shadow: none;
}
.token {