fix(ui): improve knowledge reading and add isolated formatting examples

This commit is contained in:
Codex
2026-09-13 16:52:53 +02:00
parent 2953f6b608
commit 3535fda958
14 changed files with 362 additions and 19 deletions
+53
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
import { memoryFormattingExamples } from "../src/shell/memoryFormattingExamples";
// These tests never forward API requests to an installation or invoke a real model.
const workspace = "visual-fixture";
@@ -102,6 +103,58 @@ async function inspect(page: Page) {
});
}
for (const width of [390, 1280, 2400]) {
test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1100 });
const writes = await fixtures(page);
const sample = memoryFormattingExamples[0];
const longEvidence = { ...evidence, applies_to: { concepts: ["Esempio simulato"], tables: ["demo_visuale.identificatore_lungo_per_la_verifica_della_formattazione"], columns: [] },
payload: { rule: sample.detail }, provenance: { source_file: "source/demo.md", supporting_excerpts: ["**Esempio simulato** con `codice_inline` e un collegamento alla regola.\n\nSecondo paragrafo di provenienza."] } };
await page.route(`**/api/workspaces/${workspace}/evidence/*`, route => route.fulfill({ json: { ...evidencePage, item: longEvidence } }));
await page.goto("/");
await admin(page, "Evidence");
await page.getByRole("button", { name: evidence.title, exact: true }).click();
const evidenceDetail = page.getByRole("region", { name: "Evidence detail" });
const article = evidenceDetail.locator("article");
await expect(article).toHaveCSS("max-width", "none");
const rule = article.locator(".thot-knowledge-prose").first();
await expect(rule.locator(":scope > p")).not.toHaveCount(1);
await expect(rule.locator("p").first()).toHaveCSS("max-width", "none");
expect(await article.evaluate(el => {
const parent = el.parentElement!;
const css = getComputedStyle(parent);
return Math.abs(el.getBoundingClientRect().width - (parent.clientWidth - parseFloat(css.paddingLeft) - parseFloat(css.paddingRight)));
})).toBeLessThan(2);
const copy = article.getByRole("button", { name: "Copy Evidence file path", exact: true });
await expect(copy).toHaveText("");
await expect(copy.locator("svg")).toHaveCount(1);
await expect(article.locator("strong").filter({ hasText: "Esempio simulato" })).toBeVisible();
await article.locator("h2").scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("evidence-readable.png"), animations: "disabled" });
expect((await inspect(page)).overflow).toBe(0);
await admin(page, "Memory");
await page.getByRole("button", { name: sample.subject, exact: true }).click();
const memoryDetail = page.getByRole("region", { name: "Memory detail" });
await expect(memoryDetail.getByRole("note")).toContainText("Not saved, indexed or sent to the model.");
const content = memoryDetail.locator(".thot-knowledge-prose").first();
await expect(content.locator(":scope > p")).not.toHaveCount(1);
await expect(content.locator("p").first()).toHaveCSS("max-width", "none");
await expect(content.locator("p").nth(1)).toHaveCSS("margin-top", "20px");
expect(await content.evaluate(el => Math.abs(el.getBoundingClientRect().width - el.parentElement!.clientWidth))).toBeLessThan(2);
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable.png"), animations: "disabled" });
await expect(memoryDetail.getByRole("button", { name: "Edit card", exact: true })).toHaveCount(0);
expect((await inspect(page)).overflow).toBe(0);
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await memoryDetail.getByRole("heading", { name: sample.subject, exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("memory-readable-dark.png"), animations: "disabled" });
expect((await inspect(page)).overflow).toBe(0);
expect(writes).toEqual([]);
});
}
for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 });
+3
View File
@@ -1,5 +1,8 @@
// English gettext message IDs. Workspace content, SQL and identifiers are never translated.
export const itAdmin: Record<string, string> = {
"Formatting examples": "Esempi di formattazione",
"Simulated cards for visual review only. Not saved, indexed or sent to the model.": "Schede simulate solo per la verifica grafica. Non vengono salvate, indicizzate o inviate al modello.",
"Formatting example, not workspace knowledge.": "Esempio di formattazione, non conoscenza del workspace.",
"Import sources, then review proposed changes before activation.": "Importa le fonti, poi rivedi le modifiche proposte prima dell'attivazione.",
"Which sources are imported?": "Quali fonti vengono importate?",
"Place new Markdown drafts in": "Inserisci le nuove bozze Markdown in",
+4
View File
@@ -349,6 +349,10 @@ body.thot-full-document { margin: 0; }
.thot-prose h2 { font-size: var(--text-section); }
.thot-prose h3 { font-size: var(--text-body); }
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; }
.thot-knowledge-prose { width: 100%; min-width: 0; max-width: none; }
.thot-knowledge-prose :where(p, ul, ol, blockquote) { max-width: none; }
.thot-prose.thot-knowledge-prose p + p { margin-top: 1.25em; }
.thot-knowledge-prose pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.thot-prose ul, .thot-prose ol { margin: 0.6em 0; padding-left: 1.3em; }
.thot-prose li { margin: 0.25em 0; }
.thot-prose a { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 2px; }
@@ -22,6 +22,24 @@ beforeEach(() => server.use(
http.get("/api/workspaces/sales/evidence", () => HttpResponse.json(page)),
http.get("/api/workspaces/sales/evidence/evidence:order-key", () => HttpResponse.json(page)),
));
test("copy uses an accessible icon and copies the unmodified full path", async () => {
const user = userEvent.setup();
const copy = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
renderPage(); await choose();
await user.click(await screen.findByRole("button", { name: "Order key" }));
const detail = screen.getByRole("region", { name: "Evidence detail" });
const button = within(detail).getByRole("button", { name: "Copy Evidence file path" });
expect(button).toHaveTextContent("");
expect(button.querySelector("svg")).not.toBeNull();
await user.click(button);
expect(copy).toHaveBeenCalledWith("/srv/workspaces/repo/sales/evidence/curated/domain/order-key.md");
expect(within(detail).getByRole("status")).toHaveTextContent("Copied");
copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
await user.click(button);
expect(within(detail).getByRole("status")).toHaveTextContent("Select the text and copy it manually.");
copy.mockRestore();
});
async function choose() { await screen.findByRole("option", { name: "Sales" }); await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales"); }
test("switches Evidence controls and detail labels without translating archive content", async () => {
renderPage(); await choose();
@@ -1,5 +1,6 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { Copy } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch, apiErrorMessage } from "../api/client";
import { listEvidence, getEvidence, type EvidencePage, type EvidenceUnit } from "../api/evidence";
@@ -28,11 +29,11 @@ function CopyText({ text, label, showText = true }: { text: string; label: strin
const { t } = useI18n();
const [notice, setNotice] = useState("");
return <div className="flex min-w-0 flex-wrap items-start gap-2">
{showText && <code className="min-w-0 flex-1 break-all text-xs select-text">{text}</code>}
<Button size="sm" variant="outline" aria-label={t("Copy {label}", { label: label })} onClick={async () => {
{showText && <code className="min-w-0 flex-1 [overflow-wrap:anywhere] text-xs select-text">{text}</code>}
<Button size="icon" variant="ghost" title={t("Copy {label}", { label })} aria-label={t("Copy {label}", { label })} onClick={async () => {
try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
catch { setNotice("Select the text and copy it manually."); }
}}>{t("Copy")}</Button><span role="status" className="text-xs text-muted-foreground">{t(notice)}</span>
}}><Copy aria-hidden="true" /></Button>{notice && <span role="status" className="basis-full text-xs text-muted-foreground">{t(notice)}</span>}
</div>;
}
@@ -113,7 +114,7 @@ function Instructions({ data }: { data: EvidencePage }) {
<p className="mb-2 text-sm font-medium">{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}</p>
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">{t("Edit files, consolidate and commit")}</summary>
{data.location.workspace ? <div className="mt-3"><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></div> : <p role="alert" className="mt-3 text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
<ol className="mt-3 max-w-[75ch] list-decimal space-y-3 pl-5 text-sm">
<ol className="mt-3 list-decimal space-y-3 pl-5 text-sm">
<li>{t("Edit the files under")} <code>evidence/curated/&lt;kind&gt;/</code> {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}</li>
<li>{t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git inspection command")} /></div>)}</li>
<li>{t("From the installation directory, run this command. It validates and activates Evidence without a full preprocessing run. If needed, add")} <code>--installation /absolute/path/thothii-installation.yaml</code> {t("immediately after")} <code>tht</code>.
@@ -130,14 +131,14 @@ function UnitDetail({ item, location }: { item: EvidenceUnit; location: Evidence
const { t } = useI18n();
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
return <article className="max-w-[75ch] space-y-5 pb-8">
return <article className="min-w-0 w-full space-y-5 pb-8 [overflow-wrap:anywhere]">
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header>
{file && <CopyText text={file} label={t("Evidence file path")} />}
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || t("(empty value)")}</dt><dd>{text}</dd></div>)}</dl>}</section>)}
{Object.entries(item.payload).map(([field, value]) => <section key={field}><h3 className="mb-2 text-sm font-semibold capitalize">{t(field.replaceAll("_", " "))}</h3>{typeof value === "string" ? field === "sql" ? <pre className="overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3 text-sm">{value}</pre> : <MarkdownView source={value} readable /> : Array.isArray(value) ? <ul className="list-inside list-disc space-y-2 text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="space-y-2 text-sm">{Object.entries(value).map(([key, text]) => <div key={key}><dt className="font-mono font-medium">{key || t("(empty value)")}</dt><dd><MarkdownView source={text} readable /></dd></div>)}</dl>}</section>)}
<section className="border-t pt-4"><h3 className="font-medium">{t("Provenance")}</h3><p className="mt-2 text-sm">{item.provenance.kind === "manual" ? t("Manual declaration by {author}.", { author: item.provenance.declared_by ?? "" }) : t("Derived from a source document.")}</p>
{source?.source_file && <><p className="mt-2 break-all text-xs">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <p key={i} className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">“{text}”</p>)}</>}
{source?.source_file && <><p className="mt-2 [overflow-wrap:anywhere] text-xs">{item.provenance.kind === "manual" ? t("Original document (lineage): ") : t("Source: ")}{source.source_file}</p>{source.supporting_excerpts?.map((text, i) => <div key={i} className="mt-4"><MarkdownView source={text} readable /></div>)}</>}
</section>
{item.review_items.length > 0 && <section><h3 className="font-medium">{t("Review required")}</h3>{item.review_items.map((review, i) => <p key={i} className="mt-2 text-sm">{t(review.message)}</p>)}</section>}
</article>;
@@ -6,6 +6,7 @@ import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
import { MemoryManagementPage } from "./MemoryManagementPage";
import type { MemoryCard } from "../api/memory";
import { memoryFormattingExamples } from "./memoryFormattingExamples";
const id = "mem-11111111-1111-4111-8111-111111111111";
const fixture: MemoryCard = { id, workspace_id: "sales", family: "domain_clarification", subject: "Order",
@@ -31,6 +32,33 @@ async function chooseWorkspace() {
await userEvent.selectOptions(screen.getByLabelText("Workspace"), "sales");
}
test("formatting examples use the real reader without persistence or editing actions", async () => {
const writes: string[] = [];
const cardReads: string[] = [];
server.use(
http.all("/api/workspaces/sales/memory/*", ({ request }) => {
if (request.method !== "GET") writes.push(request.method);
else cardReads.push(new URL(request.url).pathname);
return HttpResponse.json([]);
}),
);
renderPage(); await chooseWorkspace();
await userEvent.click(screen.getByText("Formatting examples", { selector: "summary" }));
const detail = screen.getByRole("region", { name: "Memory detail" });
for (const card of memoryFormattingExamples) {
await userEvent.click(screen.getByRole("button", { name: card.subject }));
expect(within(detail).getByRole("heading", { name: card.subject })).toBeVisible();
expect(within(detail).getByRole("note")).toHaveTextContent("Not saved, indexed or sent to the model.");
expect(within(detail).queryByRole("button", { name: "Edit card" })).not.toBeInTheDocument();
expect(within(detail).queryByRole("button", { name: "Delete card" })).not.toBeInTheDocument();
expect(within(detail).queryByRole("button", { name: "Save card" })).not.toBeInTheDocument();
}
expect(writes).toEqual([]);
expect(cardReads.every(path => path.endsWith("/pending"))).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "New card" }));
expect(screen.getByLabelText("Title")).toHaveValue("");
});
test("switches Memory editor controls in place and preserves the draft", async () => {
renderPage(); await chooseWorkspace();
await userEvent.click(screen.getByRole("button", { name: "New card" }));
+24 -9
View File
@@ -10,6 +10,8 @@ import {
import { useAuthGeneration } from "../auth/authState";
import { AdministrationHeader } from "./administration/AdministrationPage";
import { useWorkingContextScope } from "../workspaces/WorkingContext";
import { MarkdownView } from "../viewers/MarkdownView";
import { memoryFormattingExamples } from "./memoryFormattingExamples";
const families: Record<MemoryFamily, string> = {
domain_clarification: "Domain clarification", sql_rule: "SQL rule",
@@ -61,6 +63,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
const [filters, setFilters] = useState<Record<string, string>>({ q: "", family: "", sort: "updated_at", direction: "desc" });
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MemoryCard | null>(null);
const [formattingExample, setFormattingExample] = useState(false);
const [draft, setDraft] = useState<MemoryInput | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
@@ -93,7 +96,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
async function open(id: string) {
if (busy || !leaveDraft()) return;
setBusy(true); setError(""); setDeleteConfirm(false);
try { const card = await getMemory(workspace, id); setSelected(card); setDraft(null); }
try { const card = await getMemory(workspace, id); setSelected(card); setFormattingExample(false); setDraft(null); }
catch (e) { setError(apiErrorMessage(e)); }
finally { setBusy(false); }
}
@@ -106,7 +109,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
: outcome.action === "delete" ? "Card deleted. Index cleanup is incomplete; retry below."
: "Saved. Index update is incomplete; retry below. The previous content is excluded from recall.");
if (outcome.action === "delete" && outcome.id === selected?.id) { setSelected(null); setDraft(null); }
else if (outcome.card) { setSelected(outcome.card); setDraft(null); }
else if (outcome.card) { setSelected(outcome.card); setFormattingExample(false); setDraft(null); }
setDeleteConfirm(false); await refresh();
} catch (e) { setError(apiErrorMessage(e)); }
finally { setBusy(false); }
@@ -128,7 +131,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<option value="updated_at">{t("Updated")}</option><option value="created_at">{t("Created")}</option><option value="subject">{t("Title")}</option><option value="family">{t("Family")}</option>
</select></label>
<Button variant="outline" onClick={() => filter("direction", filters.direction === "desc" ? "asc" : "desc")}>{filters.direction === "desc" ? t("Descending") : t("Ascending")}</Button>
<Button disabled={busy} onClick={() => { if (!leaveDraft()) return; setSelected(null); setDraft(emptyCard()); setError(""); setNotice(""); }}>{t("New card")}</Button>
<Button disabled={busy} onClick={() => { if (!leaveDraft()) return; setSelected(null); setFormattingExample(false); setDraft(emptyCard()); setError(""); setNotice(""); }}>{t("New card")}</Button>
</div>
<details className="shrink-0 border-b px-6 pb-4"><summary className="cursor-pointer text-sm font-medium">{t("More filters")}</summary>
<div className="mt-3 grid grid-cols-2 gap-3 xl:grid-cols-4">
@@ -163,16 +166,28 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>{t("Next")}</Button>
</nav>}
</>}
<details className="border-t px-6 py-4" open={list.data?.total === 0}>
<summary className="cursor-pointer text-sm font-semibold">{t("Formatting examples")}</summary>
<p className="my-3 text-sm text-muted-foreground">{t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}</p>
<ul className="space-y-2">{memoryFormattingExamples.map(card => <li key={card.id}>
<button type="button" disabled={busy} className="w-full rounded-md border p-3 text-left text-sm hover:bg-muted"
aria-current={formattingExample && selected?.id === card.id ? "true" : undefined}
onClick={() => { if (!leaveDraft()) return; setSelected(card); setFormattingExample(true); setDraft(null); setDeleteConfirm(false); setError(""); setNotice(""); }}>
{card.subject}
</button>
</li>)}</ul>
</details>
</section>
<section aria-label={t("Memory detail")} className="thot-administration-detail">
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>}
{selected && !draft && <div className="space-y-5">
{selected && !draft && <div className="min-w-0 w-full space-y-5 [overflow-wrap:anywhere]">
{formattingExample && <p role="note" className="rounded-md border bg-muted p-3 text-sm">{t("Simulated cards for visual review only. Not saved, indexed or sent to the model.")}</p>}
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-sans text-xl font-semibold">{selected.subject}</h2></div>
<dl className="space-y-4 text-sm">
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt>
<dd className={`max-w-[72ch] whitespace-pre-wrap break-words ${key === "sql" ? "font-mono text-xs" : ""}`}>{selected[key]}</dd></div>)}
<div><dt className="font-semibold">{t("Provenance")}</dt><dd>{selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</dd></div>
<dd className="min-w-0 w-full">{key === "sql" ? <pre className="whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted p-3 font-mono text-sm">{selected[key]}</pre> : <MarkdownView source={selected[key]} readable />}</dd></div>)}
<div><dt className="font-semibold">{t("Provenance")}</dt><dd>{formattingExample ? t("Formatting example, not workspace knowledge.") : selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</dd></div>
{selected.decision_seq !== null && <div><dt className="font-semibold">{t("Source decision")}</dt><dd>{selected.decision_seq}</dd></div>}
<div><dt className="font-semibold">{t("Card ID")}</dt><dd className="break-all font-mono text-xs">{selected.id}</dd></div>
<div><dt className="font-semibold">{t("Updated")}</dt><dd>{new Date(selected.updated_at).toLocaleString(getLocale())}</dd></div>
@@ -181,9 +196,9 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
{selected.dependencies.length > 0 && <div><dt className="font-semibold">{t("Schema dependencies")}</dt><dd><ul>{selected.dependencies.map((d, i) => <li key={i}>{[d.database, d.schema_name, d.table, d.column].filter(Boolean).join(" / ")}</li>)}</ul></dd></div>}
{selected.links.length > 0 && <div><dt className="font-semibold">{t("Linked cards")}</dt><dd><ul>{selected.links.map(link => <li key={link.target_id} className="my-2"><button className="text-left text-primary underline" onClick={() => void open(link.target_id)}>{link.meaning}</button></li>)}</ul></dd></div>}
</dl>
<div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>{t("Delete card")}</Button></div>
{deleteConfirm && <div className="space-y-3 border-t pt-4"><p className="text-sm">{t("Delete “")}{selected.subject}{t("” and its links? Other cards will be kept.")}</p>
{!formattingExample && <div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(true)}>{t("Delete card")}</Button></div>}
{!formattingExample && deleteConfirm && <div className="space-y-3 border-t pt-4"><p className="text-sm">{t("Delete “")}{selected.subject}{t("” and its links? Other cards will be kept.")}</p>
<div className="flex gap-2"><Button variant="destructive" disabled={busy} onClick={() => void mutate(() => deleteMemory(workspace, selected.id))}>{t("Confirm deletion")}</Button>
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>{t("Keep card")}</Button></div></div>}
</div>}
@@ -0,0 +1,43 @@
import type { MemoryCard, MemoryInput } from "../api/memory";
// Presentation fixtures only. Never pass these cards to persistence or recall.
const samples: MemoryInput[] = [
{
family: "domain_clarification", subject: "FAKE · Distinguere eventi e soggetti",
detail: "Questo è un esempio inventato per verificare la leggibilità di una regola lunga, non una definizione approvata del workspace. Nel catalogo dimostrativo un soggetto può comparire in più eventi e ogni evento può contenere diverse osservazioni; la chiave `demo_visuale.event_id` identifica un evento, mentre `demo_visuale.subject_id` serve esclusivamente a raggruppare le righe dello stesso soggetto fittizio. Per mostrare la separazione tra paragrafi, immaginiamo di dover descrivere il criterio di conteggio e di confrontare due risultati costruiti a partire dallo stesso insieme di righe. Il numero degli eventi e quello dei soggetti possono essere diversi anche se nessuna riga è duplicata; questa frase serve soltanto a verificare il respiro del testo dopo un punto e virgola. Nessuna delle tabelle o delle colonne citate esiste nel database operativo e questo esempio non deve essere usato per formulare query reali.",
scope: "Archivio dimostrativo, senza dati reali.\n\n- Un solo insieme di eventi inventati.\n- Identificatori lunghi come `demo_visuale.archivio_dimostrativo_per_verifica_della_formattazione`.\n- Valido esclusivamente per la verifica grafica.",
rationale: "**Scopo della prova:** confrontare paragrafi, elenchi e codice inline.\n\nIl testo deve usare tutta la larghezza del dettaglio e adattarsi a un pannello stretto senza uscire dai suoi bordi.",
question: "Quanti soggetti fittizi compaiono negli eventi dimostrativi?", sql: "",
concepts: ["esempio simulato", "eventi", "soggetti"], dependencies: [], links: [],
},
{
family: "sql_rule", subject: "FAKE · Regola con elenco e identificatori lunghi",
detail: "### Prima del conteggio\n\nLa regola è completamente simulata. Serve a verificare titoli, spaziatura e un elenco di passi ordinati, senza definire una procedura operativa.\n\n1. Leggere il campo `demo_visuale.identificatore_esteso_dell_evento_dimostrativo`.\n2. Conservare il valore originale per la visualizzazione.\n3. Mostrare il risultato senza salvarlo nell'archivio.\n\n### Casi particolari\n\n- Il numero `0.4` deve restare integro.\n- L'espressione `status IN ('A', 'B');` non deve essere spezzata in paragrafi.\n- Un [collegamento dimostrativo](https://example.com/formatting) deve mantenere testo e destinazione.",
scope: "Solo anteprima dell'interfaccia.\n\nNessuna dipendenza da tabelle reali e nessuna indicizzazione.",
rationale: "Verificare che gli elenchi siano leggibili e che i valori tecnici non vengano alterati.",
question: "", sql: "", concepts: ["formattazione", "SQL simulato"], dependencies: [], links: [],
},
{
family: "solved_question", subject: "FAKE · Domanda risolta con SQL dimostrativo",
detail: "L'esempio mostra un piccolo insieme di righe letterali, completamente inventate.\n\nIl codice è presentato come testo: nessuna query viene eseguita aprendo questa scheda.",
scope: "Solo tre righe di prova.\n\nNessuna connessione a database o a dati personali.",
rationale: "Il risultato previsto è utile soltanto a verificare la lettura del codice e la larghezza disponibile.",
question: "Quanti eventi fittizi appartengono a ciascuna categoria?",
sql: "-- FAKE: esempio grafico, non eseguito\nWITH demo_visuale(categoria, evento) AS (\n VALUES ('A', 1), ('A', 2), ('B', 3)\n)\nSELECT categoria, COUNT(*) AS numero_eventi_dimostrativi\nFROM demo_visuale\nGROUP BY categoria;",
concepts: ["domanda simulata", "conteggio"], dependencies: [], links: [],
},
{
family: "explained_error", subject: "FAKE · Errore spiegato e correzione illustrativa",
detail: "**Errore simulato:** un esempio conta le righe dopo un collegamento che moltiplica gli eventi.\n\n**Effetto visivo:** il risultato appare maggiore di quello atteso, pur partendo dagli stessi dati inventati.\n\n**Correzione illustrativa:** confrontare la granularità delle due sorgenti dimostrative prima di scegliere il conteggio.",
scope: "Ambiente di prova grafica.\n\nQuesta scheda non descrive un incidente reale né una regola clinica.",
rationale: "La distinzione fra sintomo, spiegazione e correzione deve essere immediatamente visibile.\n\nI tre paragrafi non devono essere riuniti in un unico blocco di testo.",
question: "Perché il totale dell'esempio è cambiato?", sql: "",
concepts: ["errore simulato", "leggibilità"], dependencies: [], links: [],
},
];
export const memoryFormattingExamples: readonly MemoryCard[] = samples.map((card, index) => ({
...card, id: `formatting-example-${index + 1}`, workspace_id: "formatting-preview-only",
origin: "manual", session_id: null, decision_seq: null, indexed: false,
created_at: "2026-09-13T00:00:00Z", updated_at: "2026-09-13T00:00:00Z", revision: "preview-only",
}));
@@ -0,0 +1,32 @@
import { render } from "@testing-library/react";
import { MarkdownView } from "./MarkdownView";
test("readable paragraphs preserve text, inline code, links and authored source", () => {
const source = `${"Una regola dimostrativa conserva sempre i dati originali. ".repeat(5)}Si usa \`flag = FALSE; valore >= 0.4\` senza cambiare il codice; ${"Questa parte spiega il criterio senza aggiungere nuove informazioni. ".repeat(5)}Leggere **tutto** e il [documento](https://example.com/a.b).`;
const normal = render(<MarkdownView source={source} />);
const expected = normal.container.textContent;
normal.unmount();
const view = render(<MarkdownView source={source} readable />);
expect(view.container.querySelectorAll(".markdown-view > p").length).toBeGreaterThan(2);
// HTML paragraph boundaries add whitespace, but never change the wording.
expect(view.container.textContent?.replace(/\s+/g, " ").trim()).toBe(expected?.replace(/\s+/g, " ").trim());
expect(view.container.querySelector("code")).toHaveTextContent("flag = FALSE; valore >= 0.4");
expect(view.container.querySelector("strong")).toHaveTextContent("tutto");
expect(view.container.querySelector("a")).toHaveAttribute("href", "https://example.com/a.b");
expect(view.container.querySelector(".markdown-view")).toHaveClass("thot-knowledge-prose");
});
test("leaves existing paragraphs, lists and fenced SQL unchanged", () => {
const sql = `SELECT '${"valore; con punteggiatura. ".repeat(20)}' AS esempio;`;
const source = `Primo paragrafo.\n\nSecondo paragrafo.\n\n- Una voce\n- Altra voce\n\n\`\`\`sql\n${sql}\n\`\`\``;
const view = render(<MarkdownView source={source} readable />);
expect(view.container.querySelectorAll(".markdown-view > p")).toHaveLength(2);
expect(view.container.querySelectorAll("li")).toHaveLength(2);
expect(view.container.querySelector("pre code")?.textContent).toBe(sql + "\n");
});
test("does not enable long-paragraph reflow for ordinary document viewers", () => {
const view = render(<MarkdownView source={"A normal document keeps its authored paragraph. ".repeat(20)} />);
expect(view.container.querySelectorAll("p")).toHaveLength(1);
expect(view.container.querySelector(".thot-knowledge-prose")).toBeNull();
});
+4 -3
View File
@@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { renderMermaid } from "./mermaid";
import { useShell } from "../shell/host/ShellProvider";
import { readableParagraphs } from "./readableParagraphs";
function MermaidBlock({ code }: { code: string }) {
const { theme } = useShell();
@@ -45,10 +46,10 @@ const components: Components = {
},
};
export function MarkdownView({ source }: { source: string }) {
export function MarkdownView({ source, readable = false }: { source: string; readable?: boolean }) {
return (
<div className="markdown-view thot-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<div className={`markdown-view thot-prose${readable ? " thot-knowledge-prose" : ""}`}>
<ReactMarkdown remarkPlugins={readable ? [remarkGfm, readableParagraphs] : [remarkGfm]} components={components}>
{source}
</ReactMarkdown>
</div>
@@ -0,0 +1,41 @@
import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast";
// Display-only: preserve every inline node and character. Never rewrite source
// files, SQL, links, existing lists or short authored paragraphs.
export function readableParagraphs() {
return (tree: Root) => {
tree.children = tree.children.flatMap<RootContent>(node => {
if (node.type !== "paragraph") return [node];
const length = node.children.reduce((total, child) => total + ("value" in child ? String(child.value).length : 0), 0);
if (length < 360) return [node];
const paragraphs: Paragraph[] = [];
let children: PhrasingContent[] = [];
let size = 0;
for (const child of node.children) {
if (child.type !== "text") {
children.push(child);
if ("value" in child) size += String(child.value).length;
continue;
}
// Boundaries must be outside inline code/emphasis/links. Decimal points
// and identifiers cannot match because they have no following space.
let start = 0;
for (const match of child.value.matchAll(/;\s+|[.!?]\s+(?=[A-ZÀ-Ý])/g)) {
const end = match.index! + match[0].length;
if (size + end - start < 180) continue;
children.push({ type: "text", value: child.value.slice(start, end) });
paragraphs.push({ type: "paragraph", children });
children = [];
size = 0;
start = end;
}
if (start < child.value.length) {
children.push({ type: "text", value: child.value.slice(start) });
size += child.value.length - start;
}
}
if (children.length) paragraphs.push({ type: "paragraph", children });
return paragraphs;
});
};
}