feat(viewer): artifact modal renders structured text, never raw JSON

ArtifactView's fallback (StructuredValue) turns any object/array into labelled
sections, bullet lists and paragraphs (strings as markdown). Verified against the
real F1 gate (kind:"text", {recap, chiarimenti_risolti[], ...}). MarkdownView
uses the real .thot-prose class (Tailwind Typography is not installed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:15:48 +02:00
co-authored by Claude Opus 4.8
parent cec6620c00
commit a719ad5990
3 changed files with 66 additions and 8 deletions
+17 -3
View File
@@ -26,9 +26,23 @@ test("question renders markdown headings", () => {
expect(screen.getByText("Domanda")).toBeInTheDocument();
});
test("unknown kind falls back to formatted JSON", () => {
render(<ArtifactView artifact={{ kind: "mystery", data: { a: 1 } }} />);
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
test("unknown object kind renders as formatted sections, never raw JSON", () => {
render(
<ArtifactView
artifact={{
kind: "phase",
data: {
recap: "Domanda: lista pazienti.",
chiarimenti_risolti: ["finestra_temporale — 5 anni", "tipo_evento — solo impianto"],
},
}}
/>,
);
// Humanized section labels, values as text/bullets — and NO raw JSON braces.
expect(screen.getByText("Recap")).toBeInTheDocument();
expect(screen.getByText("Chiarimenti risolti")).toBeInTheDocument();
expect(screen.getByText("finestra_temporale — 5 anni")).toBeInTheDocument();
expect(screen.queryByText(/"recap"/)).not.toBeInTheDocument();
});
test("schema_linking renders the schema viewer", async () => {
+48 -4
View File
@@ -69,10 +69,53 @@ function cteNames(raw: unknown): string[] | null {
return null;
}
function JsonFallback({ value }: { value: unknown }): ReactElement {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
// "recap" -> "Recap", "chiarimenti_risolti" -> "Chiarimenti risolti"
function humanizeKey(k: string): string {
const spaced = k.replace(/[_-]+/g, " ").trim();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/** Renders any artifact payload as readable text — NEVER raw JSON. Objects become
* labelled sections, arrays become bullet lists, primitives become paragraphs;
* strings render as markdown. Nested structures recurse. */
function StructuredValue({ value }: { value: unknown }): ReactElement {
if (value === null || value === undefined || value === "") {
return <p className="text-sm text-muted-foreground">—</p>;
}
if (typeof value === "string") return <MarkdownView source={value} />;
if (typeof value === "number" || typeof value === "boolean") {
return <p className="text-sm leading-relaxed text-foreground/90">{String(value)}</p>;
}
if (Array.isArray(value)) {
const allPrimitive = value.every((v) => v === null || typeof v !== "object");
if (allPrimitive) {
return (
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-foreground/90 marker:text-muted-foreground">
{value.map((item, i) => <li key={i}>{String(item)}</li>)}
</ul>
);
}
return (
<div className="flex flex-col gap-3">
{value.map((item, i) => (
<div key={i} className="rounded-lg border border-border/60 p-3">
<StructuredValue value={item} />
</div>
))}
</div>
);
}
return (
<pre className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-sm">{text}</pre>
<div className="flex flex-col gap-4">
{Object.entries(value as Record<string, unknown>).map(([k, v]) => (
<section key={k}>
<h4 className="mb-1.5 text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{humanizeKey(k)}
</h4>
<StructuredValue value={v} />
</section>
))}
</div>
);
}
@@ -104,5 +147,6 @@ export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactEle
const md = toMarkdown(data);
if (md !== null) return <MarkdownView source={md} />;
}
return <JsonFallback value={data} />;
// Fallback for any other kind / shape: formatted text, never raw JSON.
return <StructuredValue value={data} />;
}
+1 -1
View File
@@ -42,7 +42,7 @@ const components: Components = {
export function MarkdownView({ source }: { source: string }) {
return (
<div className="markdown-view overflow-auto max-h-[60vh] prose prose-sm dark:prose-invert p-3">
<div className="markdown-view thot-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{source}
</ReactMarkdown>