diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 747a5de6776..b77c42fc5d5 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -48,10 +48,10 @@ import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls" import { CODE_BLOCK_CLASS, extractLanguage, - MarkdownCodeBlock, SyntaxHighlightedCode, } from "./markdown/CodeBlock"; import { EntityLinkAnchor, useOpenEntityLink } from "./markdown/entityLinks"; +import { MarkdownFence } from "./markdown/widgets/MarkdownFence"; import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; import { @@ -1554,9 +1554,7 @@ export function createMarkdownComponents( language = extractLanguage(child.props.className); } }); - return ( - {children} - ); + return {children}; }, strong: ({ children }) => ( {children} diff --git a/desktop/src/shared/ui/markdown/widgets/MarkdownFence.tsx b/desktop/src/shared/ui/markdown/widgets/MarkdownFence.tsx new file mode 100644 index 00000000000..7756f4f49a3 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/MarkdownFence.tsx @@ -0,0 +1,31 @@ +import type * as React from "react"; + +import { MarkdownCodeBlock } from "../CodeBlock"; +import { widgetFenceText } from "./fenceText"; +import { parseWidget, WIDGET_FENCE_LANGUAGE } from "./schema"; +import { WidgetView } from "./WidgetView"; + +/** + * Render one fenced block: a widget when the fence declares the widget + * language and its payload parses, otherwise the ordinary code block. + * + * Invalid payloads fall through to a plain code block on purpose: a malformed + * or unknown widget must stay readable, never blank. + * + * This lives beside the widget code rather than inside `markdown.tsx` so the + * fence-dispatch rule has one home, and the widget feature does not grow an + * already-oversized module (desktop file-size ratchet). + */ +export function MarkdownFence({ + language, + children, +}: { + language?: string; + children?: React.ReactNode; +}) { + if (language === WIDGET_FENCE_LANGUAGE) { + const parsed = parseWidget(widgetFenceText(children)); + if (parsed.ok) return ; + } + return {children}; +} diff --git a/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx b/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx new file mode 100644 index 00000000000..b6a81c142f6 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/WidgetView.tsx @@ -0,0 +1,119 @@ +import * as React from "react"; + +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; + +import type { MetricWidget, TableWidget, Widget } from "./schema"; + +/** + * Read-only widget renderers (AGENT-WIDGETS-001, PR-1). + * + * Every value below arrives from an agent and is rendered as text through JSX, + * so React escapes it. Nothing here uses `dangerouslySetInnerHTML`, and no + * value reaches an HTML parser. + */ + +function WidgetFrame({ + caption, + children, +}: { + caption?: string; + children: React.ReactNode; +}) { + const frameRef = React.useRef(null); + useSmoothCorners(frameRef); + + return ( +
+ {children} + {caption && ( +
+ {caption} +
+ )} +
+ ); +} + +function TableWidgetView({ widget }: { widget: TableWidget }) { + return ( + + + + + {widget.columns.map((column, i) => ( + + ))} + + + + {widget.rows.map((row, r) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity + + {row.map((cell, c) => ( + + ))} + + ))} + +
+ {column} +
+ {cell} +
+
+ ); +} + +function MetricWidgetView({ widget }: { widget: MetricWidget }) { + return ( + +
+ {widget.metrics.map((metric, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity +
+
+ {metric.label} +
+
+ + {metric.value} + + {metric.unit && ( + + {metric.unit} + + )} + {metric.delta && ( + + {metric.delta} + + )} +
+
+ ))} +
+
+ ); +} + +/** Render a validated widget. Unknown types are unreachable — `parseWidget` gates them. */ +export function WidgetView({ widget }: { widget: Widget }) { + switch (widget.type) { + case "table": + return ; + case "metric": + return ; + } +} diff --git a/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs b/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs new file mode 100644 index 00000000000..41b15424f67 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React from "react"; + +import { widgetFenceText } from "./fenceText.ts"; + +const code = (children) => React.createElement("code", {}, children); + +test("widgetFenceText: reads a single string child", () => { + assert.equal(widgetFenceText(code('{"v":1}')), '{"v":1}'); +}); + +test("widgetFenceText: rejoins a fragmented fence body", () => { + assert.equal( + widgetFenceText(code(['{"v":1,', '"type":', '"table"}'])), + '{"v":1,"type":"table"}', + ); +}); + +test("widgetFenceText: descends through nested syntax elements", () => { + const nested = code([React.createElement("span", {}, '{"v":'), "1}"]); + assert.equal(widgetFenceText(nested), '{"v":1}'); +}); + +test("widgetFenceText: ignores null and boolean leaves", () => { + assert.equal(widgetFenceText(code(["a", null, false, "b"])), "ab"); +}); + +test("widgetFenceText: preserves newlines and whitespace exactly", () => { + assert.equal(widgetFenceText(code(['{\n "v": 1\n}'])), '{\n "v": 1\n}'); +}); diff --git a/desktop/src/shared/ui/markdown/widgets/fenceText.ts b/desktop/src/shared/ui/markdown/widgets/fenceText.ts new file mode 100644 index 00000000000..9427b5283fe --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/fenceText.ts @@ -0,0 +1,35 @@ +import * as React from "react"; + +/** + * Recover the raw text of a fenced code block from its rendered children. + * + * `react-markdown` hands the `pre` handler a `` element whose children + * are the fence body, split into an arbitrary number of string nodes (syntax + * plugins may fragment it further). Widgets need the exact original text to + * parse as JSON, so this walks the tree and concatenates every string leaf. + * + * Non-string leaves are ignored rather than coerced: a fence containing real + * elements is not a valid payload, and `JSON.parse` will reject the remainder. + */ +export function widgetFenceText(children: React.ReactNode): string { + let out = ""; + const walk = (node: React.ReactNode): void => { + if (typeof node === "string") { + out += node; + return; + } + if (typeof node === "number") { + out += String(node); + return; + } + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + walk(node.props?.children); + } + }; + walk(children); + return out; +} diff --git a/desktop/src/shared/ui/markdown/widgets/render.test.mjs b/desktop/src/shared/ui/markdown/widgets/render.test.mjs new file mode 100644 index 00000000000..3cd77840f76 --- /dev/null +++ b/desktop/src/shared/ui/markdown/widgets/render.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; + +import { widgetFenceText } from "./fenceText.ts"; +import { parseWidget, WIDGET_FENCE_LANGUAGE } from "./schema.ts"; + +// Mirrors the `pre` handler in markdown.tsx: classify the fence, render a +// widget when the payload validates, otherwise fall back to a code block. +function renderDoc(markdown) { + return renderToStaticMarkup( + React.createElement( + ReactMarkdown, + { + components: { + pre: ({ children }) => { + let language = ""; + React.Children.forEach(children, (child) => { + if ( + React.isValidElement(child) && + typeof child.props?.className === "string" + ) { + const m = child.props.className.match(/language-(\S+)/); + language = m ? m[1] : ""; + } + }); + if (language === WIDGET_FENCE_LANGUAGE) { + const parsed = parseWidget(widgetFenceText(children)); + if (parsed.ok) { + return React.createElement( + "div", + { "data-widget-block": "", "data-type": parsed.widget.type }, + JSON.stringify(parsed.widget.rows ?? parsed.widget.metrics), + ); + } + } + return React.createElement("pre", {}, children); + }, + }, + }, + markdown, + ), + ); +} + +const fence = (body) => + `\u0060\u0060\u0060${WIDGET_FENCE_LANGUAGE}\n${body}\n\u0060\u0060\u0060`; + +test("e2e: a valid table payload renders as a widget, not a code block", () => { + const html = renderDoc( + fence('{"v":1,"type":"table","columns":["PR"],"rows":[["#15"]]}'), + ); + assert.match(html, /data-widget-block/); + assert.match(html, /data-type="table"/); + assert.doesNotMatch(html, /
/);
+});
+
+test("e2e: an unknown widget type degrades to a readable code block", () => {
+  const html = renderDoc(fence('{"v":1,"type":"kanban"}'));
+  assert.match(html, /
/);
+  assert.doesNotMatch(html, /data-widget-block/);
+  // The payload stays visible to the user rather than vanishing.
+  assert.match(html, /kanban/);
+});
+
+test("e2e: malformed JSON degrades to a code block", () => {
+  const html = renderDoc(fence("{not json"));
+  assert.match(html, /
/);
+});
+
+test("e2e: an ordinary code fence is untouched", () => {
+  const html = renderDoc("```js\nconst a = 1;\n```");
+  assert.match(html, /
/);
+  assert.doesNotMatch(html, /data-widget-block/);
+});
+
+test("e2e: hostile cell content is escaped in the rendered output", () => {
+  const html = renderDoc(
+    fence(
+      '{"v":1,"type":"table","columns":["c"],"rows":[[""]]}',
+    ),
+  );
+  assert.match(html, /data-widget-block/);
+  // No live tag reaches the DOM — it survives only as escaped text.
+  assert.doesNotMatch(html, / {
+  const html = renderDoc(
+    fence(
+      '{"v":1,"type":"table","columns":["c"],"rows":[[""]]}',
+    ),
+  );
+  assert.doesNotMatch(html, /