From 3016d454770227bc37fea41b9c9ef9e51af5493a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 13 Oct 2026 10:54:06 +0200 Subject: [PATCH 1/4] fix(ai-components): render plain json fences as code instead of the chart renderer Only json fences that hold a Chart.js config take the chart path; any other JSON stays highlighted code instead of flashing 'Loading chart...' and loading the chart chunk. chartjs fences are unchanged. --- .../AIComponents/AIMarkdown/AIMarkdown.tsx | 3 +- .../AIMarkdown/tools/charts/JsonChart.tsx | 16 +++++++++ .../AIMarkdown/tools/charts/chartJsSchema.ts | 13 ++------ .../AIMarkdown/tools/charts/chartTypes.ts | 33 +++++++++++++++++++ .../__tests__/AIMarkdown.test.tsx | 19 +++++++++++ 5 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx create mode 100644 src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts 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/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/__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(); From 266959f4d091fa3feed067acf749c2d6f3ad2585 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 13 Oct 2026 10:54:07 +0200 Subject: [PATCH 2/4] fix(ai-components): apply the syntax selection colour in Chromium and WebKit ::selection shared a selector list with ::-moz-selection, so browsers that do not know the -moz- pseudo-element dropped the whole rule. --- .../AIComponents/styling/streaming-message.scss | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 { From 6e7431439076bfd2673d10c370c4181f33f59b24 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 13 Oct 2026 10:54:08 +0200 Subject: [PATCH 3/4] fix(ai-components): register RadialLinearScale so radar and polarArea charts render react-chartjs-2 registers only each chart type's controller; radar and polarArea default to the radialLinear scale, which was never registered, so those configs fell back to raw code. --- .../AIMarkdown/tools/charts/Chart.tsx | 4 +++ .../__tests__/chartRegistration.test.ts | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/plugins/AIComponents/__tests__/chartRegistration.test.ts 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/__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(); + } + } + }, + ); +}); From 65778a35968863fc2a242f5ca4e8510fb3f76248 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 13 Oct 2026 10:54:09 +0200 Subject: [PATCH 4/4] docs(ai-components): add alt text to the README logo link --- src/plugins/AIComponents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }) {
- +Stream