diff --git a/kits/dashboard/app/src/lib/dashboard.js b/kits/dashboard/app/src/lib/dashboard.js
index 678a564..f0e962a 100644
--- a/kits/dashboard/app/src/lib/dashboard.js
+++ b/kits/dashboard/app/src/lib/dashboard.js
@@ -183,9 +183,42 @@ export function chartOption(result, viz, theme) {
// forbidding effective rather than advisory.
option.series = option.series.map(({ data, ...rest }) => rest);
}
+ fitAxes(option, viz.option);
return { option };
}
+/** A value axis's tick: compact from a thousand up (18M, 1.2K), as written below that (0.25, 500). */
+export function axisTick(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return String(v);
+ return formatValue(n, { format: Math.abs(n) >= 1000 ? 'compact' : 'decimal' });
+}
+
+/** Labels that fit the panel. A horizontal bar chart's value axis ended on "18,000,0": ECharts
+ * centres the last label on the axis end, and half of a long label hangs past the grid's right
+ * margin, which containLabel does not count. Each value axis with no formatter of its own gets
+ * compact ticks; a horizontal one also ends its last label at the axis end. The grid holds its
+ * labels unless the chart set the grid itself. Whatever the chart wrote is kept. */
+function fitAxes(option, own) {
+ for (const dim of ['xAxis', 'yAxis']) {
+ if (option[dim] == null) continue;
+ const fit = (axis) => {
+ if (!axis || typeof axis !== 'object') return axis;
+ // ECharts' own defaults: an x axis is a category axis, a y axis a value axis.
+ if ((axis.type ?? (dim === 'yAxis' ? 'value' : 'category')) !== 'value') return axis;
+ const label = { ...(axis.axisLabel || {}) };
+ if (!('formatter' in label)) label.formatter = axisTick;
+ if (dim === 'xAxis' && !('alignMaxLabel' in label)) label.alignMaxLabel = 'right';
+ return { ...axis, axisLabel: label };
+ };
+ option[dim] = Array.isArray(option[dim]) ? option[dim].map(fit) : fit(option[dim]);
+ }
+ if (!(own && 'grid' in own) && option.grid && typeof option.grid === 'object' && !Array.isArray(option.grid)
+ && !('containLabel' in option.grid)) {
+ option.grid = { ...option.grid, containLabel: true };
+ }
+}
+
/** Deep merge for plain objects; arrays and scalars from `over` replace. The theme sets axis and
* grid defaults, and a chart that specifies its own axis means to replace it, not to blend. */
export function mergeOption(base, over) {
diff --git a/kits/dashboard/app/src/lib/dashboard.test.mjs b/kits/dashboard/app/src/lib/dashboard.test.mjs
index 9041f7b..dedf96d 100644
--- a/kits/dashboard/app/src/lib/dashboard.test.mjs
+++ b/kits/dashboard/app/src/lib/dashboard.test.mjs
@@ -8,7 +8,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
- chartOption, formatValue, mergeOption, normalizeLayout, parseDashboard, queriesToRun,
+ axisTick, chartOption, formatValue, mergeOption, normalizeLayout, parseDashboard, queriesToRun,
statValue, tableView, toFile,
} from './dashboard.js';
@@ -181,6 +181,55 @@ test('an array in the override replaces rather than merges', () => {
assert.deepEqual(mergeOption({ color: ['a', 'b'] }, { color: ['c'] }), { color: ['c'] });
});
+// A horizontal bar chart's value axis ended on "18,000,0" (RNA sequences per database, staging,
+// 2026-10-19): ECharts centres the last label on the axis end, and half of a long label hangs past
+// the grid's right margin, which containLabel does not count.
+
+const HBAR = { kind: 'chart', option: { xAxis: { type: 'value' }, yAxis: { type: 'category' },
+ series: [{ type: 'bar', encode: { x: 'revenue', y: 'month' } }] } };
+
+test('a value axis with no formatter of its own gets compact ticks, and a horizontal one ends inside the chart', () => {
+ const { option } = chartOption(RESULT, HBAR, {});
+ assert.equal(option.xAxis.axisLabel.formatter, axisTick);
+ assert.equal(option.xAxis.axisLabel.alignMaxLabel, 'right');
+ assert.equal(option.yAxis.axisLabel, undefined); // a category axis is left alone
+ assert.equal(axisTick(18000000), formatValue(18000000, { format: 'compact' }));
+ assert.ok(axisTick(18000000).length < '18,000,000'.length);
+ assert.equal(axisTick(0.25), '0.25'); // small numbers keep their digits
+ assert.equal(axisTick(500), '500');
+});
+
+test('a vertical value axis gets compact ticks but keeps its labels where they are', () => {
+ const viz = { kind: 'chart', option: { yAxis: { type: 'value' }, series: [{ type: 'line' }] } };
+ const { option } = chartOption(RESULT, viz, { yAxis: { axisLabel: { color: '#888' } } });
+ assert.equal(option.yAxis.axisLabel.formatter, axisTick);
+ assert.equal(option.yAxis.axisLabel.color, '#888');
+ assert.equal(option.yAxis.axisLabel.alignMaxLabel, undefined);
+});
+
+test('a formatter or label alignment the chart wrote is kept', () => {
+ const own = (v) => `${v} seqs`;
+ const viz = { kind: 'chart', option: { xAxis: { type: 'value', axisLabel: { formatter: own, alignMaxLabel: 'center' } },
+ series: [{ type: 'bar' }] } };
+ const { option } = chartOption(RESULT, viz, {});
+ assert.equal(option.xAxis.axisLabel.formatter, own);
+ assert.equal(option.xAxis.axisLabel.alignMaxLabel, 'center');
+});
+
+test('each of several value axes is fitted the same way', () => {
+ const viz = { kind: 'chart', option: { yAxis: [{ type: 'value' }, { type: 'value', axisLabel: { formatter: '{value} %' } }],
+ series: [{ type: 'line' }] } };
+ const { option } = chartOption(RESULT, viz, {});
+ assert.equal(option.yAxis[0].axisLabel.formatter, axisTick);
+ assert.equal(option.yAxis[1].axisLabel.formatter, '{value} %');
+});
+
+test('the grid holds its labels unless the chart set the grid itself', () => {
+ assert.equal(chartOption(RESULT, HBAR, { grid: { left: 8 } }).option.grid.containLabel, true);
+ const viz = { kind: 'chart', option: { grid: { top: 24, containLabel: false }, series: [{ type: 'line' }] } };
+ assert.deepEqual(chartOption(RESULT, viz, { grid: { left: 8 } }).option.grid, { left: 8, top: 24, containLabel: false });
+});
+
test('a chart panel with no option says so instead of drawing an empty canvas', () => {
const { error } = chartOption(RESULT, { kind: 'chart', option: null }, {});
assert.match(error, /no chart definition/);
diff --git a/kits/sheets/app/src/components/HarnessCell.jsx b/kits/sheets/app/src/components/HarnessCell.jsx
index a462677..36c9a9e 100644
--- a/kits/sheets/app/src/components/HarnessCell.jsx
+++ b/kits/sheets/app/src/components/HarnessCell.jsx
@@ -7,9 +7,21 @@
// the server exposes no queue — past its concurrency limit turns are accepted and block silently,
// indistinguishable over the API from working ones — so a progress bar inside one cell would be
// invented. A spinner is the truth.
+import { Fragment } from 'react';
import { Play } from 'lucide-react';
import { FileCard } from 'reifyui';
import { containerFileUrl } from 'reifyui/harness';
+import { linkParts } from '../lib/answer';
+
+/** The answer's text with its inline citations drawn as links named by their text ("qdrant.tech"),
+ * never the brackets and the address. A click opens the page in a new tab and does not select or
+ * open the cell under it. */
+function AnswerText({ value }) {
+ return linkParts(value).map((p, i) => (p.href
+ ? e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>{p.text}
+ : {p.text}));
+}
/** The files a cell produced, as the same card the chat draws — type glyph, name, download,
* preview. They were a row of green text chips: no glyph, no download, and no way to look inside
@@ -84,7 +96,7 @@ export function HarnessCell({ cell, live, readOnly, onRun, onPreviewFile }) {
}
return (
- {cell.value}
+
{tools}
diff --git a/kits/sheets/app/src/lib/answer.js b/kits/sheets/app/src/lib/answer.js
index c7e2cc8..2200a3a 100644
--- a/kits/sheets/app/src/lib/answer.js
+++ b/kits/sheets/app/src/lib/answer.js
@@ -95,3 +95,30 @@ export function cellAnswer(response) {
export function settledAnswer(response) {
return endsOnToolStep(response) ? '' : cellAnswer(response);
}
+
+// An inline Markdown link: [text](address), the address allowed one level of balanced parentheses
+// (Wikipedia's "Rust_(programming_language)").
+const MD_LINK = /\[([^[\]\n]+)\]\(((?:[^\s()<>]|\([^\s()<>]*\))+)\)/g;
+
+/** A cell's value as the grid draws it: text parts, and a link part for each inline Markdown link
+ * whose address is http or https, named by the link's own text. The value itself is unchanged
+ * (it keeps its citations, and the export carries them as written); this is only how it reads.
+ * Any other address (javascript:, mailto:, a relative path) stays text, exactly as written. */
+export function linkParts(value) {
+ const text = String(value ?? '');
+ const parts = [];
+ let at = 0;
+ for (const m of text.matchAll(MD_LINK)) {
+ let href = '';
+ try {
+ const u = new URL(m[2]);
+ if (u.protocol === 'http:' || u.protocol === 'https:') href = m[2];
+ } catch { /* not an address: stays text */ }
+ if (!href) continue;
+ if (m.index > at) parts.push({ text: text.slice(at, m.index) });
+ parts.push({ text: m[1], href });
+ at = m.index + m[0].length;
+ }
+ if (at < text.length) parts.push({ text: text.slice(at) });
+ return parts;
+}
diff --git a/kits/sheets/app/src/lib/answer.test.mjs b/kits/sheets/app/src/lib/answer.test.mjs
index 8a91f64..4b9a52c 100644
--- a/kits/sheets/app/src/lib/answer.test.mjs
+++ b/kits/sheets/app/src/lib/answer.test.mjs
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
- cellAnswer, endsOnToolStep, lastAssistantText, settledAnswer, stripTracking,
+ cellAnswer, endsOnToolStep, lastAssistantText, linkParts, settledAnswer, stripTracking,
} from './answer.js';
const msg = (text, annotations = []) =>
@@ -110,3 +110,35 @@ test('the cell value is the answer with its links cleaned', () => {
] };
assert.equal(cellAnswer(res), 'Open source, Apache 2.0 ([github.com](https://github.com/qdrant/qdrant)).');
});
+
+// ── a cell's links ───────────────────────────────────────────────────────────
+// The value keeps its inline citations (cellAnswer above); the grid draws each as a link named by
+// its text. Shown raw it read "Zero-ops hybrid retrieval for AI apps ([docs.trychroma.com]…" (staging,
+// 2026-10-19).
+
+test('an inline Markdown link becomes a link part named by its text; the text around it stays', () => {
+ assert.deepEqual(
+ linkParts('Hybrid search for RAG workloads. ([qdrant.tech](https://qdrant.tech/documentation/search/))'),
+ [{ text: 'Hybrid search for RAG workloads. (' },
+ { text: 'qdrant.tech', href: 'https://qdrant.tech/documentation/search/' },
+ { text: ')' }]);
+});
+
+test('a link whose address is not http or https stays text, exactly as written', () => {
+ assert.deepEqual(linkParts('[click](javascript:alert(1))'), [{ text: '[click](javascript:alert(1))' }]);
+ assert.deepEqual(linkParts('Write to [us](mailto:team@example.com).'), [{ text: 'Write to [us](mailto:team@example.com).' }]);
+});
+
+test('plain text is one text part, and an empty value is no parts', () => {
+ assert.deepEqual(linkParts('Best at production-grade semantic search.'), [{ text: 'Best at production-grade semantic search.' }]);
+ assert.deepEqual(linkParts(''), []);
+ assert.deepEqual(linkParts(null), []);
+});
+
+test('an address with balanced parentheses keeps them, and two links in a row are two parts', () => {
+ assert.deepEqual(
+ linkParts('[Rust](https://en.wikipedia.org/wiki/Rust_(programming_language)) and [docs](https://doc.rust-lang.org)'),
+ [{ text: 'Rust', href: 'https://en.wikipedia.org/wiki/Rust_(programming_language)' },
+ { text: ' and ' },
+ { text: 'docs', href: 'https://doc.rust-lang.org' }]);
+});
diff --git a/kits/sheets/app/src/styles/app.css b/kits/sheets/app/src/styles/app.css
index 967bb09..7592726 100644
--- a/kits/sheets/app/src/styles/app.css
+++ b/kits/sheets/app/src/styles/app.css
@@ -119,6 +119,8 @@
display: -webkit-box; -webkit-box-orient: vertical;
-webkit-line-clamp: var(--shg-clamp, 1); line-clamp: var(--shg-clamp, 1);
}
+.hcell-link { font-size: 12px; color: var(--brand-ink); text-decoration: none; }
+.hcell-link:hover { text-decoration: underline; }
.hcell.failed .hcell-txt { color: #B91C1C; }
.hcell.skipped .hcell-txt { color: var(--mute-2); font-style: italic; }
.hcell-live { color: var(--mute); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }