From e9a7917d5e130ce8c13ff4726df884096fbf7e8a Mon Sep 17 00:00:00 2001 From: zhangxy-zju <40627701+zhangxy-zju@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:36:54 +0800 Subject: [PATCH] feat(web-shell): support compact echarts full data blocks (#6232) * feat(web-shell): support custom code block rendering * fix(web-shell): harden custom code block rendering * docs: add skill capability gating design * fix(web-shell): make chart skill host supplied * docs(web-shell): write chart skill in English * docs(web-shell): document full-data chart payload * docs(web-shell): use dataset-backed chart payload * feat(web-shell): add echarts full-data renderer * chore(web-shell): keep chart skill host supplied * fix(web-shell): show loading for streaming chart blocks * style(web-shell): polish echarts full-data renderer * fix(web-shell): harden custom code block language parsing * fix(web-shell): harden echarts full-data renderer * fix(web-shell): polish echarts renderer followups * fix(web-shell): reuse enhanced table for chart data * fix(web-shell): recover chart renderer after errors * fix(web-shell): harden chart option handling * fix(web-shell): harden chart data rendering * fix(web-shell): tighten chart renderer guardrails * fix(web-shell): update chart fallback title * fix(web-shell): polish chart renderer review fixes * feat(web-shell): support compact echarts full data blocks * docs(web-shell): add chart skill template * fix(web-shell): address chart review suggestions * fix(web-shell): preserve punctuation language aliases * fix(web-shell): address chart follow-up review * fix(web-shell): harden chart ref resolution * fix(web-shell): cover chart sanitizer follow-ups * fix(web-shell): address chart review follow-ups * fix(web-shell): address chart review leftovers * fix(web-shell): close chart review gaps * fix(web-shell): handle latest chart review --- docs/design/skill-required-capabilities.md | 342 ++ packages/web-shell/README.md | 42 + .../messages/EchartsFullDataBlock.module.css | 189 ++ .../messages/EchartsFullDataBlock.test.tsx | 2987 +++++++++++++++++ .../messages/EchartsFullDataBlock.tsx | 1888 +++++++++++ .../messages/EnhancedMarkdownTable.tsx | 66 +- .../components/messages/Markdown.test.ts | 841 ++++- .../client/components/messages/Markdown.tsx | 155 +- packages/web-shell/client/customization.tsx | 49 +- packages/web-shell/client/i18n.tsx | 45 +- packages/web-shell/client/index.ts | 20 + packages/web-shell/client/index.tsx | 19 + .../docs/examples/qwencode-viz/SKILL.md | 122 + packages/web-shell/package.json | 3 +- 14 files changed, 6673 insertions(+), 95 deletions(-) create mode 100644 docs/design/skill-required-capabilities.md create mode 100644 packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css create mode 100644 packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx create mode 100644 packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx create mode 100644 packages/web-shell/docs/examples/qwencode-viz/SKILL.md diff --git a/docs/design/skill-required-capabilities.md b/docs/design/skill-required-capabilities.md new file mode 100644 index 0000000000..38e969530b --- /dev/null +++ b/docs/design/skill-required-capabilities.md @@ -0,0 +1,342 @@ +# Skill Required Capabilities Design + +Status: design note; this PR proceeds with Option B and leaves +`required-capabilities` as a future proposal. + +## Context + +Web Shell can render custom fenced code blocks through its markdown renderer. The +chart renderer proposal uses an `echarts-fulldata` fenced code block so the model +can return a complete ECharts option and dataset payload that Web Shell renders +as an interactive chart. + +That output contract is only useful in clients that can render it. In the CLI, +ACP clients, or any other surface without a matching renderer, the same response +would appear as a large code block instead of a chart. + +The initial bundled chart skill proposal relied on wording to tell the model +that the format is for Web Shell. This is a soft guard. If the skill is exposed +in a non-Web-Shell session, the model can still choose an output format that the +client cannot render. + +For the current PR, Qwen Code keeps the renderer extension point in Web Shell +but does not bundle `qwencode-viz` in core. The Web Shell package includes a +copyable, non-auto-loaded skill template, and hosts should install or inject +that skill only when they also register an `echarts-fulldata` renderer. + +## Problem + +Qwen Code needs a clear way to decide whether a host-specific skill should be +shown to the model and to users. + +For `qwencode-viz`, the concrete question is: + +- Should core support a generic `required-capabilities` skill metadata field? +- Or should `qwencode-viz` not be a core bundled skill at all, and instead be + supplied only by Web Shell clients that install or inject it? + +## Goals + +- Prevent renderer-specific skills from being exposed when the current client + cannot satisfy their output contract. +- Keep startup skill reminders, explicit skill activation, slash-command + discovery, and skill validation consistent. +- Avoid hardcoding `qwencode-viz` as a special case. +- Preserve existing skill behavior when no capability requirement is declared. +- Keep the design extensible for future host capabilities, not only ECharts. + +## Non-goals + +- Implementing the ECharts renderer itself. +- Redesigning all client/server capability negotiation. +- Changing the semantics of existing skill frontmatter. +- Solving multi-client shared-session capability changes in the first version. + +## Current Related Mechanisms + +The codebase already has several visibility controls, but none represent client +rendering capabilities: + +- `disable-model-invocation`: prevents a skill from being auto-invoked by the + model. +- `user-invocable`: controls whether a bundled skill is available as a command. +- `paths`: scopes skill availability to matching workspace paths. +- `skills.disabled`: disables configured skills. +- `allowedTools`: currently used by bundled skill loading to hide cron-oriented + skills when cron tools are unavailable. +- Slash command `supportedModes`: filters commands by execution mode. +- Daemon and ACP capability objects: describe protocol or client support, but + are not currently connected to skill exposure. + +There is no existing `required-capabilities` or equivalent skill frontmatter. +Adding it would be a new skill contract. + +## Option A: Add `required-capabilities` + +Add a generic skill frontmatter field: + +```yaml +--- +name: qwencode-viz +description: Render analytical charts in Web Shell using echarts-fulldata fenced code blocks. +required-capabilities: + - markdown.codeBlock.echarts-fulldata +--- +``` + +When the current client/session does not advertise all listed capabilities, the +skill is treated as unavailable. + +### Capability Naming + +Use namespaced string capabilities: + +```text +markdown.codeBlock.echarts-fulldata +``` + +This keeps the field generic while making the contract precise: + +- `markdown`: the capability belongs to rendered markdown. +- `codeBlock`: the capability applies to fenced code block rendering. +- `echarts-fulldata`: the specific language/info string supported by the + renderer. + +Future examples could be: + +- `markdown.codeBlock.vega-lite` +- `markdown.codeBlock.mermaid-interactive` +- `artifact.openUrl` + +### Skill Metadata + +Add `requiredCapabilities?: string[]` to skill configuration after parsing the +frontmatter key `required-capabilities`. + +Both skill parsing paths should understand the field: + +- `packages/core/src/skills/skill-load.ts` +- `packages/core/src/skills/skill-manager.ts` + +The field should be optional. Missing or empty means the skill has no client +capability requirement. + +### Runtime Capability Source + +Add client/session capabilities to the runtime config: + +```ts +interface ConfigParameters { + clientCapabilitiesProvider?: () => ReadonlySet; +} +``` + +Expose a helper on `Config`, for example: + +```ts +config.getClientCapabilities(): ReadonlySet +``` + +Then centralize the check: + +```ts +function skillMeetsRequiredCapabilities(skill: Skill, config: Config): boolean { + return skill.config.requiredCapabilities.every((capability) => + config.getClientCapabilities().has(capability), + ); +} +``` + +### Filtering Points + +The capability filter should be applied before skills are exposed to either the +model or the user: + +- `collectAvailableSkillEntries` in `packages/core/src/tools/skill-utils.ts` + should skip skills whose required capabilities are missing. This keeps startup + skill reminders, delta reminders, `SkillTool` validation, and model-invocable + activation aligned. +- `BundledSkillLoader` should skip unavailable bundled skills when creating + user-facing commands. +- `SkillCommandLoader` should skip unavailable file-system skills when creating + user-facing commands. + +The important invariant is that a skill hidden from the model should not still +appear as an invocable command unless the project intentionally supports a +manual override. + +### Web Shell Registration + +Web Shell should advertise renderer support explicitly rather than relying on +the presence of an opaque `renderCodeBlock` callback. + +For example: + +```tsx + +``` + +The Web Shell client can map that to: + +```text +markdown.codeBlock.echarts-fulldata +``` + +This makes the capability declaration stable even if the renderer callback +contains custom logic, fallbacks, or multiple supported languages. + +### Daemon and ACP Propagation + +For hosted or daemon-based sessions, the client capability set needs to reach +core before skills are loaded or listed. A minimal version can pass capabilities +when creating a session: + +```ts +interface CreateSessionRequest { + clientCapabilities?: string[]; +} +``` + +The daemon bridge, SDK, and ACP session creation flow can store this as +session-scoped config. + +For the first version, capabilities can be session-scoped. If multiple clients +attach to the same session, the behavior should be documented as using the +capabilities from session creation time. + +### Pros + +- Keeps `qwencode-viz` as one canonical bundled skill. +- Prevents host-specific output contracts from leaking into unsupported + clients. +- Creates a reusable mechanism for future renderer-specific or host-specific + skills. +- Makes the dependency explicit and testable. + +### Cons + +- Adds a new cross-cutting skill metadata field. +- Requires client/session capability plumbing across Web Shell, daemon, SDK, and + ACP surfaces. +- Needs careful documentation for shared-session behavior. +- May be more machinery than needed if `qwencode-viz` is the only expected + capability-gated skill. + +## Option B: Client-Supplied Skill + +Do not add a generic `required-capabilities` field. Instead, avoid bundling +`qwencode-viz` in core. The Web Shell client, or any client that supports the +renderer, supplies the skill itself. + +Possible distribution models: + +- The Web Shell host installs `.qwen/skills/qwencode-viz/SKILL.md`. +- The Web Shell package ships an optional non-auto-loaded skill template that a + host can copy or install when chart rendering is enabled. +- The Web Shell integration ships an extension skill package. +- The Web Shell integration injects equivalent model instructions only when its + chart renderer is enabled. + +In this model, the skill is available only because the rendering client chose to +provide it. + +### Pros + +- Minimal core change. +- No new global skill metadata contract. +- Capability availability is naturally owned by the client that implements the + renderer. +- Avoids daemon or ACP plumbing unless the client already has a skill injection + mechanism. + +### Cons + +- No canonical bundled skill unless all clients copy the same content. +- More burden on each Web Shell integrator. +- Users moving between clients may see inconsistent skill availability. +- Does not create a general safeguard for future host-specific skills. +- Harder to test in core because availability depends on external installation + or injection. + +## Recommendation + +For this PR, use Option B. + +That keeps the core skill system unchanged and avoids exposing +`echarts-fulldata` instructions in unsupported clients. The Web Shell renderer +hook remains useful for any host-owned block renderer, while chart-specific +model instructions become an explicit host opt-in. + +Longer term, discuss this as a product/API boundary decision. + +Choose Option A if maintainers expect Qwen Code to support more client-rendered +output contracts over time. In that case, `required-capabilities` is a small +general contract that keeps skill exposure honest across CLI, Web Shell, ACP, +and future clients. + +Choose Option B if `qwencode-viz` is expected to remain a Web-Shell-only +extension and maintainers do not want core skills to depend on client rendering +features. In that case, the current bundled skill should be removed from core +and supplied by Web Shell clients that support `echarts-fulldata`. + +The recommended future default is Option A only if maintainers are comfortable +making client/session capabilities part of the skill system. Otherwise, keep +host-renderer skills client-owned. + +## Open Questions + +- Should capabilities be session-scoped, request-scoped, or client-scoped? +- Should missing capabilities hide user-invocable commands, or only hide + model-invocable skill activation? +- Should capability names be free-form strings or validated against a known + registry? +- Should unavailable skills be hidden entirely from `/skills`, or shown as + disabled with a reason? +- Should there be a manual override for users who intentionally want to emit raw + `echarts-fulldata` blocks in unsupported clients? +- Should the field name be `required-capabilities`, `requires-capabilities`, or + `client-capabilities`? + +## Validation Plan + +If Option A is implemented, add tests for: + +- Frontmatter parsing in both skill parsing paths. +- `collectAvailableSkillEntries` hiding a skill when capabilities are missing. +- The same skill appearing when capabilities are present. +- Interaction with `paths`, `skills.disabled`, and `disable-model-invocation`. +- `BundledSkillLoader` and `SkillCommandLoader` command visibility. +- Web Shell mapping from supported code block languages to client capabilities. +- Daemon or ACP session creation preserving the capability set. +- Existing bundled skill integration tests, to ensure skills without + `required-capabilities` are unchanged. + +## Migration + +Existing skills require no migration because the new field is optional. + +For the current Option B path, remove the chart skill from core bundled skills. +The Web Shell package template must not be loaded by core automatically; hosts +opt in by installing or injecting it. + +If Option A is accepted, add: + +```yaml +required-capabilities: + - markdown.codeBlock.echarts-fulldata +``` + +to a future bundled `qwencode-viz`. + +If Option B is accepted, remove the chart skill from core bundled skills and +document how Web Shell clients can install or inject it when they register an +`echarts-fulldata` renderer. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 310696c0b5..c9b8d225f3 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -103,6 +103,48 @@ export function App() { | `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 | | `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 | +## 可选图表 Renderer + +`WebShell` 支持宿主通过 `customization.markdown.renderCodeBlock` 接管特定 +fenced code block 的渲染。图表类场景可以注册内置的 +`echarts-fulldata` renderer: + +```tsx +import { createEchartsFullDataRenderer } from '@qwen-code/web-shell'; + + window.echarts, + resolveDataRef: async (ref, meta) => + loadControlledChartDataset(ref, meta), + }), + }} +/>; +``` + +renderer 会把 `echarts-fulldata` code block 替换为图表卡片,并内置图表/数据 +icon 切换;ECharts runtime 由宿主通过 `loadEcharts` 提供。若启用 +`data.kind="ref"` envelope,数据只能通过宿主提供的 `resolveDataRef` 解析, +renderer 不会自己读取 URL 或本地路径。 + +如果需要让模型主动输出 `echarts-fulldata` block,宿主应在自己的 skills 来源中 +提供对应 skill,并且只在确认当前 Web Shell 宿主已经注册 renderer 时启用。 +`@qwen-code/web-shell` 不内置或自动加载这个 skill;可从 +`packages/web-shell/docs/examples/qwencode-viz/SKILL.md` 复制模板到宿主的 +`.qwen/skills/qwencode-viz/SKILL.md`,或通过宿主自己的 skill 注入机制提供等价 +说明。 + +`echarts-fulldata` 的 block body 可以是旧版纯 JSON ECharts option,也可以是 +`{ "version": 1, "data": ..., "option": ... }` envelope。新版 inline envelope +使用 `data.dimensions: string[]` 和 `data.source` array-of-arrays;renderer 会先 +normalize 成原生 ECharts option,并注入 `option.dataset`,再渲染图表和数据视图。 +新版 ref envelope 必须使用受控 `artifact://` 或 `session-file://` ref,并提供 +`data.format`(`csv` 或 `json`)和 `data.dimensions`,这些元信息会传给宿主的 +`resolveDataRef(ref, meta)`。 +宿主应使用 `JSON.parse` 解析,不能用 `eval`、`new Function` 或 script injection +执行模型生成内容。 + ## 架构说明 ```text diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css b/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css new file mode 100644 index 0000000000..9477118542 --- /dev/null +++ b/packages/web-shell/client/components/messages/EchartsFullDataBlock.module.css @@ -0,0 +1,189 @@ +.card { + margin: 10px 0; + min-width: 0; + max-width: 100%; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: 8px; + background: var(--card, var(--background)); + box-shadow: 0 8px 22px rgb(15 23 42 / 5%); +} + +.toolbar { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 10px 0 14px; + background: color-mix(in srgb, var(--subtle-bg) 68%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); +} + +.title { + min-width: 0; + margin: 0; + color: var(--foreground); + font-size: 13px; + font-weight: 600; + line-height: 1.3; + letter-spacing: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toggle { + display: inline-grid; + flex: 0 0 auto; + grid-template-columns: repeat(2, 30px); + gap: 2px; + padding: 2px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 6px; + background: var(--background); +} + +.toggleButton { + display: grid; + width: 30px; + height: 26px; + place-items: center; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + transition: + background-color 0.16s ease, + color 0.16s ease; +} + +.toggleButton:hover { + background: var(--subtle-bg-strong); + color: var(--foreground); +} + +.toggleButton[aria-pressed='true'] { + background: var(--agent-blue-500, #0033ff); + color: #ffffff; +} + +.toggleButton svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.toggleButton path { + fill: none; + stroke: currentColor; + stroke-width: 1.4; +} + +.chartFrame { + position: relative; + min-height: 370px; +} + +.chartSurface { + height: 370px; + min-height: 260px; + width: calc(100% - 20px); + margin: 0 10px 8px; + background: var(--background); +} + +.chartOverlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--background) 88%, transparent); +} + +.state { + display: grid; + min-height: 160px; + place-items: center; + padding: 16px; + color: var(--muted-foreground); + font-size: 13px; + text-align: center; +} + +.loading { + min-height: 220px; +} + +.spinner { + width: 22px; + height: 22px; + border: 2px solid var(--border); + border-top-color: var(--agent-blue-500, #0033ff); + border-radius: 999px; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.tableWrap { + min-height: 240px; + max-height: min(60vh, 520px); + overflow: auto; + background: var(--card, var(--background)); + scrollbar-color: var(--border) transparent; +} + +.tableNotice { + position: sticky; + top: 0; + z-index: 2; + padding: 8px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--subtle-bg) 72%, var(--background)); + color: var(--muted-foreground); + font-size: 12px; +} + +.table { + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.table th, +.table td { + padding: 9px 12px; + border-right: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + text-align: left; + vertical-align: top; +} + +.table th:last-child, +.table td:last-child { + border-right: 0; +} + +.table th { + position: sticky; + top: 0; + z-index: 1; + background: color-mix(in srgb, var(--subtle-bg) 68%, var(--background)); + color: var(--foreground); + font-size: 12px; + font-weight: 600; +} + +.table td { + color: color-mix(in srgb, var(--foreground) 90%, var(--muted-foreground)); +} diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx new file mode 100644 index 0000000000..12445fb593 --- /dev/null +++ b/packages/web-shell/client/components/messages/EchartsFullDataBlock.test.tsx @@ -0,0 +1,2987 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { WebShellCustomizationProvider } from '../../customization'; +import { I18nProvider } from '../../i18n'; +import { ThemeProvider } from '../../themeContext'; +import { Markdown } from './Markdown'; +import { + ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP, + EchartsFullDataBlock, + createEchartsFullDataRenderer, + type EchartsFullDataOption, + type EchartsFullDataRefResolver, + type EchartsRuntime, + type EchartsRuntimeLoader, +} from './EchartsFullDataBlock'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +async function mount(node: ReactNode): Promise<{ + container: HTMLElement; + root: Root; + rerender(next: ReactNode): Promise; +}> { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(node); + }); + mounted.push({ root, container }); + return { + container, + root, + rerender: async (next: ReactNode) => { + await act(async () => { + root.render(next); + }); + }, + }; +} + +async function render(node: ReactNode): Promise { + return (await mount({node})) + .container; +} + +async function flushChart(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function renderEchartsMarkdown({ + code, + fenceLanguage = 'echarts-fulldata', + loadEcharts, + resolveDataRef, + language = 'en', + source = 'assistant', +}: { + code: string; + fenceLanguage?: string; + loadEcharts?: EchartsRuntimeLoader; + resolveDataRef?: EchartsFullDataRefResolver; + language?: 'en' | 'zh-CN'; + source?: 'assistant' | 'thinking'; +}): Promise { + return render( + + + + + + + , + ); +} + +function getDataRows(container: HTMLElement): string[][] { + return Array.from(container.querySelectorAll('tbody tr')).map((row) => + Array.from(row.querySelectorAll('td')) + .slice(1) + .map((cell) => cell.textContent?.trim() ?? ''), + ); +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(async () => { + for (const { root, container } of mounted.splice(0)) { + await act(async () => { + root.unmount(); + }); + container.remove(); + } + vi.restoreAllMocks(); +}); + +describe('EchartsFullDataBlock', () => { + it('keeps top-level allowlist keys disjoint from nested denylist keys', () => { + expect(ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP).toEqual([]); + }); + + it('does not render echarts blocks from thinking markdown', async () => { + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { source: [[1]] }, + series: [{ type: 'bar' }], + }), + loadEcharts: () => runtime, + source: 'thinking', + }); + + expect(runtime.init).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-testid="echarts-fulldata-rendered"]'), + ).toBeNull(); + expect(container.textContent).toContain('echarts-fulldata'); + }); + + it('renders echarts blocks with case-insensitive fence language matching', async () => { + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { source: [[1]] }, + series: [{ type: 'bar' }], + }), + fenceLanguage: 'ECharts-FullData', + loadEcharts: () => runtime, + }); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect( + container.querySelector('[data-testid="echarts-fulldata-rendered"]'), + ).not.toBeNull(); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('renders chart/data icon switching from dataset-backed options', async () => { + const option: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [ + { day: 'Mon', orders: 120 }, + { day: 'Tue', orders: 200 }, + ], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(runtime.init).toHaveBeenCalledWith(expect.any(HTMLElement), 'dark'); + expect(setOption).toHaveBeenCalledWith(expect.any(Object), { + notMerge: true, + }); + const renderedOption = setOption.mock.calls[0]?.[0] as + EchartsFullDataOption | undefined; + expect(renderedOption).toEqual( + expect.not.objectContaining({ title: expect.anything() }), + ); + expect(renderedOption).toEqual( + expect.objectContaining({ + backgroundColor: '#0d0d0d', + color: expect.arrayContaining(['#8AA0FF', '#60CCC5']), + grid: expect.objectContaining({ containLabel: true }), + }), + ); + expect(renderedOption?.xAxis).toEqual( + expect.objectContaining({ + axisLabel: expect.objectContaining({ color: '#9aa3b7' }), + }), + ); + expect(renderedOption?.series).toEqual([ + expect.objectContaining({ + barMaxWidth: 42, + itemStyle: expect.objectContaining({ borderRadius: [3, 3, 0, 0] }), + }), + ]); + expect(container.textContent).toContain('Weekly orders'); + expect(container.textContent).not.toContain('echarts-fulldata'); + expect( + container.querySelector('section[aria-label="Weekly orders"]'), + ).not.toBeNull(); + + const buttons = Array.from(container.querySelectorAll('button')); + expect(buttons.map((button) => button.textContent)).toEqual(['', '']); + expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Show chart', + 'Show data', + ]); + + await act(async () => { + buttons[1]?.click(); + }); + + const headerTexts = Array.from(container.querySelectorAll('th')).map( + (cell) => cell.textContent?.trim() ?? '', + ); + expect(headerTexts[1]).toContain('day'); + expect(headerTexts[2]).toContain('orders'); + expect(container.textContent).toContain('Quick copy'); + expect( + container.querySelector('button[aria-label="Sort by orders"]'), + ).not.toBeNull(); + expect(getDataRows(container)).toEqual([ + ['Mon', '120'], + ['Tue', '200'], + ]); + + await act(async () => { + container + .querySelector('button[aria-label="Sort by orders"]') + ?.click(); + }); + await act(async () => { + container + .querySelector( + 'button[aria-label="Sort by orders, ascending"]', + ) + ?.click(); + }); + + expect(getDataRows(container)).toEqual([ + ['Tue', '200'], + ['Mon', '120'], + ]); + }); + + it('uses item tooltip trigger for item-only series', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + await render( + runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + tooltip?: { trigger?: string }; + }; + expect(renderedOption.tooltip?.trigger).toBe('item'); + }); + + it('styles multi-axis chart options with index-based defaults', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + await render( + runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + xAxis?: Array<{ axisTick?: { show?: boolean } }>; + yAxis?: Array<{ + nameTextStyle?: { align?: string }; + splitLine?: { show?: boolean }; + }>; + }; + expect(renderedOption.xAxis?.[0]?.axisTick?.show).toBe(true); + expect(renderedOption.xAxis?.[1]?.axisTick?.show).toBe(true); + expect(renderedOption.yAxis?.[0]?.splitLine?.show).toBe(true); + expect(renderedOption.yAxis?.[0]?.nameTextStyle?.align).toBe('left'); + expect(renderedOption.yAxis?.[1]?.splitLine?.show).toBe(false); + expect(renderedOption.yAxis?.[1]?.nameTextStyle?.align).toBe('right'); + }); + + it('keeps legacy ECharts option block bodies compatible', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + title: { text: 'Legacy option' }, + version: 'legacy-custom-metadata', + dataset: { + dimensions: ['day', 'orders'], + source: [ + { day: 'Mon', orders: 120 }, + { day: 'Tue', orders: 200 }, + ], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }), + loadEcharts: () => runtime, + }); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(setOption.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dataset: expect.objectContaining({ + dimensions: ['day', 'orders'], + source: [ + { day: 'Mon', orders: 120 }, + { day: 'Tue', orders: 200 }, + ], + }), + }), + ); + expect(container.textContent).toContain('Legacy option'); + expect(container.textContent).not.toContain('echarts-fulldata'); + }); + + it('renders inline envelope array rows in chart and data views', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + }, + option: { + title: { text: 'Inline envelope' }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + }); + await flushChart(); + + expect(setOption.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dataset: { + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + }, + }), + ); + + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + expect(getDataRows(container)).toEqual([ + ['Mon', '120'], + ['Tue', '200'], + ]); + }); + + it('rejects inline envelope row length mismatches', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [['Mon', 120], ['Tue']], + }, + option: { + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + }), + }); + + expect(container.textContent).toContain( + 'Chart envelope data.source row 2 has 1 cells; expected 2.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('rejects inline envelope cells that cannot be rendered safely', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [['Mon', { nested: 120 }]], + }, + option: { + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + }), + }); + + expect(container.textContent).toContain( + 'Chart envelope data.source row 1 cell 2 must be a string, number, boolean, or null.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it.each([ + { + name: 'unsupported envelope version', + envelope: { + version: 2, + data: { kind: 'inline', dimensions: ['day'], source: [['Mon']] }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope version must be 1.', + }, + { + name: 'non-object envelope option', + envelope: { + version: 1, + data: { kind: 'inline', dimensions: ['day'], source: [['Mon']] }, + option: 'bar chart', + }, + error: 'Chart envelope option must be an object.', + }, + { + name: 'non-object envelope data', + envelope: { + version: 1, + data: 42, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data must be an object.', + }, + { + name: 'unknown data kind', + envelope: { + version: 1, + data: { kind: 'remote' }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.kind must be "inline" or "ref".', + }, + { + name: 'invalid ref dimensions', + envelope: { + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'json', + dimensions: [123], + }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.dimensions must be a string array.', + }, + { + name: 'unsafe ref dimensions', + envelope: { + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'json', + dimensions: ['day', 'javascript:alert(1)'], + }, + option: { series: [{ type: 'bar' }] }, + }, + error: + 'Chart envelope data.dimensions contains an unsafe dimension name.', + }, + { + name: 'invalid ref format', + envelope: { + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'xml', + dimensions: ['day'], + }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.format must be "csv" or "json".', + }, + { + name: 'malformed percent encoding in ref', + envelope: { + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/%ZZorders', + format: 'json', + dimensions: ['day'], + }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.ref path is malformed.', + }, + { + name: 'invalid inline dimensions', + envelope: { + version: 1, + data: { kind: 'inline', dimensions: 'day', source: [['Mon']] }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.dimensions must be a string array.', + }, + { + name: 'invalid inline source', + envelope: { + version: 1, + data: { kind: 'inline', dimensions: ['day'], source: 'Mon' }, + option: { series: [{ type: 'bar' }] }, + }, + error: 'Chart envelope data.source must be an array of rows.', + }, + ])( + 'rejects invalid full-data envelopes: $name', + async ({ envelope, error }) => { + const code = JSON.stringify(envelope); + const container = await renderEchartsMarkdown({ + code, + }); + + expect(container.textContent).toContain(error); + expect(container.querySelector('pre code')).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata parse failed: %s (code length=%d)', + error, + code.length, + ); + }, + ); + + it('rejects legacy array-row dimension mismatches', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + dimensions: ['day', 'orders'], + source: [['Mon', 120], ['Tue']], + }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }), + }); + + expect(container.textContent).toContain( + 'Chart data row 2 has 1 cells; expected 2.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('rejects legacy dataset cells that cannot be rendered safely', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + dimensions: ['day', 'orders'], + source: [['Mon', { nested: 120 }]], + }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }), + }); + + expect(container.textContent).toContain( + 'Chart data row 1 cell 2 must be a string, number, boolean, or null.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('rejects unsafe legacy dataset dimensions', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + dimensions: ['day', 'constructor'], + source: [{ day: 'Mon', orders: 120 }], + }, + series: [{ type: 'bar', encode: { x: 'day', y: 'constructor' } }], + }), + }); + + expect(container.textContent).toContain( + 'Chart envelope data.dimensions contains an unsafe dimension name.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('resolves ref envelope data through the host resolver', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + })); + + await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'ARTIFACT://chart-data/%6Frders', + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { + title: { text: 'Ref envelope' }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef, + }); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledWith( + 'artifact://chart-data/orders', + { + format: 'csv', + dimensions: ['day', 'orders'], + }, + ); + expect(setOption.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dataset: { + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + }, + }), + ); + }); + + it('does not refetch resolved ref data when streaming toggles with unchanged code', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + })); + const renderer = createEchartsFullDataRenderer({ + loadEcharts: () => runtime, + resolveDataRef, + }); + const content = `\`\`\`echarts-fulldata\n${JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + })}\n\`\`\``; + const tree = (isStreaming: boolean) => ( + + + + + + + + ); + + const { rerender } = await mount(tree(false)); + await flushChart(); + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(setOption).toHaveBeenCalledOnce(); + + await rerender(tree(true)); + await flushChart(); + expect(resolveDataRef).toHaveBeenCalledOnce(); + + await rerender(tree(false)); + await flushChart(); + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(setOption).toHaveBeenCalledTimes(2); + }); + + it('reports ref resolver rejections inside the chart card', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/missing', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef: () => Promise.reject(new Error('missing artifact')), + }); + await flushChart(); + + expect(container.textContent).toContain( + 'Chart data reference could not be resolved.', + ); + expect(container.textContent).not.toContain('missing artifact'); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata data-ref resolution failed:', + 'artifact://chart-data/missing', + expect.any(Error), + ); + expect(container.querySelector('pre code')).toBeNull(); + expect(setOption).not.toHaveBeenCalled(); + }); + + it('reports non-error ref resolver rejections with the generic message', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/missing', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef: () => Promise.reject('missing artifact'), + }); + await flushChart(); + + expect(container.textContent).toContain( + 'Chart data reference could not be resolved.', + ); + expect(container.textContent).not.toContain('missing artifact'); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata data-ref resolution failed:', + 'artifact://chart-data/missing', + 'missing artifact', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('reports invalid ref resolver data with a resolver-specific diagnostic', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/invalid', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef: () => + null as unknown as ReturnType, + }); + await flushChart(); + + expect(container.textContent).toContain( + 'Chart data resolver returned an invalid dataset.', + ); + expect(container.textContent).not.toContain( + 'Chart envelope data.dimensions must be a string array.', + ); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata resolver returned invalid dataset:', + 'artifact://chart-data/invalid', + null, + ); + expect(setOption).not.toHaveBeenCalled(); + }); + + it('reports ref resolver timeouts inside the chart card', async () => { + vi.useFakeTimers(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + try { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/slow', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef: () => new Promise(() => {}), + }); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain( + 'Chart data reference could not be resolved.', + ); + expect(container.textContent).not.toContain( + 'Data reference resolution timed out.', + ); + expect(console.warn).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata data-ref resolution timed out after %dms (ref=%s, format=%s)', + 30_000, + 'artifact://chart-data/slow', + 'json', + ); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata data-ref resolution failed:', + 'artifact://chart-data/slow', + expect.any(Error), + ); + expect(container.querySelector('pre code')).toBeNull(); + expect(setOption).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores stale ref resolutions after the chart code changes', async () => { + let resolveFirst: (value: { + dimensions: string[]; + source: Array>; + }) => void = () => {}; + let resolveSecond: (value: { + dimensions: string[]; + source: Array>; + }) => void = () => {}; + const resolveDataRef = vi.fn( + (ref: string) => + new Promise<{ + dimensions: string[]; + source: Array>; + }>((resolve) => { + if (ref.endsWith('/first')) { + resolveFirst = resolve; + } else { + resolveSecond = resolve; + } + }), + ); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const renderer = createEchartsFullDataRenderer({ + loadEcharts: () => runtime, + resolveDataRef, + }); + const contentFor = (ref: string) => + `\`\`\`echarts-fulldata\n${JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref, + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { + title: { text: 'Ref race' }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + })}\n\`\`\``; + const tree = (ref: string) => ( + + + + + + + + ); + + const { rerender } = await mount(tree('artifact://chart-data/first')); + await flushChart(); + await rerender(tree('artifact://chart-data/second')); + await flushChart(); + + await act(async () => { + resolveSecond({ + dimensions: ['day', 'orders'], + source: [['Tue', 200]], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await flushChart(); + + expect(setOption).toHaveBeenCalledTimes(1); + expect(setOption.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dataset: { + dimensions: ['day', 'orders'], + source: [['Tue', 200]], + }, + }), + ); + + await act(async () => { + resolveFirst({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await flushChart(); + + expect(setOption).toHaveBeenCalledTimes(1); + }); + + it('does not re-resolve ref data when the resolver prop identity changes', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const resolveDataRef = vi.fn(async () => ({ + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + })); + const content = `\`\`\`echarts-fulldata\n${JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + title: { text: 'Stable resolver' }, + series: [{ type: 'line', encode: { x: 'day', y: 'orders' } }], + }, + })}\n\`\`\``; + const tree = (nonce: number) => ( + + + runtime, + resolveDataRef: (ref, meta) => { + void nonce; + return resolveDataRef(ref, meta); + }, + }), + }, + }} + > + + + + + ); + + const { rerender } = await mount(tree(1)); + await flushChart(); + await rerender(tree(2)); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(setOption).toHaveBeenCalledOnce(); + }); + + it('reports missing and unsupported ref resolver states without showing raw code', async () => { + const missingResolver = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'csv', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'bar' }], + }, + }), + }); + expect(missingResolver.textContent).toContain( + 'Chart data reference resolver is unavailable.', + ); + expect(missingResolver.querySelector('pre code')).toBeNull(); + + const resolveDataRef = vi.fn(); + const unsupportedScheme = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'https://example.test/data.json', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'bar' }], + }, + }), + resolveDataRef, + }); + expect(resolveDataRef).not.toHaveBeenCalled(); + expect(unsupportedScheme.textContent).toContain( + 'Chart envelope data.ref must use artifact:// or session-file://.', + ); + expect(unsupportedScheme.querySelector('pre code')).toBeNull(); + }); + + it('validates ref envelopes before calling the host resolver', async () => { + const resolveDataRef = vi.fn(); + const invalidRefs = [ + { + ref: 'artifact://', + message: 'Chart envelope data.ref path must be non-empty.', + }, + { + ref: 'session-file://../../secret', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'session-file://%2e%2e/secret', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'session-file://.', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'artifact://chart-data/./orders', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'session-file://C:/Users/alice/secret.csv', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'session-file://C:../secret.csv', + message: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }, + { + ref: 'artifact://chart-data/orders?token=1', + message: + 'Chart envelope data.ref path must not include query, hash, or backslash characters.', + }, + { + ref: 'artifact://chart-data/%252e%252e/orders', + message: + 'Chart envelope data.ref path must not include query, hash, whitespace, control, backslash, or double-encoded percent characters.', + }, + { + ref: 'artifact://chart-data/\u0000orders', + message: + 'Chart envelope data.ref must not contain whitespace or control characters.', + }, + ]; + + for (const { ref, message } of invalidRefs) { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref, + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'bar' }], + }, + }), + resolveDataRef, + }); + + expect(container.textContent).toContain(message); + expect(container.querySelector('pre code')).toBeNull(); + } + expect(resolveDataRef).not.toHaveBeenCalled(); + }); + + it('rejects resolved ref data over the dataset limits', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'session-file://chart-data/orders', + format: 'csv', + dimensions: ['period'], + }, + option: { + series: [{ type: 'bar' }], + }, + }), + loadEcharts: () => runtime, + resolveDataRef: () => ({ + dimensions: ['period'], + source: Array.from({ length: 2001 }, (_, index) => [`P${index}`]), + }), + }); + await flushChart(); + + expect(container.textContent).toContain( + 'Chart data has too many rows. Maximum supported rows: 2000.', + ); + expect(setOption).not.toHaveBeenCalled(); + }); + + it('normalizes {c} label templates for object-row datasets', async () => { + const longDimension = 'x'.repeat(300); + const longFormatter = '{c}'.repeat(1_400); + const option: EchartsFullDataOption = { + title: { text: 'Monthly metrics' }, + dataset: { + dimensions: ['month', 'convRate', 'aov', 'cost$', longDimension], + source: [ + { + month: 'Jan', + convRate: 3.1, + aov: 186, + cost$: 42, + [longDimension]: 1, + }, + { + month: 'Feb', + convRate: 3.4, + aov: 192, + cost$: 43, + 'profit|margin': 0.34, + 'close}rate': 0.78, + [longDimension]: 2, + }, + ], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [ + { + type: 'line', + encode: { x: 'month', y: 'convRate' }, + label: { show: true, formatter: '{c:.2f}%' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'aov' }, + label: { show: true, formatter: '¥{c}' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'cost$' }, + label: { show: true, formatter: 'Cost {c:.1f}' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'missing' }, + label: { show: true, formatter: '{c}' }, + }, + { + type: 'line', + encode: { x: 'month', y: longDimension }, + label: { show: true, formatter: '{c}' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'profit|margin' }, + label: { show: true, formatter: '{c}%' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'close}rate' }, + label: { show: true, formatter: '{c:.1f}' }, + }, + { + type: 'line', + encode: { x: 'month', y: 'convRate' }, + label: { show: true, formatter: longFormatter }, + }, + ], + }; + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + await render( + runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + series?: Array<{ label?: { formatter?: string } }>; + }; + expect(renderedOption.series?.[0]?.label?.formatter).toBe( + '{@convRate|.2f}%', + ); + expect(renderedOption.series?.[1]?.label?.formatter).toBe('¥{@aov}'); + expect(renderedOption.series?.[2]?.label?.formatter).toBe( + 'Cost {@cost$|.1f}', + ); + expect(renderedOption.series?.[3]?.label?.formatter).toBe('{c}'); + expect(renderedOption.series?.[4]?.label?.formatter).toBe('{c}'); + expect(renderedOption.series?.[5]?.label?.formatter).toBe('{c}%'); + expect(renderedOption.series?.[6]?.label?.formatter).toBe('{c:.1f}'); + expect(renderedOption.series?.[7]?.label?.formatter).toBe(longFormatter); + }); + + it('normalizes {c} label templates for inline array-row envelopes', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + await renderEchartsMarkdown({ + code: JSON.stringify({ + version: 1, + data: { + kind: 'inline', + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }, + option: { + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [ + { + type: 'bar', + encode: { x: 'day', y: 'orders' }, + label: { show: true, formatter: '{c:.1f}' }, + }, + ], + }, + }), + loadEcharts: () => runtime, + }); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + series?: Array<{ label?: { formatter?: string } }>; + }; + expect(renderedOption.series?.[0]?.label?.formatter).toBe('{@orders|.1f}'); + }); + + it('renders array-row datasets with named dimensions in the data view', async () => { + const option: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [ + ['Mon', 120], + ['Tue', 200], + ], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + expect(getDataRows(container)).toEqual([ + ['Mon', '120'], + ['Tue', '200'], + ]); + }); + + it('coerces numeric dataset dimensions for chart and data views', async () => { + const option: EchartsFullDataOption = { + title: { text: 'Yearly orders' }, + dataset: { + dimensions: ['month', 2023, 2024] as unknown as string[], + source: [['Jan', 100, 200]], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [ + { + type: 'bar', + encode: { x: 'month', y: 2023 }, + label: { show: true, formatter: '{c}' }, + }, + ], + }; + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + dataset?: { dimensions?: string[] }; + series?: Array<{ label?: { formatter?: string } }>; + }; + expect(renderedOption.dataset?.dimensions).toEqual([ + 'month', + '2023', + '2024', + ]); + expect(renderedOption.series?.[0]?.label?.formatter).toBe('{@2023}'); + + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + const headerTexts = Array.from(container.querySelectorAll('th')).map( + (cell) => cell.textContent?.trim() ?? '', + ); + expect(headerTexts.some((text) => text.includes('month'))).toBe(true); + expect(headerTexts.some((text) => text.includes('2023'))).toBe(true); + expect(headerTexts.some((text) => text.includes('2024'))).toBe(true); + expect(getDataRows(container)).toEqual([['Jan', '100', '200']]); + }); + + it('uses the sanitized dataset consistently for chart and data views', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const option: EchartsFullDataOption = { + title: { text: 'Sanitized rows' }, + dataset: { + dimensions: ['label', 'url'], + source: [{ label: 'x runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + dataset?: { source?: Array> }; + }; + expect(renderedOption.dataset?.source?.[0]).toEqual({ + label: 'x { + container.querySelectorAll('button')[1]?.click(); + }); + + expect(getDataRows(container)).toEqual([['x { + const option: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: [null, {}, { name: 'orders' }] as unknown as string[], + source: [['Mon', 120]], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 0, y: 1 } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + const headerTexts = Array.from(container.querySelectorAll('th')).map( + (cell) => cell.textContent?.trim() ?? '', + ); + expect(headerTexts[1]).toContain('0'); + expect(headerTexts[2]).toContain('orders'); + expect(getDataRows(container)).toEqual([['Mon', '120']]); + }); + + it('keeps the parsed option stable across parent rerenders with unchanged code', async () => { + const code = JSON.stringify({ + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }); + const dispose = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose, + })), + }; + const renderer = createEchartsFullDataRenderer({ + loadEcharts: () => runtime, + }); + const content = `\`\`\`echarts-fulldata\n${code}\n\`\`\``; + const tree = (nonce: number) => ( +
+ + + + + +
+ ); + + const { rerender } = await mount(tree(1)); + await flushChart(); + await rerender(tree(2)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledTimes(1); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('does not recompute chart options when toggling data view', async () => { + const plainOption: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + let cloneCount = 0; + const option = { + ...plainOption, + toJSON: () => { + cloneCount += 1; + return plainOption; + }, + } as EchartsFullDataOption & { toJSON: () => EchartsFullDataOption }; + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + await act(async () => { + container.querySelectorAll('button')[0]?.click(); + }); + await flushChart(); + + expect(cloneCount).toBe(1); + expect(runtime.init).toHaveBeenCalledTimes(2); + expect(setOption).toHaveBeenCalledTimes(2); + }); + + it('does not recreate the chart when the loader prop identity changes', async () => { + const option: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const dispose = vi.fn(); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose, + })), + }; + const tree = (nonce: number) => ( + +
+ runtime} + /> +
+
+ ); + + const { rerender } = await mount(tree(1)); + await flushChart(); + await rerender(tree(2)); + await flushChart(); + + expect(runtime.init).toHaveBeenCalledOnce(); + expect(setOption).toHaveBeenCalledOnce(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('reinitializes the chart when the theme changes', async () => { + const plainOption: EchartsFullDataOption = { + title: { text: 'Weekly orders' }, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + let cloneCount = 0; + const option = { + ...plainOption, + toJSON: () => { + cloneCount += 1; + return plainOption; + }, + } as EchartsFullDataOption & { toJSON: () => EchartsFullDataOption }; + const dispose = vi.fn(); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose, + })), + }; + const tree = (theme: 'dark' | 'light') => ( + + runtime} + /> + + ); + + const { rerender } = await mount(tree('dark')); + await flushChart(); + await rerender(tree('light')); + await flushChart(); + + expect(cloneCount).toBe(1); + expect(runtime.init).toHaveBeenCalledTimes(2); + expect(runtime.init).toHaveBeenNthCalledWith( + 1, + expect.any(HTMLElement), + 'dark', + ); + expect(runtime.init).toHaveBeenNthCalledWith( + 2, + expect.any(HTMLElement), + 'light', + ); + expect(dispose).toHaveBeenCalledOnce(); + expect(setOption).toHaveBeenCalledTimes(2); + expect(setOption.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ backgroundColor: '#0d0d0d' }), + ); + expect(setOption.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ backgroundColor: '#ffffff' }), + ); + }); + + it('shows loading while the chart runtime loader is pending', async () => { + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + let resolveRuntime: (runtime: EchartsRuntime) => void = () => {}; + const runtimePromise = new Promise((resolve) => { + resolveRuntime = resolve; + }); + + const container = await render( + runtimePromise} + />, + ); + + expect(container.querySelector('[role="status"]')).not.toBeNull(); + + await act(async () => { + resolveRuntime(runtime); + await runtimePromise; + await Promise.resolve(); + }); + + expect(container.querySelector('[role="status"]')).toBeNull(); + expect(runtime.init).toHaveBeenCalledOnce(); + }); + + it('reports chart runtime loader timeouts instead of spinning forever', async () => { + vi.useFakeTimers(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + try { + const container = await render( + new Promise(() => {})} + />, + ); + + expect(container.querySelector('[role="status"]')).not.toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('Chart runtime load timed out.'); + expect(console.warn).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata runtime load timed out after %dms', + 30_000, + ); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + expect(container.querySelector('[role="status"]')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('observes chart container resize after initialization', async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const addEventListener = vi.spyOn(window, 'addEventListener'); + const observe = vi.fn(); + const disconnect = vi.fn(); + class ResizeObserverStub { + observe = observe; + disconnect = disconnect; + } + globalThis.ResizeObserver = + ResizeObserverStub as unknown as typeof ResizeObserver; + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + try { + await render( + runtime} + />, + ); + await flushChart(); + + expect(observe).toHaveBeenCalledWith(expect.any(HTMLElement)); + expect( + addEventListener.mock.calls.filter(([type]) => type === 'resize'), + ).toHaveLength(0); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + + it('falls back to window resize listener when ResizeObserver is unavailable', async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const addEventListener = vi.spyOn(window, 'addEventListener'); + const removeEventListener = vi.spyOn(window, 'removeEventListener'); + globalThis.ResizeObserver = undefined as unknown as typeof ResizeObserver; + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const tree = (nextOption?: EchartsFullDataOption) => ( + + runtime} + /> + + ); + + try { + const { rerender } = await mount(tree(option)); + await flushChart(); + + expect( + addEventListener.mock.calls.filter(([type]) => type === 'resize'), + ).toHaveLength(1); + + await rerender(tree(undefined)); + + expect( + removeEventListener.mock.calls.filter(([type]) => type === 'resize'), + ).toHaveLength(1); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + } + }); + + it('reports synchronous loader failures inside the chart card', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + const container = await render( + { + throw new Error('loader failed'); + }} + />, + ); + await flushChart(); + + expect(container.textContent).toContain('loader failed'); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + }); + + it('reports async loader rejections inside the chart card', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + const container = await render( + Promise.reject(new Error('async loader failed'))} + />, + ); + await flushChart(); + + expect(container.textContent).toContain('async loader failed'); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + }); + + it('disposes a partially initialized chart when setOption fails', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const dispose = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(() => { + throw new Error('setOption failed'); + }), + resize: vi.fn(), + dispose, + })), + }; + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + expect(container.textContent).toContain('setOption failed'); + expect(dispose).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + }); + + it('reports chart option sanitization failures inside the chart card', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + expect(container.textContent).toContain('BigInt'); + expect(setOption).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(TypeError), + ); + }); + + it('recovers from chart errors after option changes', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const dispose = vi.fn(); + const setOption = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('setOption failed'); + }) + .mockImplementation(() => {}); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose, + })), + }; + const baseOption: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const nextOption: EchartsFullDataOption = { + ...baseOption, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Tue', orders: 200 }], + }, + }; + + const { container, root } = await mount( + + runtime} + /> + , + ); + await flushChart(); + + expect(container.textContent).toContain('setOption failed'); + expect( + container.querySelector('[data-testid="echarts-fulldata-chart"]'), + ).not.toBeNull(); + + await act(async () => { + root.render( + + runtime} + /> + , + ); + }); + await flushChart(); + + expect(container.textContent).not.toContain('setOption failed'); + expect(runtime.init).toHaveBeenCalledTimes(2); + expect(setOption).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + }); + + it('clears stale chart errors while ref data is pending after option changes', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + let resolveRef: (value: { + dimensions: string[]; + source: Array>; + }) => void = () => {}; + const resolveDataRef = vi.fn( + () => + new Promise((resolve) => { + resolveRef = resolve; + }), + ); + const setOption = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('setOption failed'); + }) + .mockImplementation(() => {}); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const renderer = createEchartsFullDataRenderer({ + loadEcharts: () => runtime, + resolveDataRef, + }); + const tree = (content: string) => ( + + + + + + + + ); + const directContent = `\`\`\`echarts-fulldata\n${JSON.stringify({ + dataset: { + dimensions: ['day', 'orders'], + source: [['Mon', 120]], + }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + })}\n\`\`\``; + const refContent = `\`\`\`echarts-fulldata\n${JSON.stringify({ + version: 1, + data: { + kind: 'ref', + ref: 'artifact://chart-data/orders', + format: 'json', + dimensions: ['day', 'orders'], + }, + option: { + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }, + })}\n\`\`\``; + + const { container, rerender } = await mount(tree(directContent)); + await flushChart(); + expect(container.textContent).toContain('setOption failed'); + + await rerender(tree(refContent)); + await flushChart(); + + expect(resolveDataRef).toHaveBeenCalledOnce(); + expect(container.textContent).not.toContain('setOption failed'); + expect( + container.querySelector('[role="status"]')?.getAttribute('aria-label'), + ).toBe('Rendering chart'); + + await act(async () => { + resolveRef({ + dimensions: ['day', 'orders'], + source: [['Tue', 200]], + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await flushChart(); + + expect(setOption).toHaveBeenCalledTimes(2); + }); + + it('sanitizes unsafe chart option fields before calling ECharts', async () => { + const unsafeSeriesEntry: Record = { + type: 'line', + cursor: 'url(https://example.test/ping), auto', + datasetIndex: 1, + data: [120, 999], + href: 'javascript:alert(1)', + id: 'data:text/html,', + name: 'blob:https://example.test/marker', + encode: { x: 'day', y: 'orders' }, + itemStyle: { + image: 'file:///tmp/marker.png', + }, + markLine: { + data: [ + { + yAxis: 150, + label: { formatter: 'Goal ' }, + lineStyle: { color: 'javascript:alert(1)' }, + }, + ], + }, + markArea: { + data: [[{ xAxis: 'Mon' }, { xAxis: 'Tue' }]], + itemStyle: { color: 'javascript:alert(1)' }, + }, + renderItem: 'javascript:alert(1)', + src: 'https://example.test/marker.png', + stack: '//example.test/stack', + symbol: 'image://https://example.test/marker.png', + tooltip: { + appendToBody: true, + enterable: true, + formatter: '', + renderMode: 'html', + }, + }; + Object.defineProperty(unsafeSeriesEntry, '__proto__', { + value: { polluted: true }, + enumerable: true, + }); + const unsafeRow: Record = { + day: 'javascript:alert(1)', + orders: 120, + }; + Object.defineProperty(unsafeRow, '__proto__', { + value: null, + enumerable: true, + }); + const businessTextSeriesEntry = { + type: 'line', + name: 'Latency ', + encode: { x: 'day', y: 'orders' }, + label: { + formatter: 'Results ', + }, + }; + + const option: EchartsFullDataOption = { + color: ['#ff0000', 'javascript:alert(1)', '#0000ff'], + dataset: [ + { + dimensions: [ + 'day', + 'orders', + 'constructor', + 'javascript:alert(1)', + { name: 'region', displayName: '' }, + { name: '' }, + 2024, + ] as unknown as string[], + source: [unsafeRow], + transform: { type: 'filter' }, + }, + { + dimensions: ['day', 'orders'], + source: [{ day: 'Tue', orders: 999 }], + }, + ] as EchartsFullDataOption['dataset'], + graphic: { type: 'text', style: { text: '' } }, + legend: { + data: ['Mon', 'Tue'], + }, + tooltip: { + formatter: '', + extraCssText: 'background-image:url(https://example.test/x.png)', + }, + xAxis: { + type: 'category', + data: ['Mon', 'Tue'], + name: '', + }, + yAxis: { type: 'value' }, + series: [unsafeSeriesEntry, businessTextSeriesEntry], + }; + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + await render( + runtime} + />, + ); + await flushChart(); + + const renderedOption = setOption.mock.calls[0]?.[0] as { + color?: unknown[]; + dataset?: Array<{ + dimensions?: unknown[]; + source?: Array>; + transform?: unknown; + }>; + graphic?: unknown; + legend?: { data?: unknown }; + tooltip?: { + formatter?: unknown; + extraCssText?: string; + renderMode?: string; + enterable?: boolean; + }; + xAxis?: { data?: unknown; name?: unknown }; + series?: Array<{ + cursor?: unknown; + data?: unknown; + datasetIndex?: unknown; + href?: unknown; + id?: unknown; + itemStyle?: { image?: unknown }; + label?: { formatter?: string }; + markArea?: { + data?: Array>; + itemStyle?: { color?: unknown }; + }; + markLine?: { + data?: Array<{ + yAxis?: number; + label?: { formatter?: string }; + lineStyle?: { color?: unknown }; + }>; + }; + name?: string; + polluted?: boolean; + renderItem?: unknown; + src?: unknown; + stack?: unknown; + symbol?: string; + tooltip?: { + appendToBody?: boolean; + confine?: boolean; + enterable?: boolean; + formatter?: unknown; + renderMode?: string; + }; + }>; + }; + expect(renderedOption.color).toEqual(['#ff0000', null, '#0000ff']); + expect(renderedOption.graphic).toBeUndefined(); + expect(renderedOption.dataset).toHaveLength(2); + expect(renderedOption.dataset?.[0]?.transform).toBeUndefined(); + expect(renderedOption.dataset?.[0]?.dimensions).toEqual([ + 'day', + 'orders', + {}, + {}, + { name: 'region' }, + {}, + '2024', + ]); + expect(renderedOption.dataset?.[0]?.source?.[0]?.day).toBe(''); + expect(renderedOption.dataset?.[0]?.source).toHaveLength(1); + expect( + Object.getPrototypeOf(renderedOption.dataset?.[0]?.source?.[0]), + ).toBe(Object.prototype); + expect( + Object.prototype.hasOwnProperty.call( + renderedOption.dataset?.[0]?.source?.[0], + '__proto__', + ), + ).toBe(false); + expect(renderedOption.dataset?.[1]?.source?.[0]?.day).toBe('Tue'); + expect(renderedOption.legend?.data).toBeUndefined(); + expect(renderedOption.tooltip?.formatter).toBeUndefined(); + expect(renderedOption.tooltip?.extraCssText).not.toContain('https://'); + expect(renderedOption.tooltip).toEqual( + expect.objectContaining({ + enterable: false, + renderMode: 'richText', + }), + ); + expect(renderedOption.xAxis?.data).toBeUndefined(); + expect(renderedOption.xAxis?.name).toBeUndefined(); + expect(renderedOption.series?.[0]?.cursor).toBeUndefined(); + expect(renderedOption.series?.[0]?.data).toBeUndefined(); + expect(renderedOption.series?.[0]?.datasetIndex).toBe(1); + expect(renderedOption.series?.[0]?.href).toBeUndefined(); + expect(renderedOption.series?.[0]?.id).toBeUndefined(); + expect(renderedOption.series?.[0]?.itemStyle?.image).toBeUndefined(); + expect(renderedOption.series?.[0]?.markArea?.data?.[0]?.[0]?.xAxis).toBe( + 'Mon', + ); + expect( + renderedOption.series?.[0]?.markArea?.itemStyle?.color, + ).toBeUndefined(); + expect(renderedOption.series?.[0]?.markLine?.data?.[0]?.yAxis).toBe(150); + expect( + renderedOption.series?.[0]?.markLine?.data?.[0]?.label?.formatter, + ).toBe('Goal '); + expect( + renderedOption.series?.[0]?.markLine?.data?.[0]?.lineStyle?.color, + ).toBeUndefined(); + expect(renderedOption.series?.[0]?.name).toBeUndefined(); + expect(renderedOption.series?.[0]?.polluted).toBeUndefined(); + expect(Object.getPrototypeOf(renderedOption.series?.[0])).toBe( + Object.prototype, + ); + expect(renderedOption.series?.[0]?.renderItem).toBeUndefined(); + expect(renderedOption.series?.[0]?.src).toBeUndefined(); + expect(renderedOption.series?.[0]?.stack).toBeUndefined(); + expect(renderedOption.series?.[0]?.symbol).toBe('circle'); + expect(renderedOption.series?.[0]?.tooltip?.formatter).toBeUndefined(); + expect(renderedOption.series?.[0]?.tooltip).toEqual( + expect.objectContaining({ + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + }), + ); + expect(renderedOption.series?.[1]?.name).toBe('Latency '); + expect(renderedOption.series?.[1]?.label?.formatter).toBe( + 'Results ', + ); + }); + + it('shows an error when sanitization strips every renderable chart entry', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const container = await render( + , + ); + await flushChart(); + + expect(container.textContent).toContain( + 'Sanitized chart has no series or dataset; option keys may have been stripped.', + ); + expect(consoleError).toHaveBeenCalledWith( + '[web-shell] echarts-fulldata render failed:', + expect.any(Error), + ); + }); + + it('ignores malformed title array entries', async () => { + const option: EchartsFullDataOption = { + title: [ + null, + { text: 'Recovered title' }, + ] as unknown as EchartsFullDataOption['title'], + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + + expect(container.textContent).toContain('Recovered title'); + }); + + it('uses the default title when a single title object has empty text', async () => { + const option: EchartsFullDataOption = { + title: { text: '' }, + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + const container = await render( + , + ); + + expect(container.querySelector('[title="Chart Loading"]')).not.toBeNull(); + }); + + it('shows an error when the chart runtime is unavailable', async () => { + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + + const container = await render( + , + ); + await flushChart(); + + expect(container.textContent).toContain('Chart runtime is unavailable.'); + expect( + container.querySelector('[data-testid="echarts-fulldata-chart"]'), + ).not.toBeNull(); + }); + + it('localizes chart chrome strings', async () => { + const option: EchartsFullDataOption = { + dataset: { + source: [{ day: 'Mon', orders: 120 }], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar' }], + }; + const { container } = await mount( + + + , + ); + await flushChart(); + + expect( + container + .querySelector('[data-testid="echarts-fulldata-rendered"]') + ?.getAttribute('aria-label'), + ).toBe('图表加载中'); + expect(container.textContent).toContain('图表运行时不可用。'); + expect( + container.querySelector('button[aria-label="显示图表"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="显示数据"]'), + ).not.toBeNull(); + expect(container.querySelector('[aria-label="视图模式"]')).not.toBeNull(); + }); + + it('caps the rendered data table rows', async () => { + const rows = Array.from({ length: 501 }, (_, index) => ({ + day: `Day ${index + 1}`, + orders: index + 1, + })); + const option: EchartsFullDataOption = { + dataset: { + dimensions: ['day', 'orders'], + source: rows, + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 'day', y: 'orders' } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + expect(container.textContent).toContain('Showing 500 of 501 rows'); + expect(container.querySelectorAll('tbody tr')).toHaveLength(500); + }); + + it('caps the rendered data table columns', async () => { + const columns = Array.from({ length: 60 }, (_, index) => `Metric ${index}`); + const option: EchartsFullDataOption = { + dataset: { + dimensions: columns, + source: [columns.map((_, index) => index)], + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 0, y: 1 } }], + }; + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + + const container = await render( + runtime} + />, + ); + await flushChart(); + await act(async () => { + container.querySelectorAll('button')[1]?.click(); + }); + + expect(container.textContent).toContain('Showing 50 of 60 columns'); + expect(container.querySelectorAll('thead th')).toHaveLength(50); + expect(container.querySelectorAll('tbody td')).toHaveLength(50); + }); + + it('accepts chart data at the row and cell limits', async () => { + const setOption = vi.fn(); + const runtime: EchartsRuntime = { + init: vi.fn(() => ({ + setOption, + resize: vi.fn(), + dispose: vi.fn(), + })), + }; + const columns = Array.from({ length: 40 }, (_, index) => `metric${index}`); + const rows = Array.from({ length: 1000 }, () => columns.map(() => 1)); + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + dimensions: columns, + source: rows, + }, + xAxis: { type: 'category' }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', encode: { x: 0, y: 1 } }], + }), + loadEcharts: () => runtime, + }); + await flushChart(); + + expect(container.textContent).not.toContain('too many'); + expect(setOption).toHaveBeenCalledOnce(); + }); + + it('rejects chart data over the row limit', async () => { + const rows = Array.from({ length: 2001 }, (_, index) => [index]); + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + source: rows, + }, + series: [{ type: 'bar' }], + }), + }); + + expect(container.textContent).toContain( + 'Chart data has too many rows. Maximum supported rows: 2000.', + ); + }); + + it('rejects chart data over the cell limit', async () => { + const columns = Array.from({ length: 40 }, (_, index) => `metric${index}`); + const rows = Array.from({ length: 1001 }, () => columns.map(() => 1)); + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + dimensions: columns, + source: rows, + }, + series: [{ type: 'bar' }], + }), + }); + + expect(container.textContent).toContain( + 'Chart data has too many cells. Maximum supported cells: 40000.', + ); + }); + + it('rejects chart data over the series limit', async () => { + const series = Array.from({ length: 101 }, () => ({ type: 'line' })); + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + source: [[1]], + }, + series, + }), + }); + + expect(container.textContent).toContain( + 'Chart data has too many series. Maximum supported series: 100.', + ); + }); + + it('rejects chart data over the nesting depth limit', async () => { + let nested: unknown = 'leaf'; + for (let index = 0; index < 42; index += 1) { + nested = { child: nested }; + } + + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + source: [[1]], + }, + series: [{ type: 'bar' }], + title: nested, + }), + }); + + expect(container.textContent).toContain('Chart data is too deeply nested.'); + }); + + it('rejects chart code over the size limit before parsing', async () => { + const container = await renderEchartsMarkdown({ + code: 'x'.repeat(500_001), + }); + + expect(container.textContent).toContain( + 'Chart data is too large. Maximum supported size: 500000 characters.', + ); + }); + + it('rejects chart data without dataset source rows', async () => { + const container = await renderEchartsMarkdown({ + code: JSON.stringify({ + dataset: { + source: [], + }, + series: [{ type: 'bar' }], + }), + }); + + expect(container.textContent).toContain( + 'Chart data must include dataset.source.', + ); + }); + + it('rejects syntactically valid JSON that is not an option object', async () => { + const container = await render( + + + + + , + ); + + expect(container.textContent).toContain( + 'Chart data must be a JSON object.', + ); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('handles invalid chart JSON without falling back to the visible fence', async () => { + const container = await render( + + + + + , + ); + + expect( + container.querySelector('[data-testid="echarts-fulldata-rendered"]'), + ).not.toBeNull(); + expect(container.textContent).not.toContain('echarts-fulldata'); + expect(container.querySelector('pre code')).toBeNull(); + }); + + it('shows loading instead of parse errors while chart JSON is streaming', async () => { + const container = await render( + + + + + , + ); + + expect( + container.querySelector('[data-testid="echarts-fulldata-rendered"]'), + ).not.toBeNull(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.textContent).not.toContain('Expected property'); + expect(container.textContent).not.toContain('echarts-fulldata'); + expect(container.querySelector('pre code')).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx b/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx new file mode 100644 index 0000000000..fbdbbfbd92 --- /dev/null +++ b/packages/web-shell/client/components/messages/EchartsFullDataBlock.tsx @@ -0,0 +1,1888 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + CodeBlockRenderer, + WebShellCodeBlockRenderInfo, +} from '../../customization'; +import type { WebShellTheme } from '../../themeContext'; +import { + EnhancedTable, + MAX_ENHANCED_TABLE_COLUMNS, + MAX_ENHANCED_TABLE_ROWS, + type EnhancedTableData, +} from './EnhancedMarkdownTable'; +import { useI18n } from '../../i18n'; +import styles from './EchartsFullDataBlock.module.css'; + +export const ECHARTS_FULLDATA_LANGUAGE = 'echarts-fulldata'; + +export type DatasetCell = string | number | boolean | null; +type DatasetRow = Record | DatasetCell[]; +type DatasetSource = DatasetRow[]; +type DatasetDimension = string | { name?: string }; +type EchartsObject = Record; +type ChartTheme = WebShellTheme; + +interface EchartsDataset { + dimensions?: DatasetDimension[]; + source?: DatasetSource; +} + +export interface EchartsFullDataOption { + title?: { text?: string } | Array<{ text?: string }>; + dataset?: EchartsDataset | EchartsDataset[]; + [key: string]: unknown; +} + +export interface EchartsInstance { + setOption(option: EchartsFullDataOption, opts?: { notMerge?: boolean }): void; + resize(): void; + dispose(): void; +} + +export interface EchartsRuntime { + init(element: HTMLElement, theme?: string): EchartsInstance; +} + +export type EchartsRuntimeLoader = () => + EchartsRuntime | Promise; + +export interface EchartsFullDataResolvedDataset { + dimensions: string[]; + source: DatasetCell[][]; +} + +export interface EchartsFullDataRefMeta { + dimensions: string[]; + format: 'csv' | 'json'; +} + +/** + * Resolves a renderer-validated data ref. The ref uses artifact:// or + * session-file:// with normalized non-empty path segments and no traversal, + * dot, drive-qualified, query/hash, whitespace, control, backslash, or + * double-encoded percent forms. + */ +export type EchartsFullDataRefResolver = ( + ref: string, + meta: EchartsFullDataRefMeta, +) => EchartsFullDataResolvedDataset | Promise; + +export interface EchartsFullDataBlockProps { + /** + * Chart option. Must be JSON-serializable; functions and other non-JSON + * values are stripped during internal cloning. + */ + option?: EchartsFullDataOption; + parseError?: string; + isStreaming?: boolean; + theme: ChartTheme; + loadEcharts?: EchartsRuntimeLoader; +} + +export interface EchartsFullDataRendererOptions { + loadEcharts?: EchartsRuntimeLoader; + resolveDataRef?: EchartsFullDataRefResolver; +} + +interface EchartsFullDataBlockFromCodeProps { + code: string; + isStreaming: boolean; + theme: ChartTheme; + loadEcharts?: EchartsRuntimeLoader; + resolveDataRef?: EchartsFullDataRefResolver; +} + +const CHART_THEME = { + light: { + background: '#ffffff', + foreground: '#343434', + muted: '#838d95', + border: '#e0e6f1', + axisLine: '#5d666f', + axisPointer: '#7c8a96', + tooltipBackground: '#ffffff', + tooltipShadow: '0 8px 24px rgba(15,23,42,0.12)', + primary: '#6250f9', + palette: [ + '#6250F9', + '#33AFA9', + '#AB7BFF', + '#5F99F9', + '#A9AFFF', + '#60CCC5', + '#C2A5FF', + '#8EB8FE', + '#E0E3FE', + '#98E3DD', + '#E8E1FA', + '#D7E6FF', + ], + }, + dark: { + background: '#0d0d0d', + foreground: '#f4f7ff', + muted: '#9aa3b7', + border: 'rgba(129,145,209,0.24)', + axisLine: '#657086', + axisPointer: '#8a98b3', + tooltipBackground: '#161616', + tooltipShadow: '0 10px 28px rgba(0,0,0,0.34)', + primary: '#8aa0ff', + palette: [ + '#8AA0FF', + '#60CCC5', + '#C2A5FF', + '#5F99F9', + '#A9AFFF', + '#33AFA9', + '#AB7BFF', + '#8EB8FE', + '#E0E3FE', + '#98E3DD', + '#E8E1FA', + '#D7E6FF', + ], + }, +} satisfies Record & { palette: string[] }>; + +const MAX_CHART_CODE_LENGTH = 500_000; +const MAX_OPTION_DEPTH = 40; +const MAX_DATA_ROWS = 2_000; +const MAX_DATA_CELLS = 40_000; +const MAX_SERIES_COUNT = 100; +const MAX_FORMATTER_TEMPLATE_LENGTH = 4_096; +const MAX_FORMATTER_DIMENSION_LENGTH = 256; +const DATA_REF_TIMEOUT_MS = 30_000; +const SUPPORTED_DATA_REF_PREFIXES = ['artifact://', 'session-file://']; +const SUPPORTED_DATA_REF_FORMATS = new Set(['csv', 'json']); +const UNSAFE_URI_WITH_AUTHORITY_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//; +const UNSAFE_DIMENSION_NAMES = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); +const UNSAFE_OPTION_HTML_TAG_PATTERN = + /<\/?(?:a|applet|base|button|embed|form|iframe|img|input|link|meta|object|script|style|svg)(?=[\s/>])/i; +const ECHARTS_TEMPLATE_DIMENSION_METACHAR_PATTERN = /[|{}]/; +const WINDOWS_DRIVE_SEGMENT_PATTERN = /^[a-z]:/i; +const noop = () => {}; + +// Sanitization is intentionally two-layered: top-level option keys are an +// allowlist/default-deny surface, while nested keys are a denylist/default-allow +// surface. UNSAFE_OPTION_KEYS does not backstop SAFE_TOP_LEVEL_OPTION_KEYS; any +// key promoted to the top-level allowlist needs a fresh subtree review. +const SAFE_TOP_LEVEL_OPTION_KEYS = new Set([ + 'angleAxis', + 'backgroundColor', + 'color', + 'dataset', + 'dataZoom', + 'grid', + 'legend', + 'polar', + 'radar', + 'radiusAxis', + 'series', + 'textStyle', + 'title', + 'tooltip', + 'xAxis', + 'yAxis', +]); + +const UNSAFE_OPTION_KEYS = new Set([ + 'appendTo', + 'backgroundImage', + 'brush', + 'calendar', + 'cursor', + 'data', + 'extraCssText', + 'geo', + 'graphic', + 'href', + 'image', + 'link', + 'map', + 'media', + 'renderItem', + 'src', + 'target', + 'timeline', + 'toolbox', + 'url', +]); + +export const ECHARTS_FULLDATA_SANITIZER_KEY_OVERLAP = Object.freeze( + [...SAFE_TOP_LEVEL_OPTION_KEYS].filter((key) => UNSAFE_OPTION_KEYS.has(key)), +); + +function isObject(value: unknown): value is EchartsObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function mergeDefaults(defaults: EchartsObject, value: unknown): EchartsObject { + if (!isObject(value)) return { ...defaults }; + const merged: EchartsObject = { ...defaults, ...value }; + for (const key of Object.keys(defaults)) { + if (isObject(defaults[key]) && isObject(value[key])) { + merged[key] = mergeDefaults(defaults[key] as EchartsObject, value[key]); + } + } + return merged; +} + +function styleComponent(value: unknown, defaults: EchartsObject): unknown { + if (Array.isArray(value)) { + return value.map((entry) => + isObject(entry) ? mergeDefaults(defaults, entry) : entry, + ); + } + if (isObject(value)) return mergeDefaults(defaults, value); + return value == null ? { ...defaults } : value; +} + +function forceTooltipSafety(value: unknown, safeExtraCssText: string): unknown { + const force = (entry: unknown) => + isObject(entry) + ? { + ...entry, + appendToBody: false, + confine: true, + enterable: false, + extraCssText: safeExtraCssText, + renderMode: 'richText', + } + : entry; + + if (Array.isArray(value)) return value.map(force); + return force(value); +} + +function getSafeTooltipCss(tokens: (typeof CHART_THEME)[ChartTheme]): string { + return `border-radius:6px;box-shadow:${tokens.tooltipShadow};max-height:300px;overflow:auto;white-space:pre-wrap;`; +} + +function styleExistingObject(value: unknown, defaults: EchartsObject): unknown { + return isObject(value) ? mergeDefaults(defaults, value) : value; +} + +function styleAxis( + value: unknown, + getDefaults: (index: number) => EchartsObject, +): unknown { + if (Array.isArray(value)) { + return value.map((entry, index) => + isObject(entry) ? mergeDefaults(getDefaults(index), entry) : entry, + ); + } + if (isObject(value)) return mergeDefaults(getDefaults(0), value); + return value; +} + +function exceedsMaxDepth(value: unknown, maxDepth: number): boolean { + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + if (current.depth > maxDepth) return true; + if (!current.value || typeof current.value !== 'object') continue; + + const entries = Array.isArray(current.value) + ? current.value + : Object.values(current.value); + for (const entry of entries) { + stack.push({ value: entry, depth: current.depth + 1 }); + } + } + + return false; +} + +function isUnsafeUriString(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return ( + normalized.startsWith('//') || + UNSAFE_URI_WITH_AUTHORITY_PATTERN.test(normalized) || + normalized.startsWith('blob:') || + normalized.startsWith('data:') || + normalized.startsWith('image://') || + normalized.startsWith('javascript:') || + normalized.startsWith('vbscript:') + ); +} + +function isUnsafeOptionString(value: string): boolean { + return isUnsafeUriString(value) || UNSAFE_OPTION_HTML_TAG_PATTERN.test(value); +} + +function isUnsafeDimensionName(value: string): boolean { + return UNSAFE_DIMENSION_NAMES.has(value) || isUnsafeOptionString(value); +} + +function sanitizeDatasetDimension( + dimension: unknown, +): DatasetDimension | undefined { + if (typeof dimension === 'number' && Number.isFinite(dimension)) { + return String(dimension); + } + if (typeof dimension === 'string') { + return isUnsafeDimensionName(dimension) ? {} : dimension; + } + if (isObject(dimension) && typeof dimension.name === 'string') { + return isUnsafeDimensionName(dimension.name) + ? {} + : { name: dimension.name }; + } + return isObject(dimension) ? {} : undefined; +} + +function getUnsafeDimensionError(dimensions: string[]): string | undefined { + return dimensions.some(isUnsafeDimensionName) + ? 'Chart envelope data.dimensions contains an unsafe dimension name.' + : undefined; +} + +function sanitizeDatasetCell(value: unknown): DatasetCell { + if (typeof value === 'string') return isUnsafeUriString(value) ? '' : value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'boolean' || value === null) return value; + return ''; +} + +function sanitizeDatasetRow(row: unknown): DatasetRow | undefined { + if (Array.isArray(row)) return row.map(sanitizeDatasetCell); + if (!isObject(row)) return undefined; + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (key === '__proto__') continue; + sanitized[key] = sanitizeDatasetCell(value); + } + return sanitized; +} + +function isAllowedAnnotationData(path: string[]): boolean { + const parentKey = path.at(-1); + return ( + parentKey === 'markLine' || + parentKey === 'markPoint' || + parentKey === 'markArea' + ); +} + +function sanitizeOptionValue(value: unknown, path: string[] = []): unknown { + if (Array.isArray(value)) { + return value.map((entry) => sanitizeOptionValue(entry, path) ?? null); + } + + if (isObject(value)) { + const sanitized: EchartsObject = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === '__proto__') continue; + if ( + UNSAFE_OPTION_KEYS.has(key) && + !(key === 'data' && isAllowedAnnotationData(path)) + ) { + continue; + } + if (key === 'formatter' && path.includes('tooltip')) continue; + + const next = sanitizeOptionValue(entry, [...path, key]); + if (next !== undefined) sanitized[key] = next; + } + return sanitized; + } + + if (typeof value === 'string' && isUnsafeOptionString(value)) + return undefined; + return value; +} + +function sanitizeDatasetValue(value: unknown): unknown { + const sanitizeDataset = (entry: unknown) => { + if (!isObject(entry)) return undefined; + const dataset: EchartsDataset = {}; + if (Array.isArray(entry.dimensions)) { + dataset.dimensions = entry.dimensions + .map(sanitizeDatasetDimension) + .filter( + (dimension): dimension is DatasetDimension => dimension !== undefined, + ); + } + if (Array.isArray(entry.source)) { + dataset.source = entry.source + .map(sanitizeDatasetRow) + .filter((row): row is DatasetRow => !!row); + } + return dataset; + }; + + if (Array.isArray(value)) { + const datasets = value + .map(sanitizeDataset) + .filter((entry): entry is EchartsDataset => !!entry); + return datasets.length > 0 ? datasets : undefined; + } + return sanitizeDataset(value); +} + +function sanitizeOptionForChart( + option: EchartsFullDataOption, +): EchartsFullDataOption { + const sanitized: EchartsFullDataOption = {}; + for (const [key, value] of Object.entries(option)) { + if (!SAFE_TOP_LEVEL_OPTION_KEYS.has(key)) continue; + const next = + key === 'dataset' + ? sanitizeDatasetValue(value) + : sanitizeOptionValue(value, [key]); + if (next !== undefined) sanitized[key] = next; + } + return sanitized; +} + +function getSeriesList(series: unknown): EchartsObject[] { + if (Array.isArray(series)) return series.filter(isObject); + return isObject(series) ? [series] : []; +} + +function hasRenderableChartData(option: EchartsFullDataOption): boolean { + return getSeriesList(option.series).length > 0 || !!option.dataset; +} + +function getEncodedDimension( + value: unknown, + dimensions: string[], +): string | undefined { + const candidate = Array.isArray(value) ? value[0] : value; + if (typeof candidate === 'string') { + return dimensions.includes(candidate) ? candidate : undefined; + } + if (typeof candidate === 'number' && Number.isInteger(candidate)) { + return ( + dimensions[candidate] ?? + dimensions.find((dimension) => dimension === String(candidate)) + ); + } + return undefined; +} + +function normalizeDatasetValueFormatter( + formatter: unknown, + dimension: string | undefined, +): unknown { + if (typeof formatter !== 'string' || !dimension) return formatter; + if (ECHARTS_TEMPLATE_DIMENSION_METACHAR_PATTERN.test(dimension)) { + return formatter; + } + if ( + formatter.length > MAX_FORMATTER_TEMPLATE_LENGTH || + dimension.length > MAX_FORMATTER_DIMENSION_LENGTH + ) { + return formatter; + } + return formatter.replace( + /\{c(?::([^}]*))?\}/g, + (_match: string, format?: string) => + format ? `{@${dimension}|${format}}` : `{@${dimension}}`, + ); +} + +function normalizeSeriesDatasetFormatters( + series: EchartsObject, + dimensions: string[], +): EchartsObject { + const encode = isObject(series.encode) ? series.encode : undefined; + const yDimension = getEncodedDimension(encode?.y, dimensions); + if (!yDimension || !isObject(series.label)) return series; + + return { + ...series, + label: { + ...series.label, + formatter: normalizeDatasetValueFormatter( + series.label.formatter, + yDimension, + ), + }, + }; +} + +function normalizeObjectDatasetFormatters( + option: EchartsFullDataOption, +): EchartsFullDataOption { + const dimensions = getColumns(option); + if (dimensions.length === 0 || !('series' in option)) return option; + + if (Array.isArray(option.series)) { + return { + ...option, + series: option.series.map((entry) => + isObject(entry) + ? normalizeSeriesDatasetFormatters(entry, dimensions) + : entry, + ), + }; + } + + return isObject(option.series) + ? { + ...option, + series: normalizeSeriesDatasetFormatters(option.series, dimensions), + } + : option; +} + +function getTooltipTrigger(option: EchartsFullDataOption): 'axis' | 'item' { + const hasAxis = 'xAxis' in option || 'yAxis' in option; + if (!hasAxis) return 'item'; + const series = getSeriesList(option.series); + const itemOnlyTypes = new Set(['pie', 'funnel', 'gauge', 'radar', 'treemap']); + if ( + series.length > 0 && + series.every((entry) => itemOnlyTypes.has(String(entry.type))) + ) { + return 'item'; + } + return 'axis'; +} + +function styleSeriesEntry( + series: EchartsObject, + tokens: (typeof CHART_THEME)[ChartTheme], +): EchartsObject { + const type = typeof series.type === 'string' ? series.type : undefined; + let defaults: EchartsObject = { + emphasis: { + focus: 'series', + itemStyle: { + borderColor: tokens.background, + borderWidth: 2, + }, + }, + labelLayout: { + hideOverlap: true, + }, + }; + + if (type === 'line') { + defaults = mergeDefaults(defaults, { + lineStyle: { + width: 2, + }, + itemStyle: { + borderWidth: 1, + }, + symbol: 'circle', + symbolSize: 4, + }); + } else if (type === 'bar') { + defaults = mergeDefaults(defaults, { + barCategoryGap: '48%', + barMaxWidth: 42, + itemStyle: { + borderRadius: [3, 3, 0, 0], + }, + }); + } else if (type === 'pie') { + defaults = mergeDefaults(defaults, { + itemStyle: { + borderColor: tokens.background, + borderWidth: 2, + }, + }); + } + + const styled = mergeDefaults(defaults, series); + const safeTooltipCss = getSafeTooltipCss(tokens); + if ('tooltip' in series) { + styled.tooltip = forceTooltipSafety( + styleComponent(series.tooltip, { + appendToBody: false, + confine: true, + enterable: false, + extraCssText: safeTooltipCss, + renderMode: 'richText', + }), + safeTooltipCss, + ); + } + const annotationLabel = { + color: tokens.foreground, + fontSize: 12, + textBorderColor: tokens.background, + textBorderWidth: 2, + }; + + if (isObject(series.label)) { + styled.label = styleExistingObject(series.label, { + color: tokens.foreground, + fontSize: 12, + textBorderColor: tokens.background, + textBorderWidth: 2, + }); + } + if (isObject(series.markPoint)) { + styled.markPoint = styleExistingObject(series.markPoint, { + itemStyle: { + borderColor: tokens.background, + borderWidth: 1, + }, + label: annotationLabel, + }); + } + if (isObject(series.markLine)) { + styled.markLine = styleExistingObject(series.markLine, { + label: { + ...annotationLabel, + color: tokens.muted, + }, + lineStyle: { + color: tokens.primary, + type: 'dashed', + width: 1.5, + }, + symbol: ['none', 'none'], + }); + } + + return styled; +} + +function styleSeries( + value: unknown, + tokens: (typeof CHART_THEME)[ChartTheme], +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => + isObject(entry) ? styleSeriesEntry(entry, tokens) : entry, + ); + } + return isObject(value) ? styleSeriesEntry(value, tokens) : value; +} + +function applyDefaultChartStyle( + option: EchartsFullDataOption, + theme: ChartTheme, +): EchartsFullDataOption { + const tokens = CHART_THEME[theme]; + const styled: EchartsFullDataOption = { ...option }; + + styled.backgroundColor = option.backgroundColor ?? tokens.background; + styled.color = option.color ?? [...tokens.palette]; + styled.textStyle = styleComponent(option.textStyle, { + color: tokens.foreground, + fontFamily: + "'pingfang SC', 'helvetica neue', arial, 'hiragino sans gb', 'microsoft yahei ui', 'microsoft yahei', sans-serif", + }); + styled.grid = styleComponent(option.grid, { + top: 24, + right: 36, + bottom: 48, + left: 24, + containLabel: true, + }); + const safeTooltipCss = getSafeTooltipCss(tokens); + styled.tooltip = forceTooltipSafety( + styleComponent(option.tooltip, { + trigger: getTooltipTrigger(option), + confine: true, + enterable: false, + renderMode: 'richText', + backgroundColor: tokens.tooltipBackground, + borderColor: tokens.border, + borderWidth: 1, + padding: [8, 10], + textStyle: { + color: tokens.foreground, + fontSize: 12, + }, + axisPointer: { + lineStyle: { + color: tokens.axisPointer, + width: 1, + }, + crossStyle: { + color: tokens.axisPointer, + width: 1, + }, + }, + extraCssText: safeTooltipCss, + }), + safeTooltipCss, + ); + styled.legend = styleComponent(option.legend, { + type: 'scroll', + bottom: 8, + padding: [4, 16], + textStyle: { + color: tokens.muted, + fontSize: 12, + }, + pageIconColor: tokens.primary, + pageIconInactiveColor: tokens.border, + pageTextStyle: { + color: tokens.muted, + }, + }); + + if ('xAxis' in option) { + styled.xAxis = styleAxis(option.xAxis, () => ({ + axisLine: { + show: true, + lineStyle: { + color: tokens.axisLine, + }, + }, + axisTick: { + show: true, + lineStyle: { + color: tokens.axisLine, + }, + }, + axisLabel: { + color: tokens.muted, + fontSize: 12, + hideOverlap: true, + }, + splitLine: { + show: false, + lineStyle: { + color: tokens.border, + }, + }, + nameTextStyle: { + color: tokens.muted, + }, + })); + } + + if ('yAxis' in option) { + styled.yAxis = styleAxis(option.yAxis, (index) => ({ + alignTicks: true, + axisLine: { + show: false, + lineStyle: { + color: tokens.axisLine, + }, + }, + axisTick: { + show: false, + lineStyle: { + color: tokens.axisLine, + }, + }, + axisLabel: { + color: tokens.muted, + fontSize: 12, + hideOverlap: true, + }, + splitLine: { + show: index === 0, + lineStyle: { + color: tokens.border, + }, + }, + nameGap: 12, + nameTextStyle: { + color: tokens.muted, + align: index === 0 ? 'left' : 'right', + }, + })); + } + + if ('series' in option) { + styled.series = styleSeries(option.series, tokens); + } + + return styled; +} + +function cloneAndSanitizeOptionForChart( + option: EchartsFullDataOption, +): EchartsFullDataOption { + // The component is exported, so callers can pass programmatic options; clone + // first to strip non-JSON values before the sanitizer walks the tree. + const cloned = JSON.parse(JSON.stringify(option)) as EchartsFullDataOption; + const normalized = normalizeObjectDatasetFormatters(cloned); + const sanitized = sanitizeOptionForChart(normalized); + if (!hasRenderableChartData(sanitized)) { + throw new Error( + 'Sanitized chart has no series or dataset; option keys may have been stripped.', + ); + } + return sanitized; +} + +function styleOptionForChart( + option: EchartsFullDataOption, + theme: ChartTheme, +): EchartsFullDataOption { + const styled = applyDefaultChartStyle(option, theme); + delete styled.title; + return styled; +} + +function getPrimaryDataset( + option: EchartsFullDataOption | undefined, +): EchartsDataset | undefined { + if (!option) return undefined; + return Array.isArray(option.dataset) ? option.dataset[0] : option.dataset; +} + +function getTitle( + option: EchartsFullDataOption | undefined, +): string | undefined { + const title = option?.title; + if (Array.isArray(title)) { + return title.find( + (entry): entry is { text: string } => + isObject(entry) && typeof entry.text === 'string' && !!entry.text, + )?.text; + } + return isObject(title) && typeof title.text === 'string' && !!title.text + ? title.text + : undefined; +} + +function getRows(option: EchartsFullDataOption | undefined): DatasetSource { + const source = getPrimaryDataset(option)?.source; + return Array.isArray(source) ? source : []; +} + +function normalizeDimensions( + dimensions: DatasetDimension[] | undefined, +): string[] { + if (!Array.isArray(dimensions)) return []; + return dimensions.map((dimension, index) => { + if (typeof dimension === 'string') return dimension; + if (typeof dimension === 'number' && Number.isFinite(dimension)) { + return String(dimension); + } + return isObject(dimension) && + typeof dimension.name === 'string' && + dimension.name + ? dimension.name + : String(index); + }); +} + +function getColumns(option: EchartsFullDataOption | undefined): string[] { + const dataset = getPrimaryDataset(option); + const explicit = normalizeDimensions(dataset?.dimensions); + if (explicit.length > 0) return explicit; + + const first = getRows(option)[0]; + if (!first) return []; + if (Array.isArray(first)) { + return first.map((_, index) => String(index + 1)); + } + return Object.keys(first); +} + +function getCell( + row: DatasetRow, + column: string, + columnIndex: number, +): DatasetCell | undefined { + return Array.isArray(row) + ? row[columnIndex] + : Object.hasOwn(row, column) + ? row[column] + : undefined; +} + +function formatCell(value: DatasetCell | undefined): string { + if (value == null) return ''; + return String(value); +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function hasWhitespaceOrControl(value: string): boolean { + return /\s/.test(value) || hasControlCharacter(value); +} + +type EchartsFullDataRefDescriptor = { + ref: string; + meta: EchartsFullDataRefMeta; + option: EchartsFullDataOption; +}; + +type ParseOptionResult = { + option?: EchartsFullDataOption; + parseError?: string; + dataRef?: EchartsFullDataRefDescriptor; +}; + +function validateDatasetSize( + rowCount: number, + columnCount: number, + cellCount = rowCount * Math.max(columnCount, 1), +): string | undefined { + if (rowCount === 0) { + return 'Chart data must include dataset.source.'; + } + if (rowCount > MAX_DATA_ROWS) { + return `Chart data has too many rows. Maximum supported rows: ${MAX_DATA_ROWS}.`; + } + + if (cellCount > MAX_DATA_CELLS) { + return `Chart data has too many cells. Maximum supported cells: ${MAX_DATA_CELLS}.`; + } + + return undefined; +} + +function validateDatasetCell( + value: unknown, + rowIndex: number, + cellIndex: number, +): string | undefined { + if ( + typeof value === 'string' || + typeof value === 'boolean' || + value === null + ) { + return undefined; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return undefined; + } + return `Chart data row ${rowIndex + 1} cell ${cellIndex + 1} must be a string, number, boolean, or null.`; +} + +function validateDatasetRows( + option: EchartsFullDataOption, +): string | undefined { + const rows = getRows(option); + const columns = getColumns(option); + let cellCount = 0; + let maxColumnCount = columns.length; + + for (const [rowIndex, row] of rows.entries()) { + if (Array.isArray(row)) { + if (columns.length > 0 && row.length !== columns.length) { + return `Chart data row ${rowIndex + 1} has ${row.length} cells; expected ${columns.length}.`; + } + maxColumnCount = Math.max(maxColumnCount, row.length); + cellCount += row.length; + for (const [cellIndex, cell] of row.entries()) { + const cellError = validateDatasetCell(cell, rowIndex, cellIndex); + if (cellError) return cellError; + } + continue; + } + + if (!isObject(row)) { + return `Chart data row ${rowIndex + 1} must be an object or array.`; + } + + const values = Object.values(row); + maxColumnCount = Math.max(maxColumnCount, values.length); + cellCount += values.length; + for (const [cellIndex, cell] of values.entries()) { + const cellError = validateDatasetCell(cell, rowIndex, cellIndex); + if (cellError) return cellError; + } + } + + return validateDatasetSize(rows.length, maxColumnCount, cellCount); +} + +function validateDatasetDimensions( + option: EchartsFullDataOption, +): string | undefined { + const datasets = Array.isArray(option.dataset) + ? option.dataset + : option.dataset + ? [option.dataset] + : []; + + for (const dataset of datasets) { + if (!Array.isArray(dataset.dimensions)) continue; + const error = getUnsafeDimensionError( + normalizeDimensions(dataset.dimensions), + ); + if (error) return error; + } + + return undefined; +} + +function validateOptionShape( + option: EchartsFullDataOption, +): string | undefined { + if (exceedsMaxDepth(option, MAX_OPTION_DEPTH)) { + return 'Chart data is too deeply nested.'; + } + + const datasetError = validateDatasetRows(option); + if (datasetError) return datasetError; + + const dimensionError = validateDatasetDimensions(option); + if (dimensionError) return dimensionError; + + const seriesCount = getSeriesList(option.series).length; + if (seriesCount > MAX_SERIES_COUNT) { + return `Chart data has too many series. Maximum supported series: ${MAX_SERIES_COUNT}.`; + } + + return undefined; +} + +function normalizeEnvelopeDataset(value: unknown): { + dataset?: EchartsFullDataResolvedDataset; + parseError?: string; +} { + if (!isObject(value)) { + return { parseError: 'Chart envelope data must be an object.' }; + } + + const dimensions = value.dimensions; + if ( + !Array.isArray(dimensions) || + dimensions.length === 0 || + !dimensions.every((dimension) => typeof dimension === 'string') + ) { + return { + parseError: 'Chart envelope data.dimensions must be a string array.', + }; + } + const dimensionError = getUnsafeDimensionError(dimensions); + if (dimensionError) return { parseError: dimensionError }; + + const source = value.source; + if (!Array.isArray(source)) { + return { + parseError: 'Chart envelope data.source must be an array of rows.', + }; + } + + const sizeError = validateDatasetSize(source.length, dimensions.length); + if (sizeError) return { parseError: sizeError }; + + const rows: DatasetCell[][] = []; + for (const [rowIndex, row] of source.entries()) { + if (!Array.isArray(row)) { + return { + parseError: `Chart envelope data.source row ${rowIndex + 1} must be an array.`, + }; + } + if (row.length !== dimensions.length) { + return { + parseError: `Chart envelope data.source row ${rowIndex + 1} has ${row.length} cells; expected ${dimensions.length}.`, + }; + } + + const nextRow: DatasetCell[] = []; + for (const [cellIndex, cell] of row.entries()) { + const cellError = validateDatasetCell(cell, rowIndex, cellIndex); + if (cellError) { + return { + parseError: cellError.replace( + 'Chart data row', + 'Chart envelope data.source row', + ), + }; + } + nextRow.push(cell as DatasetCell); + } + rows.push(nextRow); + } + + return { dataset: { dimensions: [...dimensions], source: rows } }; +} + +function injectDatasetAndValidate( + option: EchartsFullDataOption, + dataset: EchartsFullDataResolvedDataset, +): ParseOptionResult { + const normalized: EchartsFullDataOption = { + ...option, + dataset: { + dimensions: dataset.dimensions, + source: dataset.source, + }, + }; + const shapeError = validateOptionShape(normalized); + return shapeError ? { parseError: shapeError } : { option: normalized }; +} + +function isEchartsFullDataEnvelope(value: EchartsObject): boolean { + return 'version' in value && 'data' in value && 'option' in value; +} + +function normalizeDataRef(ref: unknown): { + ref?: string; + parseError?: string; +} { + if (typeof ref !== 'string' || ref.trim().length === 0) { + return { + parseError: 'Chart envelope data.ref must be a non-empty string.', + }; + } + const trimmed = ref.trim(); + if (trimmed !== ref || hasWhitespaceOrControl(ref)) { + return { + parseError: + 'Chart envelope data.ref must not contain whitespace or control characters.', + }; + } + const lower = trimmed.toLowerCase(); + const prefix = SUPPORTED_DATA_REF_PREFIXES.find((candidate) => + lower.startsWith(candidate), + ); + if (!prefix) { + return { + parseError: + 'Chart envelope data.ref must use artifact:// or session-file://.', + }; + } + + const rawPath = trimmed.slice(prefix.length); + if (rawPath.length === 0) { + return { parseError: 'Chart envelope data.ref path must be non-empty.' }; + } + if (/[?#\\]/.test(rawPath)) { + return { + parseError: + 'Chart envelope data.ref path must not include query, hash, or backslash characters.', + }; + } + + let decodedPath: string; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + return { parseError: 'Chart envelope data.ref path is malformed.' }; + } + if (/[%?#\\]/.test(decodedPath) || hasWhitespaceOrControl(decodedPath)) { + return { + parseError: + 'Chart envelope data.ref path must not include query, hash, whitespace, control, backslash, or double-encoded percent characters.', + }; + } + + const segments = decodedPath.split('/'); + if ( + segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + WINDOWS_DRIVE_SEGMENT_PATTERN.test(segment), + ) + ) { + return { + parseError: + 'Chart envelope data.ref path must use non-empty normalized segments and cannot contain ".", "..", or drive-qualified segments.', + }; + } + return { ref: `${prefix}${segments.join('/')}` }; +} + +function normalizeDataRefMeta(data: EchartsObject): { + meta?: EchartsFullDataRefMeta; + parseError?: string; +} { + const dimensions = data.dimensions; + if ( + !Array.isArray(dimensions) || + dimensions.length === 0 || + !dimensions.every((dimension) => typeof dimension === 'string') + ) { + return { + parseError: 'Chart envelope data.dimensions must be a string array.', + }; + } + const dimensionError = getUnsafeDimensionError(dimensions); + if (dimensionError) return { parseError: dimensionError }; + + const format = data.format; + if (typeof format !== 'string' || !SUPPORTED_DATA_REF_FORMATS.has(format)) { + return { + parseError: 'Chart envelope data.format must be "csv" or "json".', + }; + } + + return { + meta: { + dimensions: [...dimensions], + format: format as EchartsFullDataRefMeta['format'], + }, + }; +} + +function normalizeEnvelope(envelope: EchartsObject): ParseOptionResult { + if (envelope.version !== 1) { + return { parseError: 'Chart envelope version must be 1.' }; + } + if (!isObject(envelope.option)) { + return { parseError: 'Chart envelope option must be an object.' }; + } + if (!isObject(envelope.data)) { + return { parseError: 'Chart envelope data must be an object.' }; + } + + const option = envelope.option as EchartsFullDataOption; + const { data } = envelope; + if (data.kind === 'inline') { + const { dataset, parseError } = normalizeEnvelopeDataset(data); + if (parseError || !dataset) return { parseError }; + return injectDatasetAndValidate(option, dataset); + } + + if (data.kind !== 'ref') { + return { + parseError: 'Chart envelope data.kind must be "inline" or "ref".', + }; + } + + const { ref, parseError } = normalizeDataRef(data.ref); + if (parseError || !ref) return { parseError }; + const { meta, parseError: metaError } = normalizeDataRefMeta(data); + if (metaError || !meta) return { parseError: metaError }; + return { dataRef: { ref, meta, option } }; +} + +function resolveDataRefWithTimeout( + resolveDataRef: EchartsFullDataRefResolver, + ref: string, + meta: EchartsFullDataRefMeta, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = globalThis.setTimeout(() => { + console.warn( + '[web-shell] echarts-fulldata data-ref resolution timed out after %dms (ref=%s, format=%s)', + DATA_REF_TIMEOUT_MS, + ref, + meta.format, + ); + reject(new Error('Data reference resolution timed out.')); + }, DATA_REF_TIMEOUT_MS); + + Promise.resolve() + .then(() => resolveDataRef(ref, meta)) + .then(resolve, reject) + .finally(() => globalThis.clearTimeout(timeoutId)); + }); +} + +function isResolvedDataset( + value: unknown, +): value is EchartsFullDataResolvedDataset { + return ( + isObject(value) && + Array.isArray(value.dimensions) && + Array.isArray(value.source) + ); +} + +function resolveEnvelopeDataRef( + resolveDataRef: EchartsFullDataRefResolver, + dataRef: EchartsFullDataRefDescriptor, +): Promise { + return resolveDataRefWithTimeout(resolveDataRef, dataRef.ref, dataRef.meta) + .then((resolved) => { + if (!isResolvedDataset(resolved)) { + console.error( + '[web-shell] echarts-fulldata resolver returned invalid dataset:', + dataRef.ref, + resolved, + ); + return { + parseError: 'Chart data resolver returned an invalid dataset.', + }; + } + const result = normalizeEnvelopeDataset({ + kind: 'inline', + dimensions: resolved.dimensions, + source: resolved.source, + }); + if (result.parseError || !result.dataset) { + return { parseError: result.parseError }; + } + return injectDatasetAndValidate(dataRef.option, result.dataset); + }) + .catch((error: unknown) => { + console.error( + '[web-shell] echarts-fulldata data-ref resolution failed:', + dataRef.ref, + error, + ); + return { + parseError: 'Chart data reference could not be resolved.', + }; + }); +} + +function parseOptionInner(code: string): ParseOptionResult { + if (code.length > MAX_CHART_CODE_LENGTH) { + return { + parseError: `Chart data is too large. Maximum supported size: ${MAX_CHART_CODE_LENGTH} characters.`, + }; + } + + try { + const parsed = JSON.parse(code) as unknown; + if (!isObject(parsed)) { + return { parseError: 'Chart data must be a JSON object.' }; + } + if (exceedsMaxDepth(parsed, MAX_OPTION_DEPTH)) { + return { parseError: 'Chart data is too deeply nested.' }; + } + if (isEchartsFullDataEnvelope(parsed)) { + return normalizeEnvelope(parsed); + } + const option = parsed as EchartsFullDataOption; + const shapeError = validateOptionShape(option); + if (shapeError) return { parseError: shapeError }; + return { option }; + } catch (error) { + return { + parseError: + error instanceof Error ? error.message : 'Chart data is invalid.', + }; + } +} + +function parseOption(code: string): ParseOptionResult { + const result = parseOptionInner(code); + if (result.parseError) { + console.warn( + '[web-shell] echarts-fulldata parse failed: %s (code length=%d)', + result.parseError, + code.length, + ); + } + return result; +} + +function getDataRefKey( + dataRef: EchartsFullDataRefDescriptor, + code: string, +): string { + return JSON.stringify([ + dataRef.ref, + dataRef.meta.format, + dataRef.meta.dimensions, + code, + ]); +} + +export function createEchartsFullDataRenderer({ + loadEcharts, + resolveDataRef, +}: EchartsFullDataRendererOptions = {}): CodeBlockRenderer { + return function renderEchartsFullDataBlock( + info: WebShellCodeBlockRenderInfo, + ) { + if ( + info.source !== 'assistant' || + info.language.toLowerCase() !== ECHARTS_FULLDATA_LANGUAGE + ) { + return undefined; + } + return ( + + ); + }; +} + +function EchartsFullDataBlockFromCode({ + code, + isStreaming, + theme, + loadEcharts, + resolveDataRef, +}: EchartsFullDataBlockFromCodeProps) { + const resolveDataRefRef = useRef(resolveDataRef); + resolveDataRefRef.current = resolveDataRef; + const hasResolveDataRef = !!resolveDataRef; + const stableResolveDataRef = useCallback( + (ref, meta) => { + const resolver = resolveDataRefRef.current; + if (!resolver) { + throw new Error('Chart data reference resolver is unavailable.'); + } + return resolver(ref, meta); + }, + [], + ); + const parsed = useMemo( + () => (isStreaming ? {} : parseOption(code)), + [code, isStreaming], + ); + const [resolvedParsed, setResolvedParsed] = useState({}); + const { dataRef } = parsed; + const dataRefKey = dataRef ? getDataRefKey(dataRef, code) : undefined; + const dataRefRef = useRef(dataRef); + const resolvedDataRefCacheRef = useRef< + { key: string; result: ParseOptionResult } | undefined + >(undefined); + dataRefRef.current = dataRef; + + useEffect(() => { + const currentDataRef = dataRefRef.current; + if (!dataRefKey || !currentDataRef) { + return; + } + if (!hasResolveDataRef) { + setResolvedParsed({ + parseError: 'Chart data reference resolver is unavailable.', + }); + return; + } + if (resolvedDataRefCacheRef.current?.key === dataRefKey) { + setResolvedParsed(resolvedDataRefCacheRef.current.result); + return; + } + + let cancelled = false; + setResolvedParsed({}); + resolveEnvelopeDataRef(stableResolveDataRef, currentDataRef).then( + (result) => { + if (!cancelled) { + resolvedDataRefCacheRef.current = { key: dataRefKey, result }; + setResolvedParsed(result); + } + }, + ); + return () => { + cancelled = true; + }; + }, [dataRefKey, hasResolveDataRef, stableResolveDataRef]); + + const { option, parseError } = dataRef ? resolvedParsed : parsed; + + return ( + + ); +} + +function ChartIcon() { + return ( + + ); +} + +function DataIcon() { + return ( + + ); +} + +function toEnhancedTableData( + columns: string[], + rows: DatasetSource, +): EnhancedTableData { + return { + headers: columns.map((column, columnIndex) => ({ + key: `header-${columnIndex}-${column}`, + content: column, + text: column, + isHeader: true, + })), + rows: rows.map((row, rowIndex) => ({ + key: `row-${rowIndex}`, + cells: columns.map((column, columnIndex) => { + const text = formatCell(getCell(row, column, columnIndex)); + return { + key: `row-${rowIndex}-${columnIndex}`, + content: text, + text, + isHeader: false, + }; + }), + })), + columnCount: columns.length, + }; +} + +function SimpleDatasetTable({ + columns, + rows, +}: { + columns: string[]; + rows: DatasetSource; +}) { + const { t } = useI18n(); + const visibleRows = rows.slice(0, MAX_ENHANCED_TABLE_ROWS); + const visibleColumns = columns.slice(0, MAX_ENHANCED_TABLE_COLUMNS); + const omittedRows = rows.length - visibleRows.length; + const omittedColumns = columns.length - visibleColumns.length; + const isTruncated = omittedRows > 0 || omittedColumns > 0; + + return ( +
+ {isTruncated && ( +
+ {t('echartsChart.tableNotice', { + visibleRows: visibleRows.length, + totalRows: rows.length, + visibleColumns: visibleColumns.length, + totalColumns: columns.length, + omittedRows, + omittedColumns, + })} +
+ )} + + + + {visibleColumns.map((column, columnIndex) => ( + + ))} + + + + {visibleRows.map((row, rowIndex) => ( + + {visibleColumns.map((column, columnIndex) => ( + + ))} + + ))} + +
{column}
+ {formatCell(getCell(row, column, columnIndex))} +
+
+ ); +} + +function EchartsDataTable({ + columns, + rows, +}: { + columns: string[]; + rows: DatasetSource; +}) { + const { t } = useI18n(); + const isEmpty = columns.length === 0 || rows.length === 0; + const isOversized = + rows.length > MAX_ENHANCED_TABLE_ROWS || + columns.length > MAX_ENHANCED_TABLE_COLUMNS; + const table = useMemo( + () => + isEmpty || isOversized ? undefined : toEnhancedTableData(columns, rows), + [columns, isEmpty, isOversized, rows], + ); + + if (isEmpty) { + return
{t('echartsChart.noData')}
; + } + + if (isOversized) { + return ; + } + + return ( +
+ +
+ ); +} + +function ChartLoadingState() { + const { t } = useI18n(); + return ( +
+
+ ); +} + +function loadEchartsWithTimeout( + loadRuntime: EchartsRuntimeLoader, +): Promise { + return new Promise((resolve, reject) => { + const timeoutId = globalThis.setTimeout(() => { + console.warn( + '[web-shell] echarts-fulldata runtime load timed out after %dms', + DATA_REF_TIMEOUT_MS, + ); + reject(new Error('Chart runtime load timed out.')); + }, DATA_REF_TIMEOUT_MS); + + Promise.resolve() + .then(loadRuntime) + .then(resolve, reject) + .finally(() => globalThis.clearTimeout(timeoutId)); + }); +} + +export function EchartsFullDataBlock({ + option, + parseError, + isStreaming = false, + theme, + loadEcharts, +}: EchartsFullDataBlockProps) { + const { t } = useI18n(); + const chartRef = useRef(null); + const chartInstanceRef = useRef(undefined); + const removeResizeRef = useRef<() => void>(noop); + const loadEchartsRef = useRef(loadEcharts); + const tRef = useRef(t); + const renderRequestRef = useRef(0); + const chartThemeRef = useRef(undefined); + const [mode, setMode] = useState<'chart' | 'data'>('chart'); + const [chartError, setChartError] = useState(null); + const [chartReady, setChartReady] = useState(false); + const { sanitizedOption, chartOptionError } = useMemo(() => { + if (!option || parseError) return {}; + try { + return { sanitizedOption: cloneAndSanitizeOptionForChart(option) }; + } catch (error) { + return { chartOptionError: error }; + } + }, [option, parseError]); + const rows = useMemo(() => getRows(sanitizedOption), [sanitizedOption]); + const columns = useMemo(() => getColumns(sanitizedOption), [sanitizedOption]); + const chartOption = useMemo( + () => + sanitizedOption ? styleOptionForChart(sanitizedOption, theme) : undefined, + [sanitizedOption, theme], + ); + const hasLoadEcharts = !!loadEcharts; + loadEchartsRef.current = loadEcharts; + tRef.current = t; + + const disposeChart = useCallback(() => { + removeResizeRef.current(); + removeResizeRef.current = noop; + chartInstanceRef.current?.dispose(); + chartInstanceRef.current = undefined; + chartThemeRef.current = undefined; + }, []); + + useEffect( + () => () => { + renderRequestRef.current += 1; + disposeChart(); + }, + [disposeChart], + ); + + useEffect(() => { + if (mode !== 'chart' || parseError || (!chartOption && !chartOptionError)) { + renderRequestRef.current += 1; + disposeChart(); + setChartReady(false); + setChartError(null); + return; + } + const requestId = (renderRequestRef.current += 1); + if (chartOptionError) { + disposeChart(); + setChartReady(false); + console.error( + '[web-shell] echarts-fulldata render failed:', + chartOptionError, + ); + setChartError( + chartOptionError instanceof Error + ? chartOptionError.message + : tRef.current('echartsChart.renderFailed'), + ); + return; + } + if (!chartOption) { + disposeChart(); + setChartReady(false); + return; + } + if (!hasLoadEcharts) { + disposeChart(); + setChartReady(false); + setChartError(tRef.current('echartsChart.runtimeUnavailable')); + return; + } + + setChartError(null); + if (!chartInstanceRef.current) setChartReady(false); + + const renderChart = async () => { + try { + let chart = chartInstanceRef.current; + if (chart && chartThemeRef.current !== theme) { + disposeChart(); + setChartReady(false); + chart = undefined; + } + if (!chart) { + const loadRuntime = loadEchartsRef.current; + if (!loadRuntime || !chartRef.current) return; + const runtime = await loadEchartsWithTimeout(loadRuntime); + if (requestId !== renderRequestRef.current || !chartRef.current) { + return; + } + chart = runtime.init(chartRef.current, theme); + chartInstanceRef.current = chart; + chartThemeRef.current = theme; + } + + if (requestId !== renderRequestRef.current) return; + chart.setOption(chartOption, { notMerge: true }); + if (removeResizeRef.current === noop && chartRef.current) { + let resizeFrame: number | undefined; + const onResize = () => { + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined; + chartInstanceRef.current?.resize(); + }); + }; + const observer = + typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(onResize); + if (observer) { + observer.observe(chartRef.current); + } else { + window.addEventListener('resize', onResize); + } + removeResizeRef.current = () => { + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame); + observer?.disconnect(); + if (!observer) window.removeEventListener('resize', onResize); + }; + } + setChartReady(true); + } catch (error) { + if (requestId !== renderRequestRef.current) return; + console.error('[web-shell] echarts-fulldata render failed:', error); + disposeChart(); + setChartReady(false); + setChartError( + error instanceof Error + ? error.message + : tRef.current('echartsChart.renderFailed'), + ); + } + }; + + void renderChart(); + }, [ + chartOption, + chartOptionError, + disposeChart, + hasLoadEcharts, + mode, + parseError, + theme, + ]); + + const title = getTitle(option) ?? t('echartsChart.defaultTitle'); + + return ( +
+
+
+ {title} +
+ {!parseError && ( +
+ + +
+ )} +
+ {parseError && isStreaming ? ( + + ) : parseError ? ( +
{parseError}
+ ) : mode === 'chart' ? ( +
+
+ {chartError ? ( +
+
{chartError}
+
+ ) : ( + !chartReady && ( +
+ +
+ ) + )} +
+ ) : ( + + )} +
+ ); +} diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index 0a3b62da43..0639b98b96 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -31,7 +31,7 @@ type TextFilterOperator = | 'endsWith'; type NumberFilterOperator = 'gt' | 'gte' | 'lt' | 'lte' | 'between'; -interface CellData { +export interface EnhancedTableCell { key: string; content: ReactNode; text: string; @@ -39,14 +39,14 @@ interface CellData { textAlign?: CSSProperties['textAlign']; } -interface RowData { +export interface EnhancedTableRow { key: string; - cells: CellData[]; + cells: EnhancedTableCell[]; } -interface ParsedTable { - headers: CellData[]; - rows: RowData[]; +export interface EnhancedTableData { + headers: EnhancedTableCell[]; + rows: EnhancedTableRow[]; columnCount: number; } @@ -103,8 +103,8 @@ const NUMBER_FILTER_LABEL_KEYS: Record = { between: 'markdownTable.filter.number.between', }; -const MAX_ENHANCED_TABLE_ROWS = 500; -const MAX_ENHANCED_TABLE_COLUMNS = 50; +export const MAX_ENHANCED_TABLE_ROWS = 500; +export const MAX_ENHANCED_TABLE_COLUMNS = 50; const FOCUSABLE_FILTER_MENU_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -155,7 +155,7 @@ function getTextContent(node: ReactNode): string { return ''; } -function emptyCell(rowKey: string, columnIndex: number): CellData { +function emptyCell(rowKey: string, columnIndex: number): EnhancedTableCell { return { key: `${rowKey}-empty-${columnIndex}`, content: '', @@ -164,7 +164,7 @@ function emptyCell(rowKey: string, columnIndex: number): CellData { }; } -function parseRow(rowNode: TableElement, rowKey: string): RowData { +function parseRow(rowNode: TableElement, rowKey: string): EnhancedTableRow { const cellNodes = Children.toArray(rowNode.props.children).filter( (child) => isTagElement(child, 'td') || isTagElement(child, 'th'), ); @@ -180,13 +180,19 @@ function parseRow(rowNode: TableElement, rowKey: string): RowData { }; } -function parseRows(sectionNode: TableElement, prefix: string): RowData[] { +function parseRows( + sectionNode: TableElement, + prefix: string, +): EnhancedTableRow[] { return Children.toArray(sectionNode.props.children) .filter((child) => isTagElement(child, 'tr')) .map((rowNode, rowIndex) => parseRow(rowNode, `${prefix}-${rowIndex}`)); } -function normalizeRow(row: RowData, columnCount: number): RowData { +function normalizeRow( + row: EnhancedTableRow, + columnCount: number, +): EnhancedTableRow { return { ...row, cells: Array.from( @@ -200,11 +206,11 @@ function normalizeRow(row: RowData, columnCount: number): RowData { function parseTable( children: ReactNode, defaultColumnLabel: (columnIndex: number) => string, -): ParsedTable { +): EnhancedTableData { const topLevel = Children.toArray(children); - const headerRows: RowData[] = []; - const bodyRows: RowData[] = []; - const directRows: RowData[] = []; + const headerRows: EnhancedTableRow[] = []; + const bodyRows: EnhancedTableRow[] = []; + const directRows: EnhancedTableRow[] = []; topLevel.forEach((child, index) => { if (isTagElement(child, 'thead')) { @@ -291,7 +297,7 @@ function sanitizeForClipboard(value: string): string { function getSelectionText( range: SelectionRange | null, - rows: RowData[], + rows: EnhancedTableRow[], visibleColumnIndexes: number[], ): string { if (!range) return ''; @@ -317,8 +323,8 @@ function getSelectionText( } function getVisibleTableText( - headers: CellData[], - rows: RowData[], + headers: EnhancedTableCell[], + rows: EnhancedTableRow[], visibleColumnIndexes: number[], ): string { if (visibleColumnIndexes.length === 0) return ''; @@ -422,10 +428,10 @@ function matchesColumnFilter(value: string, filter: ColumnFilter): boolean { } function applyFilters( - rows: RowData[], + rows: EnhancedTableRow[], filters: Record, excludeColumnIndex?: number, -): RowData[] { +): EnhancedTableRow[] { const activeFilters = Object.entries(filters) .map(([key, value]) => [Number(key), value] as const) .filter( @@ -441,7 +447,10 @@ function applyFilters( ); } -function sortRows(rows: RowData[], sort: SortState | null): RowData[] { +function sortRows( + rows: EnhancedTableRow[], + sort: SortState | null, +): EnhancedTableRow[] { if (!sort) return rows; return rows .map((row, index) => ({ row, index })) @@ -457,7 +466,7 @@ function sortRows(rows: RowData[], sort: SortState | null): RowData[] { } function getColumnOptions( - rows: RowData[], + rows: EnhancedTableRow[], columnIndex: number, blankLabel: string, ): FilterOption[] { @@ -476,7 +485,10 @@ function getColumnOptions( .sort((a, b) => compareCellText(a.value, b.value)); } -function isMostlyNumericColumn(rows: RowData[], columnIndex: number): boolean { +function isMostlyNumericColumn( + rows: EnhancedTableRow[], + columnIndex: number, +): boolean { let filledCount = 0; let numericCount = 0; rows.forEach((row) => { @@ -1038,14 +1050,14 @@ export function EnhancedMarkdownTable({ return <>{fallback ?? {children}
}; } - return ; + return ; } -function InteractiveMarkdownTable({ +export function EnhancedTable({ table, toolbarExtra, }: { - table: ParsedTable; + table: EnhancedTableData; toolbarExtra?: ReactNode; }) { const { t } = useI18n(); diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index 7d9b95b6ba..bd8549cd10 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -4,8 +4,12 @@ import { act, createElement, type ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { WebShellCustomizationProvider } from '../../customization'; +import { + WebShellCustomizationProvider, + type WebShellCodeBlockRenderInfo, +} from '../../customization'; import { I18nProvider } from '../../i18n'; +import { ThemeProvider } from '../../themeContext'; import * as EnhancedTableModule from './EnhancedMarkdownTable'; import { MAX_HIGHLIGHT_LINE_CHARS, @@ -261,6 +265,9 @@ describe('resolveFenceLanguage', () => { expect(resolveFenceLanguage('ts').resolvedLang).toBe('typescript'); expect(resolveFenceLanguage('js').resolvedLang).toBe('javascript'); expect(resolveFenceLanguage('py').resolvedLang).toBe('python'); + expect(resolveFenceLanguage('c++').resolvedLang).toBe('cpp'); + expect(resolveFenceLanguage('c#').resolvedLang).toBe('csharp'); + expect(resolveFenceLanguage('f#').resolvedLang).toBe('fsharp'); expect(resolveFenceLanguage('sh').resolvedLang).toBe('bash'); expect(resolveFenceLanguage('yml').resolvedLang).toBe('yaml'); expect(resolveFenceLanguage('golang').resolvedLang).toBe('go'); @@ -334,6 +341,838 @@ describe('Markdown mermaid rendering', () => { }); }); +describe('Markdown custom code block rendering', () => { + it('lets host renderers replace assistant fenced code blocks', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn((info: WebShellCodeBlockRenderInfo) => { + if (info.language !== 'echarts-fulldata') return undefined; + return createElement( + 'div', + { 'data-chart-theme': info.theme }, + `${info.source}:${info.isStreaming}:${info.code}`, + ); + }); + + await act(async () => { + root.render( + createElement( + ThemeProvider, + { value: 'dark' }, + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: '```echarts-fulldata\nconst option = {};\n```', + source: 'assistant', + isStreaming: true, + }), + ), + ), + ); + }); + + expect(renderCodeBlock).toHaveBeenCalledWith({ + language: 'echarts-fulldata', + resolvedLanguage: 'text', + className: 'language-echarts-fulldata', + code: 'const option = {};', + isStreaming: true, + source: 'assistant', + theme: 'dark', + }); + expect(container.querySelector('[data-chart-theme="dark"]')).not.toBeNull(); + expect(container.textContent).toContain( + 'assistant:true:const option = {};', + ); + expect(container.querySelector('pre code')).toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('falls back to the default code block when the host renderer declines', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn(() => undefined); + + await act(async () => { + root.render( + createElement( + ThemeProvider, + { value: 'light' }, + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: '```custom-chart\nconst option = {};\n```', + source: 'assistant', + isStreaming: false, + }), + ), + ), + ); + }); + + expect(renderCodeBlock).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'custom-chart', + resolvedLanguage: 'text', + className: 'language-custom-chart', + code: 'const option = {};', + theme: 'light', + }), + ); + expect(container.textContent).toContain('custom-chart'); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const option = {};', + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('passes resolved language aliases to host renderers', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn(() => undefined); + + await act(async () => { + root.render( + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: '```ts\nconst x = 1;\n```', + source: 'assistant', + }), + ), + ); + }); + + expect(renderCodeBlock).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'ts', + resolvedLanguage: 'typescript', + }), + ); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const x = 1;', + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('passes punctuation language aliases to host renderers', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn(() => undefined); + + await act(async () => { + root.render( + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: '```c++\nstd::cout << "hello";\n```', + source: 'assistant', + }), + ), + ); + }); + + expect(renderCodeBlock).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'c++', + resolvedLanguage: 'cpp', + }), + ); + expect(container.querySelector('pre code')?.textContent).toContain( + 'std::cout << "hello";', + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('extracts language prefixes from glued fence metadata', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const renderCodeBlock = vi.fn(() => undefined); + + await act(async () => { + root.render( + createElement( + WebShellCustomizationProvider, + { value: { markdown: { renderCodeBlock } } }, + createElement(Markdown, { + content: + '```js{1,3}\nconst x = 1;\n```\n\n```c:main.c\nint main() {}\n```\n\n```vue{2}\n