Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d0ef6d8
feat(web-shell): support custom code block rendering
zhangxy-zju Jul 3, 2026
634772e
fix(web-shell): harden custom code block rendering
zhangxy-zju Jul 3, 2026
6d706dc
docs: add skill capability gating design
zhangxy-zju Jul 3, 2026
62dbb51
fix(web-shell): make chart skill host supplied
zhangxy-zju Jul 3, 2026
7daeebf
docs(web-shell): write chart skill in English
zhangxy-zju Jul 3, 2026
be64d78
docs(web-shell): document full-data chart payload
zhangxy-zju Jul 3, 2026
031e949
docs(web-shell): use dataset-backed chart payload
zhangxy-zju Jul 3, 2026
68bec83
feat(web-shell): add echarts full-data renderer
zhangxy-zju Jul 3, 2026
e6f228a
chore(web-shell): keep chart skill host supplied
zhangxy-zju Jul 3, 2026
a25192d
fix(web-shell): show loading for streaming chart blocks
zhangxy-zju Jul 3, 2026
bcf9c35
style(web-shell): polish echarts full-data renderer
zhangxy-zju Jul 3, 2026
eb0da5d
Merge remote-tracking branch 'origin/main' into feat/web-shell-code-b…
zhangxy-zju Jul 3, 2026
e689175
fix(web-shell): harden custom code block language parsing
zhangxy-zju Jul 3, 2026
dde6c54
fix(web-shell): harden echarts full-data renderer
zhangxy-zju Jul 3, 2026
a8dc8fb
fix(web-shell): polish echarts renderer followups
zhangxy-zju Jul 3, 2026
9464e11
fix(web-shell): reuse enhanced table for chart data
zhangxy-zju Jul 3, 2026
d43ca8f
fix(web-shell): recover chart renderer after errors
zhangxy-zju Jul 3, 2026
9e76749
Merge branch 'main' into feat/web-shell-code-block-renderer
zhangxy-zju Jul 3, 2026
56ed1d3
fix(web-shell): harden chart option handling
zhangxy-zju Jul 3, 2026
e98e20d
fix(web-shell): harden chart data rendering
zhangxy-zju Jul 3, 2026
925da8f
Merge branch 'main' into feat/web-shell-code-block-renderer
zhangxy-zju Jul 3, 2026
89ceb55
fix(web-shell): tighten chart renderer guardrails
zhangxy-zju Jul 3, 2026
66445e2
fix(web-shell): update chart fallback title
zhangxy-zju Jul 3, 2026
1c3f39c
fix(web-shell): polish chart renderer review fixes
zhangxy-zju Jul 3, 2026
52dd5cc
feat(web-shell): support compact echarts full data blocks
zhangxy-zju Jul 3, 2026
e25bc46
docs(web-shell): add chart skill template
zhangxy-zju Jul 3, 2026
ed482f9
fix(web-shell): address chart review suggestions
zhangxy-zju Jul 3, 2026
1514239
fix(web-shell): preserve punctuation language aliases
zhangxy-zju Jul 3, 2026
5e0f44e
fix(web-shell): address chart follow-up review
zhangxy-zju Jul 3, 2026
75202f3
fix(web-shell): harden chart ref resolution
zhangxy-zju Jul 4, 2026
4191476
fix(web-shell): cover chart sanitizer follow-ups
zhangxy-zju Jul 4, 2026
1dffa3b
fix(web-shell): address chart review follow-ups
zhangxy-zju Jul 4, 2026
976e0a5
fix(web-shell): address chart review leftovers
zhangxy-zju Jul 4, 2026
7ba76c3
fix(web-shell): close chart review gaps
zhangxy-zju Jul 4, 2026
31e05f7
fix(web-shell): handle latest chart review
zhangxy-zju Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/core/src/skills/bundled/web-shell-charts/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
name: web-shell-charts
description: Produce Qwen Code Web Shell renderable ECharts code blocks using the echarts-fulldata fenced-code format. Use when the user asks for charts or visual data output that should render directly in Web Shell.
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
when_to_use: When the user asks to create, show, render, or visualize a chart in Qwen Code Web Shell, especially with ECharts or the echarts-fulldata fenced-code format.
---

# Web Shell Charts

Use this skill to emit charts that a Web Shell host can render from Markdown
code blocks.

## Output Contract

Emit a fenced code block with the language tag `echarts-fulldata`:

