diff --git a/frontend/src/viewers/ArtifactView.test.tsx b/frontend/src/viewers/ArtifactView.test.tsx
new file mode 100644
index 00000000..03342f4c
--- /dev/null
+++ b/frontend/src/viewers/ArtifactView.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from "@testing-library/react";
+import { ArtifactView } from "./ArtifactView";
+
+vi.mock("./mermaid", () => ({
+ renderMermaid: vi.fn().mockResolvedValue(''),
+}));
+
+test("sql artifact renders the SQL text", () => {
+ render();
+ expect(screen.getByText("SELECT 1")).toBeInTheDocument();
+});
+
+test("cte_plan renders an ordered list of names", () => {
+ render();
+ expect(screen.getByText("a_cte")).toBeInTheDocument();
+ expect(screen.getByText("b_cte")).toBeInTheDocument();
+});
+
+test("question renders markdown headings", () => {
+ render();
+ expect(screen.getByText("Domanda")).toBeInTheDocument();
+});
+
+test("unknown kind falls back to formatted JSON", () => {
+ render();
+ expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
+});
+
+test("schema_linking renders the schema viewer", async () => {
+ render(
+ ,
+ );
+ expect(await screen.findByTestId("mm")).toBeInTheDocument();
+});
diff --git a/frontend/src/viewers/ArtifactView.tsx b/frontend/src/viewers/ArtifactView.tsx
new file mode 100644
index 00000000..d1b00643
--- /dev/null
+++ b/frontend/src/viewers/ArtifactView.tsx
@@ -0,0 +1,107 @@
+import type { ReactElement } from "react";
+import { SqlViewer, type SqlBlock } from "./SqlViewer";
+import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
+import { MarkdownView } from "./MarkdownView";
+
+type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
+
+function asRecord(v: unknown): Record | null {
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null;
+}
+
+// The payload lives in `data` (harness contract); fall back to `content`, then the
+// artifact object itself, so older/other producers still render something.
+function payload(artifact: ArtifactData): unknown {
+ if (artifact.data !== undefined) return artifact.data;
+ if (artifact.content !== undefined) return artifact.content;
+ return artifact;
+}
+
+function toSchemaLinking(raw: unknown): SchemaLinking | null {
+ let obj: unknown = raw;
+ if (typeof raw === "string") {
+ try { obj = JSON.parse(raw); } catch { return null; }
+ }
+ const rec = asRecord(obj);
+ if (!rec || !Array.isArray(rec.candidates)) return null;
+ return {
+ candidates: rec.candidates as SchemaLinking["candidates"],
+ joins: Array.isArray(rec.joins) ? (rec.joins as SchemaLinking["joins"]) : [],
+ excluded: Array.isArray(rec.excluded) ? (rec.excluded as SchemaLinking["excluded"]) : [],
+ open_questions: Array.isArray(rec.open_questions) ? (rec.open_questions as string[]) : [],
+ question: typeof rec.question === "string" ? rec.question : undefined,
+ };
+}
+
+function toSqlBlocks(raw: unknown): SqlBlock[] | null {
+ if (typeof raw === "string") return [{ name: "SQL", sql: raw }];
+ const rec = asRecord(raw);
+ if (!rec) return null;
+ if (typeof rec.sql === "string") return [{ name: "SQL", sql: rec.sql }];
+ if (Array.isArray(rec.ctes)) {
+ const blocks = rec.ctes
+ .map((c) => asRecord(c))
+ .filter((c): c is Record => !!c && typeof c.sql === "string")
+ .map((c) => ({ name: typeof c.name === "string" ? c.name : "cte", sql: c.sql as string }));
+ if (typeof rec.final === "string") blocks.push({ name: "final", sql: rec.final });
+ return blocks.length ? blocks : null;
+ }
+ return null;
+}
+
+function toMarkdown(raw: unknown): string | null {
+ if (typeof raw === "string") return raw;
+ const rec = asRecord(raw);
+ if (!rec) return null;
+ for (const k of ["markdown", "question", "text"]) {
+ if (typeof rec[k] === "string") return rec[k] as string;
+ }
+ return null;
+}
+
+function cteNames(raw: unknown): string[] | null {
+ if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
+ const rec = asRecord(raw);
+ if (rec && Array.isArray(rec.names) && rec.names.every((x) => typeof x === "string")) {
+ return rec.names as string[];
+ }
+ return null;
+}
+
+function JsonFallback({ value }: { value: unknown }): ReactElement {
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ return (
+ {text}
+ );
+}
+
+export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactElement {
+ const kind = artifact.kind ?? "";
+ const data = payload(artifact);
+
+ if (kind === "schema_linking") {
+ const linking = toSchemaLinking(data);
+ if (linking) return ;
+ }
+ if (kind === "sql" || kind === "cte_result") {
+ const blocks = toSqlBlocks(data);
+ if (blocks) return ;
+ }
+ if (kind === "cte_plan") {
+ const names = cteNames(data);
+ if (names) {
+ return (
+
+ {names.map((n, i) => (
+ - {n}
+ ))}
+
+ );
+ }
+ }
+ if (kind === "question" || kind === "phase") {
+ const md = toMarkdown(data);
+ if (md !== null) return ;
+ }
+ return ;
+}