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:
@@ -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 () => {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user