From 5acd8259031c55dab85d97ddb2fea88be4a19ac8 Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 22 Jul 2026 01:30:36 +0530 Subject: [PATCH 1/3] feat(x): inline charts in chat via a charts skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat's markdown fence override now renders ```chart blocks (the same ChartBlockSchema notes use) as interactive recharts inline — the mermaid pattern. A new ChartRenderer handles line/bar/pie with multi-series support, light/dark theming, and a quiet placeholder while the fence body is still streaming. ChartBlockSchema.y widens to string | string[] for multi-series charts (notes chart block updated to map series too), with a chartSeries() helper in shared. A new 'charts' skill teaches the model the fence format, form selection (line/bar/pie, no mixed scales), and data honesty rules; loaded on demand like any skill. --- .../ai-elements/markdown-code-override.tsx | 12 ++ .../src/components/chart-renderer.tsx | 147 ++++++++++++++++++ .../renderer/src/extensions/chart-block.tsx | 11 +- .../runtime/assembly/skills/charts/skill.ts | 74 +++++++++ .../core/src/runtime/assembly/skills/index.ts | 7 + apps/x/packages/shared/src/blocks.ts | 9 +- 6 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chart-renderer.tsx create mode 100644 apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts diff --git a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx index 9e6a3d3e..4c5eca24 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/markdown-code-override.tsx @@ -1,6 +1,7 @@ import { isValidElement, type JSX } from 'react' import { FilePathCard } from './file-path-card' import { MermaidRenderer } from '@/components/mermaid-renderer' +import { ChartRenderer } from '@/components/chart-renderer' export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { const { children, ...rest } = props @@ -31,6 +32,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) { return } } + if ( + typeof childProps.className === 'string' && + childProps.className.includes('language-chart') + ) { + const text = typeof childProps.children === 'string' + ? childProps.children.trim() + : '' + if (text) { + return + } + } } // Passthrough for all other code blocks - return children directly diff --git a/apps/x/apps/renderer/src/components/chart-renderer.tsx b/apps/x/apps/renderer/src/components/chart-renderer.tsx new file mode 100644 index 00000000..2edb83c1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chart-renderer.tsx @@ -0,0 +1,147 @@ +import { useMemo } from 'react' +import { BarChart3 } from 'lucide-react' +import { blocks } from '@x/shared' +import { useTheme } from '@/contexts/theme-context' +import { + LineChart, Line, + BarChart, Bar, + PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts' + +// Categorical palettes validated for CVD separation and surface contrast on +// each mode's chart surface (dataviz six-checks validator). Slots are +// assigned to series in fixed order — the dark column is the same hues +// re-stepped for the dark surface, not a different palette. +const SERIES_COLORS_LIGHT = ['#2a78d6', '#008300', '#e87ba4', '#eda100', '#1baf7a', '#eb6834'] +const SERIES_COLORS_DARK = ['#3987e5', '#008300', '#d55181', '#c98500', '#199e70', '#d95926'] + +interface ChartRendererProps { + /** Raw contents of a ```chart fence: ChartBlockSchema JSON. */ + source: string +} + +/** + * Inline chart for chat messages: renders a ```chart fenced block (same + * ChartBlockSchema the notes chart block uses) via recharts. Invalid or + * still-streaming JSON renders as a quiet placeholder rather than an error — + * the fence body arrives token by token while the model writes it. + */ +export function ChartRenderer({ source }: ChartRendererProps) { + const { resolvedTheme } = useTheme() + const colors = resolvedTheme === 'dark' ? SERIES_COLORS_DARK : SERIES_COLORS_LIGHT + const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0' + const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e' + + const config = useMemo(() => { + try { + return blocks.ChartBlockSchema.parse(JSON.parse(source)) + } catch { + return null + } + }, [source]) + + if (!config || !config.data || config.data.length === 0) { + return ( +
+ + Preparing chart… +
+ ) + } + + const data = config.data + const series = blocks.chartSeries(config) + const axisProps = { + stroke: textColor, + tick: { fill: textColor, fontSize: 11 }, + tickLine: false, + } + + return ( +
+ {config.title && ( +
{config.title}
+ )} + + {config.chart === 'line' ? ( + + + + + + {series.length > 1 && } + {series.map((key, i) => ( + + ))} + + ) : config.chart === 'bar' ? ( + + + + + + {series.length > 1 && } + {series.map((key, i) => ( + + ))} + + ) : ( + + + + + {data.map((_, i) => ( + + ))} + + + )} + +
+ ) +} diff --git a/apps/x/apps/renderer/src/extensions/chart-block.tsx b/apps/x/apps/renderer/src/extensions/chart-block.tsx index 3377b157..b8454d1c 100644 --- a/apps/x/apps/renderer/src/extensions/chart-block.tsx +++ b/apps/x/apps/renderer/src/extensions/chart-block.tsx @@ -64,6 +64,7 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record{error} if (!data || data.length === 0) return
No data
+ const series = blocks.chartSeries(config!) return ( {config!.chart === 'line' ? ( @@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record - + {series.map((key, index) => ( + + ))} ) : config!.chart === 'bar' ? ( @@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record - + {series.map((key, index) => ( + + ))} ) : ( - + {data.map((_, index) => ( ))} diff --git a/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts new file mode 100644 index 00000000..4e071609 --- /dev/null +++ b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts @@ -0,0 +1,74 @@ +export const skill = String.raw` +# Charts + +Load this skill when the user asks for a chart, graph, plot, trend, comparison, or "visualize" — or when data you've gathered (prices over time, counts per category, proportions) would land better as a picture than a table. + +## How it works + +Emit a fenced code block with language \`chart\` anywhere in your reply. The app renders it as an interactive chart inline (tooltips, legend, light/dark theming are handled for you — never pick colors yourself). Everything outside the fence is normal markdown. + +\`\`\`\` +\`\`\`chart +{ ...JSON config... } +\`\`\` +\`\`\`\` + +## Config schema + +- **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\` +- **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers. +- **\`x\`** (required): key of the label/category field in each row +- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart +- **\`title\`** (optional): short heading shown above the chart + +## Picking the form + +- **line** — change over time (prices, counts by date). X is the time field. +- **bar** — compare magnitudes across categories (issues per label, revenue per region). +- **pie** — proportions of a whole; only with ≤ 6 slices, otherwise use a bar. +- Two measures with very different scales (e.g. a $600 stock vs a $5 one): do NOT mix them on one chart — either normalize to % change and say so in the title, or emit two chart blocks. + +## Examples + +Multi-series line (comparison over time, normalized): + +\`\`\`chart +{ + "chart": "line", + "title": "5-Day % Change", + "x": "date", + "y": ["AAPL", "NVDA", "SPY"], + "data": [ + { "date": "Jul 15", "AAPL": 0, "NVDA": 0, "SPY": 0 }, + { "date": "Jul 16", "AAPL": -0.4, "NVDA": 1.2, "SPY": 0.1 }, + { "date": "Jul 17", "AAPL": -1.1, "NVDA": 0.8, "SPY": -0.3 } + ] +} +\`\`\` + +Single-series bar: + +\`\`\`chart +{ + "chart": "bar", + "title": "Open issues by area", + "x": "area", + "y": "count", + "data": [ + { "area": "sync", "count": 14 }, + { "area": "editor", "count": 9 }, + { "area": "billing", "count": 3 } + ] +} +\`\`\` + +## Rules + +- Data must come from what you actually fetched or computed this turn — never fabricate points to make a chart possible. Too little real data? Say so instead of charting. +- Keep it readable: ≤ ~30 rows per chart, ≤ 6 series. Round values sensibly. +- Numbers must be JSON numbers, not strings ("3.1", "5%" won't plot). +- Follow the chart with one sentence of takeaway — what the reader should see in it. +- One chart per distinct question; don't emit several variants of the same data. +`; + +export default skill; diff --git a/apps/x/packages/core/src/runtime/assembly/skills/index.ts b/apps/x/packages/core/src/runtime/assembly/skills/index.ts index ee676fa1..1124ae93 100644 --- a/apps/x/packages/core/src/runtime/assembly/skills/index.ts +++ b/apps/x/packages/core/src/runtime/assembly/skills/index.ts @@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js"; import notifyUserSkill from "./notify-user/skill.js"; import appsSkill from "./apps/skill.js"; import slackSkill from "./slack/skill.js"; +import chartsSkill from "./charts/skill.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/runtime/assembly/skills"; @@ -77,6 +78,12 @@ const definitions: SkillDefinition[] = [ summary: "Prepare for meetings by gathering context about attendees from the knowledge base.", content: meetingPrepSkill, }, + { + id: "charts", + title: "Charts", + summary: "Render interactive charts (line, bar, pie) inline in the chat reply. Use when the user asks for a chart/graph/visualization or when gathered data is clearer as a picture.", + content: chartsSkill, + }, { id: "organize-files", title: "Organize Files", diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 6e6c75c2..f1ee90a7 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({ data: z.array(z.record(z.string(), z.unknown())).optional(), source: z.string().optional(), x: z.string(), - y: z.string(), + // One series (string) or several (array of data keys). Pie ignores all + // but the first. + y: z.union([z.string(), z.array(z.string()).min(1)]), }); export type ChartBlock = z.infer; +/** The y series list regardless of which form the block used. */ +export function chartSeries(block: ChartBlock): string[] { + return Array.isArray(block.y) ? block.y : [block.y]; +} + export const TableBlockSchema = z.object({ columns: z.array(z.string()), data: z.array(z.record(z.string(), z.unknown())), From 099a47ac36e7b14e572053760108b42d4117081f Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 22 Jul 2026 01:38:08 +0530 Subject: [PATCH 2/3] fix(x): chart fences with alias fields no longer hang on the placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing showed models emitting pie configs with label/value instead of x/y, and long-format line data with a series column — schema-invalid fences sat on 'Preparing chart…' forever. The renderer now maps the label/value aliases before validating, distinguishes complete-but-invalid JSON (explicit 'config invalid' state) from still-streaming JSON (placeholder), and the skill spells out the exact field list, pie's x/y meaning, and wide-vs-long data format with a wrong/right example. --- .../src/components/chart-renderer.tsx | 33 ++++++++++++++++--- .../runtime/assembly/skills/charts/skill.ts | 27 +++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chart-renderer.tsx b/apps/x/apps/renderer/src/components/chart-renderer.tsx index 2edb83c1..7ecc217a 100644 --- a/apps/x/apps/renderer/src/components/chart-renderer.tsx +++ b/apps/x/apps/renderer/src/components/chart-renderer.tsx @@ -33,15 +33,38 @@ export function ChartRenderer({ source }: ChartRendererProps) { const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0' const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e' - const config = useMemo(() => { + const { config, invalid } = useMemo(() => { + let parsed: unknown try { - return blocks.ChartBlockSchema.parse(JSON.parse(source)) + parsed = JSON.parse(source) } catch { - return null + // Incomplete JSON — the fence body is still streaming in. + return { config: null, invalid: false } } + // Models occasionally reach for pie-flavored field names despite the + // skill's schema; map the predictable aliases before validating. + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record + if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label + if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value + } + const result = blocks.ChartBlockSchema.safeParse(parsed) + return result.success + ? { config: result.data, invalid: false } + : { config: null, invalid: true } }, [source]) - if (!config || !config.data || config.data.length === 0) { + // Complete JSON that fails the schema is a real error — say so instead of + // showing the streaming placeholder forever. + if (invalid || (config && (!config.data || config.data.length === 0))) { + return ( +
+ + {invalid ? 'Chart config invalid — ask me to redraw it' : 'Chart has no data'} +
+ ) + } + if (!config) { return (
@@ -50,7 +73,7 @@ export function ChartRenderer({ source }: ChartRendererProps) { ) } - const data = config.data + const data = config.data ?? [] const series = blocks.chartSeries(config) const axisProps = { stroke: textColor, diff --git a/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts index 4e071609..71ac3110 100644 --- a/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts +++ b/apps/x/packages/core/src/runtime/assembly/skills/charts/skill.ts @@ -15,12 +15,19 @@ Emit a fenced code block with language \`chart\` anywhere in your reply. The app ## Config schema +The ONLY fields are \`chart\`, \`data\`, \`x\`, \`y\`, \`title\` — nothing else exists (no \`label\`, \`value\`, \`series\`, \`color\`, etc.). Unknown fields are ignored; a missing \`x\` or \`y\` breaks the chart. + - **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\` - **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers. -- **\`x\`** (required): key of the label/category field in each row -- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart +- **\`x\`** (required): key of the label/category field in each row. For pie: the slice-name key. +- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart. For pie: the slice-value key. - **\`title\`** (optional): short heading shown above the chart +**Data must be wide-format**: one row per x value, one key per series. Never long/tidy format (a row per series-point with a series-name column) — multiple series means multiple keys in the SAME row: + +WRONG: \`[{ "day": "Mon", "index": "S&P", "pct": 0.1 }, { "day": "Mon", "index": "Dow", "pct": 0.2 }]\` +RIGHT: \`[{ "day": "Mon", "S&P": 0.1, "Dow": 0.2 }]\` with \`"y": ["S&P", "Dow"]\` + ## Picking the form - **line** — change over time (prices, counts by date). X is the time field. @@ -62,6 +69,22 @@ Single-series bar: } \`\`\` +Pie (x names the slice, y is its value — same keys as every other chart): + +\`\`\`chart +{ + "chart": "pie", + "title": "Time spent by project", + "x": "project", + "y": "hours", + "data": [ + { "project": "Alpha", "hours": 14 }, + { "project": "Beta", "hours": 6 }, + { "project": "Internal", "hours": 3 } + ] +} +\`\`\` + ## Rules - Data must come from what you actually fetched or computed this turn — never fabricate points to make a chart possible. Too little real data? Say so instead of charting. From 7bf735d6c9f8a9f0a076ae6b2e34454807f079e6 Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 22 Jul 2026 01:40:51 +0530 Subject: [PATCH 3/3] test(x): cover chart fence parsing; document y arrays in doc-collab parseChartSource extracted as a pure function with tests for the three render states (streaming / invalid / parsed), the label/value alias mapping, and multi-series y arrays. doc-collab's chart block reference now mentions the array form of y. --- .../src/components/chart-renderer.test.ts | 52 +++++++++++++++++++ .../src/components/chart-renderer.tsx | 48 ++++++++++------- .../assembly/skills/doc-collab/skill.ts | 2 +- 3 files changed, 81 insertions(+), 21 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chart-renderer.test.ts diff --git a/apps/x/apps/renderer/src/components/chart-renderer.test.ts b/apps/x/apps/renderer/src/components/chart-renderer.test.ts new file mode 100644 index 00000000..22c9273b --- /dev/null +++ b/apps/x/apps/renderer/src/components/chart-renderer.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { parseChartSource } from './chart-renderer' + +const valid = { + chart: 'line', + x: 'day', + y: 'pct', + data: [{ day: 'Mon', pct: 0.1 }], +} + +describe('parseChartSource', () => { + it('parses a valid config', () => { + const { config, invalid } = parseChartSource(JSON.stringify(valid)) + expect(invalid).toBe(false) + expect(config?.chart).toBe('line') + }) + + it('treats incomplete JSON as streaming, not invalid', () => { + const partial = JSON.stringify(valid).slice(0, 25) + expect(parseChartSource(partial)).toEqual({ config: null, invalid: false }) + }) + + it('flags complete-but-schema-invalid JSON as invalid', () => { + const { config, invalid } = parseChartSource( + JSON.stringify({ chart: 'line', data: [] }), + ) + expect(config).toBeNull() + expect(invalid).toBe(true) + }) + + it('maps the label/value pie aliases to x/y', () => { + // Seen live: models emit pie configs with label/value field names. + const { config, invalid } = parseChartSource( + JSON.stringify({ + chart: 'pie', + label: 'index', + value: 'drop', + data: [{ index: 'Nasdaq', drop: 2.4 }], + }), + ) + expect(invalid).toBe(false) + expect(config?.x).toBe('index') + expect(config?.y).toBe('drop') + }) + + it('accepts a y array for multi-series charts', () => { + const { config } = parseChartSource( + JSON.stringify({ ...valid, y: ['a', 'b'] }), + ) + expect(config?.y).toEqual(['a', 'b']) + }) +}) diff --git a/apps/x/apps/renderer/src/components/chart-renderer.tsx b/apps/x/apps/renderer/src/components/chart-renderer.tsx index 7ecc217a..81095fbb 100644 --- a/apps/x/apps/renderer/src/components/chart-renderer.tsx +++ b/apps/x/apps/renderer/src/components/chart-renderer.tsx @@ -21,6 +21,33 @@ interface ChartRendererProps { source: string } +/** + * Parse a chart fence body. Distinguishes the three states the renderer + * shows: `streaming` (JSON incomplete — the model is still writing), + * `invalid` (complete JSON that fails the schema), and a parsed config. + * Models occasionally reach for pie-flavored field names despite the + * skill's schema; the predictable label/value aliases map to x/y. + */ +export function parseChartSource( + source: string, +): { config: blocks.ChartBlock | null; invalid: boolean } { + let parsed: unknown + try { + parsed = JSON.parse(source) + } catch { + return { config: null, invalid: false } + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record + if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label + if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value + } + const result = blocks.ChartBlockSchema.safeParse(parsed) + return result.success + ? { config: result.data, invalid: false } + : { config: null, invalid: true } +} + /** * Inline chart for chat messages: renders a ```chart fenced block (same * ChartBlockSchema the notes chart block uses) via recharts. Invalid or @@ -33,26 +60,7 @@ export function ChartRenderer({ source }: ChartRendererProps) { const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0' const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e' - const { config, invalid } = useMemo(() => { - let parsed: unknown - try { - parsed = JSON.parse(source) - } catch { - // Incomplete JSON — the fence body is still streaming in. - return { config: null, invalid: false } - } - // Models occasionally reach for pie-flavored field names despite the - // skill's schema; map the predictable aliases before validating. - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const obj = parsed as Record - if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label - if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value - } - const result = blocks.ChartBlockSchema.safeParse(parsed) - return result.success - ? { config: result.data, invalid: false } - : { config: null, invalid: true } - }, [source]) + const { config, invalid } = useMemo(() => parseChartSource(source), [source]) // Complete JSON that fails the schema is a real error — say so instead of // showing the streaming placeholder forever. diff --git a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts index 27c5dea5..9678df7d 100644 --- a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts @@ -224,7 +224,7 @@ Renders a chart from inline data. - \`data\` (optional): Array of objects with the data points - \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data) - \`x\` (required): Key name for the x-axis / label field -- \`y\` (required): Key name for the y-axis / value field +- \`y\` (required): Key name for the y-axis / value field — or an array of key names to plot several series on one chart (each row then carries one key per series) ### Table Block Renders a styled table from structured data.