From 60165979f0d067c65734c2d633e1b0c4c79b2dc9 Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa Date: Mon, 20 Jul 2026 17:51:14 +0530 Subject: [PATCH 1/6] feat(x): scrollable release-notes box on the update card, notes on Windows too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notes pane was a bare max-h clip: nothing signalled that more content sat below the fold, and Squirrel.Windows never supplies release notes at all, so Windows users only ever saw the static fallback line. Squirrel.Mac is no better in the edit-after-publish window — it snapshots the release body at download time, which is how v0.7.7 shipped a card showing two paragraphs of a ten-item release. - Render the notes in a bordered, inset scroll box (max-h + overflow-y) so the frame and scrollbar make the overflow visible on both platforms. - Backfill the notes from the GitHub release body after update-downloaded: gives Windows notes for the first time and replaces macOS's stale snapshot with the current body. On fetch failure the snapshot (or the fallback line) stands. - Strip the release body's leading "What's new" heading, which duplicated the card's own label. Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/updater.ts | 32 ++++++++++++++++++- .../renderer/src/components/update-card.tsx | 14 ++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/main/src/updater.ts b/apps/x/apps/main/src/updater.ts index 66d5aee3..53369548 100644 --- a/apps/x/apps/main/src/updater.ts +++ b/apps/x/apps/main/src/updater.ts @@ -1,4 +1,4 @@ -import { app, autoUpdater, nativeImage, BrowserWindow } from "electron"; +import { app, autoUpdater, net, nativeImage, BrowserWindow } from "electron"; import { capture } from "@x/core/dist/analytics/posthog.js"; import type { ipc } from "@x/shared"; @@ -90,6 +90,12 @@ export function initUpdater(): void { releaseNotes: releaseNotes || undefined, }); showReadyBadge(); + // Squirrel.Windows never carries notes, and Squirrel.Mac's copy is a + // snapshot from download time — stale when the release body is edited + // after publish (update.electronjs.org caching widens that window). + // Refresh from the GitHub API; on failure the snapshot (or the card's + // static fallback line) stands. + void backfillReleaseNotes(releaseName || undefined); }); autoUpdater.on("error", (err) => { setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt }); @@ -110,6 +116,30 @@ export function initUpdater(): void { setInterval(checkForUpdates, CHECK_INTERVAL_MS); } +/** + * Replace the staged update's release notes with the current GitHub release + * body. releaseName is "0.7.7" from Squirrel.Windows and "v0.7.7" from + * Squirrel.Mac — normalize to the tag form. + */ +async function backfillReleaseNotes(releaseName: string | undefined): Promise { + if (!releaseName) return; + try { + const tag = `v${releaseName.replace(/^v/, "")}`; + const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, { + headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" }, + }); + if (!res.ok) return; + const { body } = (await res.json()) as { body?: string }; + const notes = body?.trim(); + // Re-check the state: the fetch raced user actions (quitAndInstall). + if (notes && status.state === "ready" && status.newVersion === releaseName) { + setStatus({ ...status, releaseNotes: notes }); + } + } catch { + // Offline or rate-limited — the Squirrel snapshot / fallback line stands. + } +} + /** * Manual "Check for updates". Only meaningful when idle or errored; * checking/downloading are already in flight and ready is already staged. diff --git a/apps/x/apps/renderer/src/components/update-card.tsx b/apps/x/apps/renderer/src/components/update-card.tsx index 3c9d5039..a0b93df0 100644 --- a/apps/x/apps/renderer/src/components/update-card.tsx +++ b/apps/x/apps/renderer/src/components/update-card.tsx @@ -55,6 +55,11 @@ export function UpdateCard() { if (!visible || status?.state !== "ready") return null const releaseUrl = version ? `${RELEASES_URL}/tag/v${version}` : `${RELEASES_URL}/latest` + // Release bodies usually open with their own "What's new" heading, which + // would duplicate the card's label above the box — drop it. + const releaseNotes = status.releaseNotes + ?.replace(/^\s*#{1,6}[ \t]*what[’']?s new[ \t]*\r?\n+/i, "") + .trim() return (
What's new
- {status.releaseNotes ? ( -
+ {releaseNotes ? ( + // A bordered, fixed-height scroll box (not a bare max-h clip): the + // frame and inset scrollbar signal there is more content below the + // fold on every platform. +
- {status.releaseNotes} + {releaseNotes}
) : ( From 1de932c7bac09d210e52b3fb3aaf6077e323c22d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:00:09 +0530 Subject: [PATCH 2/6] bump max model calls to 50 --- .../packages/core/src/runtime/assembly/spawn-agent.test.ts | 6 +++--- apps/x/packages/shared/src/turns.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts index 7ac176d8..f6440301 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts @@ -38,7 +38,7 @@ function parentCreated( config: { autoPermission: true, humanAvailable: false, - maxModelCalls: 20, + maxModelCalls: 50, }, } as z.infer, ]; @@ -106,7 +106,7 @@ describe("runSpawnedAgent", () => { model: { provider: "parent-p", model: "parent-m" }, }, }); - expect(started[0].maxModelCalls).toBe(20); + expect(started[0].maxModelCalls).toBe(50); expect(started[0].signal).toBe(signal); expect(progress).toEqual([ { childTurnId: "child-1", agentName: "researcher", task: "find things" }, @@ -184,7 +184,7 @@ describe("runSpawnedAgent", () => { it("rejects a model-call budget above the cap at the schema boundary", async () => { const { services } = fakeServices({}); const result = await runSpawnedAgent( - { task: "t", instructions: "x", max_model_calls: 50 }, + { task: "t", instructions: "x", max_model_calls: 51 }, { parentTurnId: "parent-1", signal, services }, ); expect(result.isError).toBe(true); diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 9a6c14cc..424ff38b 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -16,7 +16,7 @@ import { ReasoningEffort } from "./models.js"; export type JsonValue = z.infer>; -export const DEFAULT_MAX_MODEL_CALLS = 20; +export const DEFAULT_MAX_MODEL_CALLS = 50; export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit"; // --------------------------------------------------------------------------- From 5acd8259031c55dab85d97ddb2fea88be4a19ac8 Mon Sep 17 00:00:00 2001 From: Gagan Date: Wed, 22 Jul 2026 01:30:36 +0530 Subject: [PATCH 3/6] 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 4/6] 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 5/6] 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. From e659f0bdde9642e6d63c57ff0a534f6f8515accf Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:48:36 +0530 Subject: [PATCH 6/6] Log background agent failure reasons --- apps/x/ANALYTICS.md | 3 +- .../core/src/background-tasks/runner.ts | 29 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 5bc0f456..6c8dec08 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -181,7 +181,8 @@ All renderer events live in `apps/renderer/src/lib/analytics.ts` (typed wrappers - `email_send_failed` — send returned an error or threw (`components/email-view.tsx`) - `meeting_summarize_failed` — post-recording notes generation threw (`App.tsx`) -- `bg_agent_run_failed` / `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — every background-agent run settles as exactly one of these (`packages/core/src/background-tasks/runner.ts`), giving a failure *rate* across all trigger sources, not just manual clicks +- `bg_agent_run_failed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event', error: string }` **(core)** — emitted when a background-agent run fails; `error` contains the normalized failure message +- `bg_agent_run_completed` — `{ trigger: 'manual' | 'cron' | 'window' | 'event' }` **(core)** — emitted when a background-agent run succeeds; together these events give a failure *rate* across all trigger sources, not just manual clicks **Misc**: diff --git a/apps/x/packages/core/src/background-tasks/runner.ts b/apps/x/packages/core/src/background-tasks/runner.ts index 73a94bc7..49fda498 100644 --- a/apps/x/packages/core/src/background-tasks/runner.ts +++ b/apps/x/packages/core/src/background-tasks/runner.ts @@ -93,6 +93,10 @@ Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}i const runningTasks = new Set(); +type RunAnalyticsOutcome = + | { event: 'bg_agent_run_completed'; properties: { trigger: BackgroundTaskTriggerType } } + | { event: 'bg_agent_run_failed'; properties: { trigger: BackgroundTaskTriggerType; error: string } }; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -114,6 +118,8 @@ export async function runBackgroundTask( } runningTasks.add(slug); + let analyticsOutcome: RunAnalyticsOutcome | undefined; + try { const task = await fetchTask(slug); if (!task) { @@ -211,7 +217,6 @@ export async function runBackgroundTask( }); log.log(`${slug} — done summary="${truncate(summary)}"`); - capture('bg_agent_run_completed', { trigger }); backgroundTaskBus.publish({ type: 'background_task_agent_complete', @@ -220,10 +225,16 @@ export async function runBackgroundTask( ...(summary ? { summary } : {}), }); + analyticsOutcome = { event: 'bg_agent_run_completed', properties: { trigger } }; return { slug, runId, summary }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); + analyticsOutcome = { + event: 'bg_agent_run_failed', + properties: { trigger, error: msg }, + }; + // Failure — only record the error. `lastRunAt` and `lastRunSummary` // are deliberately untouched so the user keeps seeing the last good // state; the scheduler's backoff (lastAttemptAt + 5min) prevents @@ -235,7 +246,6 @@ export async function runBackgroundTask( } log.log(`${slug} — failed: ${truncate(msg)}`); - capture('bg_agent_run_failed', { trigger }); backgroundTaskBus.publish({ type: 'background_task_agent_complete', @@ -246,7 +256,22 @@ export async function runBackgroundTask( return { slug, runId, summary: null, error: msg }; } + } catch (err) { + // Preserve the original throw behavior for setup/infrastructure errors, + // but still settle analytics for the attempted run. If the agent had + // already failed, keep that original failure reason. + analyticsOutcome ??= { + event: 'bg_agent_run_failed', + properties: { + trigger, + error: err instanceof Error ? err.message : String(err), + }, + }; + throw err; } finally { + if (analyticsOutcome) { + capture(analyticsOutcome.event, analyticsOutcome.properties); + } runningTasks.delete(slug); } }