Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 2 additions & 4 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1554,9 +1554,7 @@ export function createMarkdownComponents(
language = extractLanguage(child.props.className);
}
});
return (
<MarkdownCodeBlock language={language}>{children}</MarkdownCodeBlock>
);
return <MarkdownFence language={language}>{children}</MarkdownFence>;
},
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/shared/ui/markdown/widgets/MarkdownFence.tsx
Original file line number Diff line number Diff line change
@@ -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 <WidgetView widget={parsed.widget} />;
}
return <MarkdownCodeBlock language={language}>{children}</MarkdownCodeBlock>;
}
119 changes: 119 additions & 0 deletions desktop/src/shared/ui/markdown/widgets/WidgetView.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
useSmoothCorners(frameRef);

return (
<div
ref={frameRef}
className="overflow-x-auto rounded-2xl border border-border/70"
data-widget-block=""
>
{children}
{caption && (
<div className="border-t border-border/70 px-3 py-1.5 text-xs text-muted-foreground/70">
{caption}
</div>
)}
</div>
);
}

function TableWidgetView({ widget }: { widget: TableWidget }) {
return (
<WidgetFrame caption={widget.caption}>
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr>
{widget.columns.map((column, i) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity
key={`${i}:${column}`}
className="border-b border-border/70 px-3 py-1.5 font-semibold"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{widget.rows.map((row, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity
<tr key={`${r}:${row.join("\u0000")}`}>
{row.map((cell, c) => (
<td
// biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity
key={`${c}:${cell}`}
className="border-b border-border/40 px-3 py-1.5"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</WidgetFrame>
);
}

function MetricWidgetView({ widget }: { widget: MetricWidget }) {
return (
<WidgetFrame caption={widget.caption}>
<div className="flex flex-wrap gap-x-6 gap-y-3 px-3 py-2.5">
{widget.metrics.map((metric, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: agent data has no stable identity
<div key={`${i}:${metric.label}`} className="min-w-24">
<div className="text-xs text-muted-foreground/70">
{metric.label}
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">
{metric.value}
</span>
{metric.unit && (
<span className="text-xs text-muted-foreground/70">
{metric.unit}
</span>
)}
{metric.delta && (
<span className="text-xs tabular-nums text-muted-foreground">
{metric.delta}
</span>
)}
</div>
</div>
))}
</div>
</WidgetFrame>
);
}

/** Render a validated widget. Unknown types are unreachable — `parseWidget` gates them. */
export function WidgetView({ widget }: { widget: Widget }) {
switch (widget.type) {
case "table":
return <TableWidgetView widget={widget} />;
case "metric":
return <MetricWidgetView widget={widget} />;
}
}
31 changes: 31 additions & 0 deletions desktop/src/shared/ui/markdown/widgets/fenceText.test.mjs
Original file line number Diff line number Diff line change
@@ -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}');
});
35 changes: 35 additions & 0 deletions desktop/src/shared/ui/markdown/widgets/fenceText.ts
Original file line number Diff line number Diff line change
@@ -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 `<code>` 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;
}
98 changes: 98 additions & 0 deletions desktop/src/shared/ui/markdown/widgets/render.test.mjs
Original file line number Diff line number Diff line change
@@ -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, /<pre>/);
});

test("e2e: an unknown widget type degrades to a readable code block", () => {
const html = renderDoc(fence('{"v":1,"type":"kanban"}'));
assert.match(html, /<pre>/);
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, /<pre>/);
});

test("e2e: an ordinary code fence is untouched", () => {
const html = renderDoc("```js\nconst a = 1;\n```");
assert.match(html, /<pre>/);
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":[["<img src=x onerror=alert(1)>"]]}',
),
);
assert.match(html, /data-widget-block/);
// No live tag reaches the DOM — it survives only as escaped text.
assert.doesNotMatch(html, /<img/);
assert.match(html, /&lt;img/);
});

test("e2e: a script payload inside a widget fence never becomes a tag", () => {
const html = renderDoc(
fence(
'{"v":1,"type":"table","columns":["c"],"rows":[["<script>alert(1)</script>"]]}',
),
);
assert.doesNotMatch(html, /<script/);
});
Loading
Loading