feat(frontend): v2 artifact viewers for cte_plan/cte_result/phase

Add CtePlanViewer, CteResultViewer, and PhaseSummaryViewer to render the
structured v2 payloads (schema_version: 2) the harness now emits for
artifact-gate widgets, per contracts.md. Extract PreviewGrid from
ResultsPanel as a reusable AG Grid component shared by CteResultViewer.
ArtifactView dispatches to the new viewers on a schema_version/shape
guard, falling back to the existing legacy renderers unchanged for
older sessions and replay fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:57:20 +02:00
co-authored by Claude Fable 5
parent 3ad93cd02f
commit 1c97289ed8
12 changed files with 937 additions and 26 deletions
+90 -2
View File
@@ -5,14 +5,20 @@ vi.mock("./mermaid", () => ({
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
}));
test("sql artifact renders the SQL text", () => {
vi.mock("./highlight", () => ({
highlightSql: vi.fn().mockResolvedValue('<pre data-testid="hl">SQL</pre>'),
}));
test("sql artifact renders the SQL text", async () => {
render(<ArtifactView artifact={{ kind: "sql", data: "SELECT 1" }} />);
expect(screen.getByText("SELECT 1")).toBeInTheDocument();
await screen.findByTestId("hl");
});
test("sql artifact accepts a { content } wrapper", () => {
test("sql artifact accepts a { content } wrapper", async () => {
render(<ArtifactView artifact={{ kind: "sql", data: { content: "SELECT 7" } }} />);
expect(screen.getByText("SELECT 7")).toBeInTheDocument();
await screen.findByTestId("hl");
});
test("cte_plan renders an ordered list of names", () => {
@@ -56,3 +62,85 @@ test("schema_linking renders the schema viewer", async () => {
);
expect(await screen.findByTestId("mm")).toBeInTheDocument();
});
test("cte_plan v2 dispatches to CtePlanViewer", () => {
render(
<ArtifactView
artifact={{
kind: "cte_plan",
data: {
schema_version: 2,
strategy: "Filtriamo prima i pazienti idonei.",
ctes: [{ name: "pazienti_idonei", index: 1, purpose: "Seleziona pazienti" }],
},
}}
/>,
);
expect(screen.getByText("Filtriamo prima i pazienti idonei.")).toBeInTheDocument();
expect(screen.getByText("CTE 1/1")).toBeInTheDocument();
});
test("cte_plan v1 (legacy names list) still renders via the legacy path", () => {
render(<ArtifactView artifact={{ kind: "cte_plan", data: { names: ["a_cte", "b_cte"] } }} />);
expect(screen.getByText("a_cte")).toBeInTheDocument();
expect(screen.getByText("b_cte")).toBeInTheDocument();
});
test("cte_result v2 dispatches to CteResultViewer", async () => {
render(
<ArtifactView
artifact={{
kind: "cte_result",
data: {
schema_version: 2,
name: "pazienti_idonei",
index: 1,
total: 1,
sql: "SELECT 1",
status: "ok",
},
}}
/>,
);
await screen.findByTestId("hl");
expect(screen.getByText("CTE 1/1")).toBeInTheDocument();
});
test("cte_result v1 (bare sql string) still renders via toSqlBlocks legacy path", async () => {
render(<ArtifactView artifact={{ kind: "cte_result", data: "SELECT 42" }} />);
await screen.findByTestId("hl");
expect(screen.queryByText("CTE")).not.toBeInTheDocument();
});
test("phase v2 dispatches to PhaseSummaryViewer", () => {
render(
<ArtifactView
artifact={{
kind: "phase",
data: {
schema_version: 2,
phase: { id: "F5", num: 5, name: "sintesi" },
summary: "Riassunto della fase.",
},
}}
/>,
);
expect(screen.getByText("F5 — sintesi")).toBeInTheDocument();
expect(screen.getByText("Riassunto della fase.")).toBeInTheDocument();
});
test("phase v1 (no schema_version) still renders via legacy markdown/structured path", () => {
render(
<ArtifactView
artifact={{
kind: "phase",
data: {
recap: "Domanda: lista pazienti.",
chiarimenti_risolti: ["finestra_temporale — 5 anni"],
},
}}
/>,
);
expect(screen.getByText("Recap")).toBeInTheDocument();
expect(screen.getByText("Chiarimenti risolti")).toBeInTheDocument();
});
+34
View File
@@ -2,6 +2,10 @@ import type { ReactElement } from "react";
import { SqlViewer, type SqlBlock } from "./SqlViewer";
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
import { MarkdownView } from "./MarkdownView";
import { CtePlanViewer } from "./CtePlanViewer";
import { CteResultViewer } from "./CteResultViewer";
import { PhaseSummaryViewer } from "./PhaseSummaryViewer";
import type { CtePlanV2, CteResultV2, PhaseSummaryV2 } from "./artifactV2";
type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
@@ -60,6 +64,26 @@ function toMarkdown(raw: unknown): string | null {
return null;
}
function toCtePlanV2(raw: unknown): CtePlanV2 | null {
const rec = asRecord(raw);
if (!rec || rec.schema_version !== 2 || !Array.isArray(rec.ctes)) return null;
return rec as unknown as CtePlanV2;
}
function toCteResultV2(raw: unknown): CteResultV2 | null {
const rec = asRecord(raw);
if (!rec || rec.schema_version !== 2 || typeof rec.sql !== "string" || typeof rec.name !== "string") {
return null;
}
return rec as unknown as CteResultV2;
}
function toPhaseSummaryV2(raw: unknown): PhaseSummaryV2 | null {
const rec = asRecord(raw);
if (!rec || rec.schema_version !== 2) return null;
return rec as unknown as PhaseSummaryV2;
}
function cteNames(raw: unknown): string[] | null {
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
const rec = asRecord(raw);
@@ -140,11 +164,17 @@ export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactEle
const linking = toSchemaLinking(data);
if (linking) return <SchemaLinkingViewer linking={linking} />;
}
if (kind === "cte_result") {
const resultV2 = toCteResultV2(data);
if (resultV2) return <CteResultViewer result={resultV2} />;
}
if (kind === "sql" || kind === "cte_result") {
const blocks = toSqlBlocks(data);
if (blocks) return <SqlViewer blocks={blocks} />;
}
if (kind === "cte_plan") {
const planV2 = toCtePlanV2(data);
if (planV2) return <CtePlanViewer plan={planV2} />;
const names = cteNames(data);
if (names) {
return (
@@ -156,6 +186,10 @@ export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactEle
);
}
}
if (kind === "phase") {
const phaseV2 = toPhaseSummaryV2(data);
if (phaseV2) return <PhaseSummaryViewer phase={phaseV2} />;
}
if (kind === "question" || kind === "phase") {
const md = toMarkdown(data);
if (md !== null) return <MarkdownView source={md} />;
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { CtePlanViewer } from "./CtePlanViewer";
import type { CtePlanV2 } from "./artifactV2";
const plan: CtePlanV2 = {
schema_version: 2,
question: "Quanti pazienti hanno avuto un impianto negli ultimi 5 anni?",
strategy: "Filtriamo prima i pazienti idonei, poi joiniamo gli impianti.",
ctes: [
{
name: "pazienti_idonei",
index: 1,
purpose: "Seleziona i pazienti con follow-up attivo",
rationale: "Base della catena: riduce il volume prima dei join",
depends_on: [],
tables: [{ name: "pazienti", description: "Anagrafica pazienti" }],
keys: ["paziente_id"],
filters: [
{
column: "pazienti.stato",
op: "IS",
value: "TRUE",
description: "Flag attivo",
rationale: "Solo pazienti in follow-up",
},
],
output_columns: ["paziente_id", "data_nascita"],
},
{
name: "impianti_recenti",
index: 2,
purpose: "Filtra gli impianti recenti",
depends_on: ["pazienti_idonei"],
tables: [{ name: "impianti" }],
output_columns: ["impianto_id"],
},
],
};
test("renders strategy, question, and a chain strip with CTE names in order", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText(plan.question!)).toBeInTheDocument();
expect(screen.getByText(plan.strategy!)).toBeInTheDocument();
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
});
test("renders one card per CTE with index badge, purpose, and filter value", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText("CTE 1/2")).toBeInTheDocument();
expect(screen.getByText("CTE 2/2")).toBeInTheDocument();
expect(screen.getByText("Seleziona i pazienti con follow-up attivo")).toBeInTheDocument();
expect(screen.getByText(/pazienti\.stato/)).toBeInTheDocument();
expect(screen.getByText(/TRUE/)).toBeInTheDocument();
});
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
});
test("renders depends_on chips for a CTE that has dependencies", () => {
render(<CtePlanViewer plan={plan} />);
// "pazienti_idonei" appears both as chain-strip entry and as a depends_on chip
// for impianti_recenti — assert at least one occurrence beyond the strip.
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(1);
});
test("omits sections for missing optional fields without rendering 'undefined'", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
// impianti_recenti has no rationale/keys/filters — no crash, no stray text
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
});
+134
View File
@@ -0,0 +1,134 @@
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
function Chip({ children }: { children: ReactNode }) {
return <span className={CHIP}>{children}</span>;
}
function FilterChip({ filter }: { filter: CtePlanFilter }) {
return (
<div className="flex flex-col gap-0.5">
<span className={CHIP}>
{filter.column} {filter.op} {filter.value}
</span>
{filter.description && <p className="text-xs text-muted-foreground">{filter.description}</p>}
{filter.rationale && <p className="text-xs text-muted-foreground">{filter.rationale}</p>}
</div>
);
}
function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
const dependsOn = cte.depends_on ?? [];
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
CTE {cte.index}/{total}
</Badge>
<CardTitle className="font-mono text-sm">{cte.name}</CardTitle>
</div>
{cte.purpose && <p className="text-sm text-muted-foreground">{cte.purpose}</p>}
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div>
<h4 className="thot-label mb-1">Depends on</h4>
{dependsOn.length ? (
<div className="flex flex-wrap gap-1.5">
{dependsOn.map((d) => (
<Chip key={d}>{d}</Chip>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">no dependencies</p>
)}
</div>
{cte.tables && cte.tables.length > 0 && (
<div>
<h4 className="thot-label mb-1">Tables</h4>
<div className="flex flex-col gap-1">
{cte.tables.map((t) => (
<div key={t.name}>
<span className="font-mono text-sm">{t.name}</span>
{t.description && (
<p className="text-xs text-muted-foreground">{t.description}</p>
)}
</div>
))}
</div>
</div>
)}
{cte.keys && cte.keys.length > 0 && (
<div>
<h4 className="thot-label mb-1">Keys</h4>
<div className="flex flex-wrap gap-1.5">
{cte.keys.map((k) => (
<Chip key={k}>{k}</Chip>
))}
</div>
</div>
)}
{cte.filters && cte.filters.length > 0 && (
<div>
<h4 className="thot-label mb-1">Filters</h4>
<div className="flex flex-col gap-2">
{cte.filters.map((f, i) => (
<FilterChip key={i} filter={f} />
))}
</div>
</div>
)}
{cte.output_columns && cte.output_columns.length > 0 && (
<div>
<h4 className="thot-label mb-1">Output columns</h4>
<div className="flex flex-wrap gap-1.5">
{cte.output_columns.map((c) => (
<Chip key={c}>{c}</Chip>
))}
</div>
</div>
)}
{cte.rationale && (
<div>
<h4 className="thot-label mb-1">Rationale</h4>
<p className="text-sm text-foreground/90">{cte.rationale}</p>
</div>
)}
</CardContent>
</Card>
);
}
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
return (
<div className="flex flex-col gap-4">
{plan.question && <p className="text-sm text-muted-foreground">{plan.question}</p>}
{plan.strategy && <p className="thot-prose">{plan.strategy}</p>}
<div className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
{plan.ctes.map((c, i) => (
<span key={c.name} className="flex items-center gap-1.5">
{i > 0 && <span aria-hidden="true">→</span>}
<span className="text-foreground/90">{c.name}</span>
</span>
))}
</div>
<div className="flex flex-col gap-3">
{plan.ctes.map((cte) => (
<CteCard key={cte.name} cte={cte} total={plan.ctes.length} />
))}
</div>
</div>
);
}
@@ -0,0 +1,123 @@
import { render, screen } from "@testing-library/react";
import { CteResultViewer } from "./CteResultViewer";
import type { CteResultV2 } from "./artifactV2";
vi.mock("./highlight", () => ({
highlightSql: vi.fn().mockResolvedValue('<pre data-testid="hl">SQL</pre>'),
}));
const okResult: CteResultV2 = {
schema_version: 2,
name: "pazienti_idonei",
index: 1,
total: 2,
purpose: "Seleziona i pazienti con follow-up attivo",
rationale: "Base della catena",
depends_on: [],
sql: "WITH pazienti_idonei AS (SELECT 1)",
status: "ok",
execution_ms: 142,
row_sample: 10,
warnings: [],
columns: [{ name: "paziente_id", description: "Identificativo paziente" }],
preview: { columns: ["paziente_id"], rows: [[1], [2]] },
note: "Nota del modello",
};
test("renders header meta: index/total badge, mono name, status badge, execution_ms, row_sample", async () => {
render(<CteResultViewer result={okResult} />);
await screen.findByTestId("hl");
expect(screen.getByText("CTE 1/2")).toBeInTheDocument();
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
expect(screen.getByText(/success|ok/i)).toBeInTheDocument();
expect(screen.getByText(/142 ms/)).toBeInTheDocument();
expect(screen.getByText(/10 rows/)).toBeInTheDocument();
});
test("renders warnings as badges", async () => {
const withWarnings: CteResultV2 = { ...okResult, warnings: ["colonna troncata"] };
render(<CteResultViewer result={withWarnings} />);
await screen.findByTestId("hl");
expect(screen.getByText("colonna troncata")).toBeInTheDocument();
});
test("passes sql to SqlViewer with mapped test status", async () => {
render(<CteResultViewer result={okResult} />);
await screen.findByTestId("hl");
expect(screen.getByText(/passed/i)).toBeInTheDocument();
});
test("error status maps to failed test status and destructive badge", async () => {
const errored: CteResultV2 = { ...okResult, status: "error" };
render(<CteResultViewer result={errored} />);
await screen.findByTestId("hl");
expect(screen.getByText(/failed/i)).toBeInTheDocument();
});
test("renders PreviewGrid rows when preview is present", async () => {
render(<CteResultViewer result={okResult} />);
await screen.findByTestId("hl");
const cells = screen.getAllByRole("gridcell").map((c) => c.textContent);
expect(cells).toContain("1");
expect(cells).toContain("2");
});
test("shows a discreet empty-state when preview is absent", async () => {
const noPreview: CteResultV2 = { ...okResult, preview: undefined };
render(<CteResultViewer result={noPreview} />);
await screen.findByTestId("hl");
expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
});
test("shows empty-state when preview has no rows", async () => {
const emptyPreview: CteResultV2 = { ...okResult, preview: { columns: ["paziente_id"], rows: [] } };
render(<CteResultViewer result={emptyPreview} />);
await screen.findByTestId("hl");
expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
});
test("renders columns definition list with descriptions", async () => {
const { container } = render(<CteResultViewer result={okResult} />);
await screen.findByTestId("hl");
const dt = container.querySelector("dl dt");
expect(dt).toHaveTextContent("paziente_id");
expect(screen.getByText("Identificativo paziente")).toBeInTheDocument();
});
test("omits the columns definition list when all descriptions are empty", async () => {
const noDescriptions: CteResultV2 = {
...okResult,
columns: [{ name: "paziente_id", description: "" }],
};
const { container } = render(<CteResultViewer result={noDescriptions} />);
await screen.findByTestId("hl");
// No <dl> definition list should render when all descriptions are empty.
expect(container.querySelector("dl")).not.toBeInTheDocument();
});
test("renders note at the end", async () => {
render(<CteResultViewer result={okResult} />);
await screen.findByTestId("hl");
expect(screen.getByText("Nota del modello")).toBeInTheDocument();
});
test("renders depends_on chips when present", async () => {
const withDeps: CteResultV2 = { ...okResult, depends_on: ["cte_base"] };
render(<CteResultViewer result={withDeps} />);
await screen.findByTestId("hl");
expect(screen.getByText("cte_base")).toBeInTheDocument();
});
test("does not render 'undefined' strings for missing optional fields", async () => {
const minimal: CteResultV2 = {
schema_version: 2,
name: "final",
index: 2,
total: 2,
sql: "SELECT 1",
status: "ok",
};
render(<CteResultViewer result={minimal} />);
await screen.findByTestId("hl");
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
});
+100
View File
@@ -0,0 +1,100 @@
import { Badge } from "../components/ui/badge";
import { SqlViewer, type SqlBlock } from "./SqlViewer";
import { PreviewGrid } from "./PreviewGrid";
import type { CteResultV2 } from "./artifactV2";
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
function statusBadgeVariant(status: string): "default" | "destructive" | "outline" {
if (status === "ok") return "default";
if (status === "error") return "destructive";
return "outline";
}
function statusLabel(status: string): string {
if (status === "ok") return "success";
if (status === "error") return "error";
return status;
}
function testStatus(status: string): SqlBlock["testStatus"] {
if (status === "ok") return "passed";
if (status === "error") return "failed";
return "untested";
}
export function CteResultViewer({ result }: { result: CteResultV2 }) {
const hasPreviewRows = !!result.preview && result.preview.rows.length > 0;
const descriptions = (result.columns ?? []).map((c) => c.description ?? "");
const showColumns = descriptions.some((d) => d.trim() !== "");
const dependsOn = result.depends_on ?? [];
const block: SqlBlock = {
name: result.name,
sql: result.sql,
testStatus: testStatus(result.status),
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
CTE {result.index}/{result.total}
</Badge>
<span className="font-mono text-sm font-semibold">{result.name}</span>
<Badge variant={statusBadgeVariant(result.status)}>{statusLabel(result.status)}</Badge>
{result.execution_ms !== undefined && (
<span className="text-xs text-muted-foreground">{result.execution_ms} ms</span>
)}
{result.row_sample !== undefined && (
<span className="text-xs text-muted-foreground">{result.row_sample} rows</span>
)}
</div>
{result.warnings && result.warnings.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{result.warnings.map((w, i) => (
<Badge key={i} variant="outline" className="border-[oklch(var(--warning)/0.4)] text-[oklch(0.5_0.12_79)]">
{w}
</Badge>
))}
</div>
)}
{(result.purpose || result.rationale || dependsOn.length > 0) && (
<div className="flex flex-col gap-1">
{result.purpose && <p className="text-sm text-muted-foreground">{result.purpose}</p>}
{result.rationale && <p className="text-sm text-foreground/90">{result.rationale}</p>}
{dependsOn.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{dependsOn.map((d) => (
<span key={d} className={CHIP}>{d}</span>
))}
</div>
)}
</div>
)}
<SqlViewer blocks={[block]} />
{hasPreviewRows ? (
<PreviewGrid columns={result.preview!.columns} rows={result.preview!.rows} />
) : (
<p className="text-sm text-muted-foreground">No preview rows</p>
)}
{showColumns && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
{result.columns!.map((c) => (
<div key={c.name} className="contents">
<dt className="font-mono font-medium">{c.name}</dt>
<dd className="text-muted-foreground">{c.description}</dd>
</div>
))}
</dl>
)}
{result.note && <p className="thot-prose">{result.note}</p>}
</div>
);
}
@@ -0,0 +1,92 @@
import { render, screen } from "@testing-library/react";
import { PhaseSummaryViewer } from "./PhaseSummaryViewer";
import type { PhaseSummaryV2 } from "./artifactV2";
const phase: PhaseSummaryV2 = {
schema_version: 2,
phase: { id: "F5", num: 5, name: "sintesi" },
summary: "## Recap\nLa catena copre pazienti e impianti.",
checks: [
{ label: "Schema linking completo", status: "ok" },
{ label: "Join validati", status: "warn", detail: "Un join manuale non testato" },
{ label: "SQL eseguibile", status: "fail", detail: "Errore di sintassi" },
],
sections: [
{
title: "Criteri di selezione",
items: [
{
label: "Pazienti attivi",
table: "pazienti",
column: "stato",
value: "IS TRUE",
kind: "filter",
description: "Flag di stato attivo",
rationale: "Solo pazienti in follow-up",
},
],
},
],
tables: [
{
name: "pazienti",
role: "promoted",
description: "Anagrafica pazienti",
columns: [{ name: "stato", description: "Flag attivo", value_filter: "IS TRUE" }],
},
{ name: "log_accessi", role: "excluded", description: "Non pertinente" },
],
open_questions: ["Serve confermare la finestra temporale?"],
};
test("renders phase id/name header and markdown summary", () => {
render(<PhaseSummaryViewer phase={phase} />);
expect(screen.getByText(/F5/)).toBeInTheDocument();
expect(screen.getByText(/sintesi/)).toBeInTheDocument();
expect(screen.getByText("Recap")).toBeInTheDocument();
expect(screen.getByText(/La catena copre pazienti e impianti/)).toBeInTheDocument();
});
test("renders checks with status label and detail", () => {
render(<PhaseSummaryViewer phase={phase} />);
expect(screen.getByText("Schema linking completo")).toBeInTheDocument();
expect(screen.getByText("Join validati")).toBeInTheDocument();
expect(screen.getByText("Un join manuale non testato")).toBeInTheDocument();
expect(screen.getByText("SQL eseguibile")).toBeInTheDocument();
expect(screen.getByText("Errore di sintassi")).toBeInTheDocument();
});
test("renders sections with item label, table.column code-chip, and value badge", () => {
render(<PhaseSummaryViewer phase={phase} />);
expect(screen.getByText("Criteri di selezione")).toBeInTheDocument();
expect(screen.getByText("Pazienti attivi")).toBeInTheDocument();
expect(screen.getByText("pazienti.stato")).toBeInTheDocument();
expect(screen.getAllByText("IS TRUE").length).toBeGreaterThan(0);
expect(screen.getByText("Flag di stato attivo")).toBeInTheDocument();
expect(screen.getByText("Solo pazienti in follow-up")).toBeInTheDocument();
});
test("renders tables recap with promoted/excluded roles and columns", () => {
render(<PhaseSummaryViewer phase={phase} />);
expect(screen.getByText("pazienti")).toBeInTheDocument();
expect(screen.getByText(/promoted/i)).toBeInTheDocument();
expect(screen.getByText("log_accessi")).toBeInTheDocument();
expect(screen.getByText(/excluded/i)).toBeInTheDocument();
expect(screen.getByText("stato")).toBeInTheDocument();
expect(screen.getAllByText("IS TRUE").length).toBeGreaterThan(0);
});
test("renders open_questions as a bullet list", () => {
render(<PhaseSummaryViewer phase={phase} />);
expect(screen.getByText("Serve confermare la finestra temporale?")).toBeInTheDocument();
});
test("omits open_questions section when empty and shows no 'undefined' text", () => {
const minimal: PhaseSummaryV2 = {
schema_version: 2,
phase: { id: "F1", num: 1, name: "domanda" },
};
render(<PhaseSummaryViewer phase={minimal} />);
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
expect(screen.getByText(/F1/)).toBeInTheDocument();
});
+121
View File
@@ -0,0 +1,121 @@
import { Badge } from "../components/ui/badge";
import { MarkdownView } from "./MarkdownView";
import type { PhaseCheck, PhaseSection, PhaseSectionItem, PhaseSummaryV2, PhaseTable } from "./artifactV2";
const CODE_CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
function checkBadgeVariant(status: string): "default" | "destructive" | "outline" {
if (status === "ok") return "default";
if (status === "fail") return "destructive";
return "outline";
}
function Check({ check }: { check: PhaseCheck }) {
return (
<div className="flex items-start gap-2">
<Badge variant={checkBadgeVariant(check.status)}>{check.status}</Badge>
<div className="flex flex-col">
<span className="text-sm text-foreground/90">{check.label}</span>
{check.detail && <span className="text-xs text-muted-foreground">{check.detail}</span>}
</div>
</div>
);
}
function SectionItemRow({ item }: { item: PhaseSectionItem }) {
const tableColumn = item.table && item.column ? `${item.table}.${item.column}` : item.table ?? item.column;
return (
<div className="flex flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-foreground/90">{item.label}</span>
{tableColumn && <span className={CODE_CHIP}>{tableColumn}</span>}
{item.value && <Badge variant="outline">{item.value}</Badge>}
</div>
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
{item.rationale && <p className="text-xs italic text-muted-foreground">{item.rationale}</p>}
</div>
);
}
function Section({ section }: { section: PhaseSection }) {
return (
<div>
<h4 className="thot-label mb-1.5">{section.title}</h4>
<div className="flex flex-col gap-2">
{section.items.map((item, i) => (
<SectionItemRow key={i} item={item} />
))}
</div>
</div>
);
}
function TableRecap({ table }: { table: PhaseTable }) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{table.name}</span>
<Badge variant={table.role === "promoted" ? "default" : "outline"}>{table.role}</Badge>
</div>
{table.description && <p className="text-xs text-muted-foreground">{table.description}</p>}
{table.columns && table.columns.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{table.columns.map((c) => (
<div key={c.name} className="flex flex-col">
<span className={CODE_CHIP}>{c.name}</span>
{c.description && <span className="text-xs text-muted-foreground">{c.description}</span>}
{c.value_filter && <span className={CODE_CHIP}>{c.value_filter}</span>}
</div>
))}
</div>
)}
</div>
);
}
export function PhaseSummaryViewer({ phase }: { phase: PhaseSummaryV2 }) {
return (
<div className="flex flex-col gap-4">
<h3 className="thot-label">
{phase.phase.id} — {phase.phase.name}
</h3>
{phase.summary && <MarkdownView source={phase.summary} />}
{phase.checks && phase.checks.length > 0 && (
<div className="flex flex-col gap-2">
{phase.checks.map((c, i) => (
<Check key={i} check={c} />
))}
</div>
)}
{phase.sections && phase.sections.length > 0 && (
<div className="flex flex-col gap-4">
{phase.sections.map((s, i) => (
<Section key={i} section={s} />
))}
</div>
)}
{phase.tables && phase.tables.length > 0 && (
<div className="flex flex-col gap-3">
{phase.tables.map((t) => (
<TableRecap key={t.name} table={t} />
))}
</div>
)}
{phase.open_questions && phase.open_questions.length > 0 && (
<div>
<h4 className="thot-label mb-1">Open questions</h4>
<ul className="list-disc space-y-1 pl-5 text-sm text-foreground/90 marker:text-muted-foreground">
{phase.open_questions.map((q, i) => (
<li key={i}>{q}</li>
))}
</ul>
</div>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { render, screen } from "@testing-library/react";
import { PreviewGrid } from "./PreviewGrid";
test("renders grid cells for the given columns and rows", () => {
render(
<PreviewGrid columns={["name", "age"]} rows={[["Alice", 30], ["Bob", 25]]} />
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("30")).toBeInTheDocument();
expect(screen.getByText("25")).toBeInTheDocument();
});
+31
View File
@@ -0,0 +1,31 @@
import { AgGridReact } from "ag-grid-react";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
// Register AG Grid modules once at module load time
ModuleRegistry.registerModules([AllCommunityModule]);
interface Props {
columns: string[];
rows: unknown[][];
}
/** Reusable read-only preview grid: columns + row tuples → AG Grid. */
export function PreviewGrid({ columns, rows }: Props) {
const columnDefs = columns.map((col) => ({ field: col, headerName: col }));
const rowData = rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
return (
<div className="overflow-hidden rounded-xl border border-border/70 shadow-xs">
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact columnDefs={columnDefs} rowData={rowData} />
</div>
</div>
);
}
+2 -24
View File
@@ -1,11 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
import { sqlPreview, sqlExport } from "../api/sql";
// Register AG Grid modules once at module load time
ModuleRegistry.registerModules([AllCommunityModule]);
import { PreviewGrid } from "./PreviewGrid";
interface Props {
sessionId: string;
@@ -26,20 +22,6 @@ export function ResultsPanel({ sessionId }: Props) {
const isScalar =
data != null && data.columns.length === 1 && data.rows.length === 1;
const columnDefs = data
? data.columns.map((col) => ({ field: col, headerName: col }))
: [];
const rowData = data
? data.rows.map((row) => {
const obj: Record<string, unknown> = {};
data.columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
})
: [];
return (
<div>
{/* Limit selector */}
@@ -76,11 +58,7 @@ export function ResultsPanel({ sessionId }: Props) {
{/* Grid for everything else */}
{data && !isScalar && (
<>
<div className="overflow-hidden rounded-xl border border-border/70 shadow-xs">
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact columnDefs={columnDefs} rowData={rowData} />
</div>
</div>
<PreviewGrid columns={data.columns} rows={data.rows} />
{data.truncated && <div>(results truncated)</div>}
</>
)}
+121
View File
@@ -0,0 +1,121 @@
// TypeScript mirrors of the v2 artifact payload contracts (see
// .superpowers/sdd/review-gates-v2/contracts.md). Detection everywhere is
// `data.schema_version === 2` + a shape-guard; anything else falls back to the
// legacy renderers in ArtifactView.
// --- A. cte_plan v2 ---------------------------------------------------------
export interface CtePlanTable {
name: string;
description?: string;
}
export interface CtePlanFilter {
column: string;
op: string;
value: string;
description?: string;
rationale?: string;
}
export interface CtePlanCte {
name: string;
index: number;
purpose?: string;
rationale?: string;
depends_on?: string[];
tables?: CtePlanTable[];
keys?: string[];
filters?: CtePlanFilter[];
output_columns?: string[];
}
export interface CtePlanV2 {
schema_version: 2;
question?: string;
strategy?: string;
ctes: CtePlanCte[];
}
// --- B. cte_result v2 --------------------------------------------------------
export interface CteResultColumn {
name: string;
description?: string;
}
export interface CteResultPreview {
columns: string[];
rows: unknown[][];
}
export interface CteResultV2 {
schema_version: 2;
name: string;
index: number;
total: number;
purpose?: string;
rationale?: string;
depends_on?: string[];
sql: string;
status: "ok" | "error" | string;
execution_ms?: number;
row_sample?: number;
warnings?: string[];
sql_hash?: string;
columns?: CteResultColumn[];
preview?: CteResultPreview;
note?: string;
}
// --- C. phase summary v2 -----------------------------------------------------
export interface PhaseMeta {
id: string;
num: number;
name: string;
}
export interface PhaseCheck {
label: string;
status: "ok" | "warn" | "fail" | string;
detail?: string;
}
export interface PhaseSectionItem {
label: string;
table?: string;
column?: string;
value?: string;
kind?: "table" | "column" | "filter" | "join" | "metric" | "note" | string;
description?: string;
rationale?: string;
}
export interface PhaseSection {
title: string;
items: PhaseSectionItem[];
}
export interface PhaseTableColumn {
name: string;
description?: string;
value_filter?: string;
}
export interface PhaseTable {
name: string;
role: "promoted" | "excluded" | string;
description?: string;
columns?: PhaseTableColumn[];
}
export interface PhaseSummaryV2 {
schema_version: 2;
phase: PhaseMeta;
summary?: string;
checks?: PhaseCheck[];
sections?: PhaseSection[];
tables?: PhaseTable[];
open_questions?: string[];
}