```echarts-fulldata
const option = {
title: { text: 'Weekly orders' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: [120, 200, 150, 80, 240] }],
};
```

The code block body must define `option` as an Apache ECharts option object.
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
The renderer decides how to load ECharts and execute the block.

## Rules

- Put all chart data inside the code block. Use `dataset.source`, `series.data`,
or other inline option fields. Do not reference local files, URLs, DOM nodes,
global app state, or network requests.
- Keep the block deterministic and side-effect free. Do not use `fetch`,
`import`, timers, random data, `document`, `window`, or filesystem access.
- Prefer plain object/array data. Avoid functions in the option unless the user
explicitly needs custom ECharts callbacks and the host is known to allow them.
- Close the Markdown fence normally so the Web Shell can render after the fence
closes or streaming finishes.
- Use the simplest chart that answers the question. Include clear titles, axis
labels, units, and readable number formats.
- If the data is too large, aggregate or sample it before producing the chart
and explain that reduction outside the code block.
- If a chart cannot be produced safely, explain the reason in normal Markdown
and do not emit an `echarts-fulldata` block.

## Response Shape

For chart answers, use this order:

1. One short takeaway sentence.
2. One `echarts-fulldata` fenced code block containing the complete option.
3. Optional notes about data reduction, assumptions, or how to read the chart.

Do not wrap the chart block in another Markdown container.
310 changes: 309 additions & 1 deletion packages/web-shell/client/components/messages/Markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -334,6 +341,307 @@ describe('Markdown mermaid rendering', () => {
});
});

describe('Markdown custom code block rendering', () => {
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
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: 'light' },
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
isStreaming: true,
}),
),
),
);
});

expect(renderCodeBlock).toHaveBeenCalledWith({
language: 'echarts-fulldata',
className: 'language-echarts-fulldata',
code: 'const option = {};',
isStreaming: true,
source: 'assistant',
theme: 'light',
});
expect(
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
container.querySelector('[data-chart-theme="light"]'),
).not.toBeNull();
expect(container.textContent).toContain(
'assistant:true:const option = {};',
);

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',
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('falls back to the default code block when the host renderer returns null', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => null);

await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```custom-chart\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});

expect(renderCodeBlock).toHaveBeenCalledOnce();
expect(container.textContent).toContain('custom-chart');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);

await act(async () => {
root.unmount();
});
container.remove();
});

it('does not call host renderers when markdown source is omitted', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));

await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
}),
),
);
});

expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);

await act(async () => {
root.unmount();
});
container.remove();
});

it('does not call host renderers for inline code', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));

await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: 'Inline `const x = 1` example.',
source: 'assistant',
}),
),
);
});

expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('code')?.textContent).toBe('const x = 1');

await act(async () => {
root.unmount();
});
container.remove();
});

it('does not call host renderers for unfenced multiline code blocks', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement('div', null, 'custom'));

await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: ' const x = 1;\n const y = 2;',
source: 'assistant',
}),
),
);
});

expect(renderCodeBlock).not.toHaveBeenCalled();
expect(container.querySelector('pre code')?.textContent).toContain(
'const x = 1;',
);

await act(async () => {
root.unmount();
});
container.remove();
});

it('falls back to the default code block when the host renderer throws', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => {
throw new Error('boom');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});

expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block renderer failed:',
expect.any(Error),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});

it('falls back to the default code block when custom rendered content throws', async () => {
function ThrowingChart() {
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
throw new Error('render boom');
}

const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const renderCodeBlock = vi.fn(() => createElement(ThrowingChart));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

try {
await act(async () => {
root.render(
createElement(
WebShellCustomizationProvider,
{ value: { markdown: { renderCodeBlock } } },
createElement(Markdown, {
content: '```echarts-fulldata\nconst option = {};\n```',
source: 'assistant',
}),
),
);
});

expect(renderCodeBlock).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'[web-shell] custom code block renderer failed:',
expect.any(Error),
expect.any(String),
);
expect(container.textContent).toContain('echarts-fulldata');
expect(container.querySelector('pre code')?.textContent).toContain(
'const option = {};',
);
} finally {
errorSpy.mockRestore();
await act(async () => {
root.unmount();
});
container.remove();
}
});
});
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved

describe('Markdown code highlighting while streaming', () => {
Comment thread
zhangxy-zju marked this conversation as resolved.
Resolved
it('keeps streamed code content visible while streaming', async () => {
const container = document.createElement('div');
Expand Down
Loading
Loading