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
+13
View File
@@ -351,6 +351,19 @@ default, hover, focus, active, disabled, loading, and error behavior where those
### Curated Evidence Documents ### Curated Evidence Documents
Memory and Evidence detail readers use the entire available content width, without
the ordinary 72–75ch prose cap. This is the owner's explicit reading-layout choice.
Long unstructured paragraphs are split for display at existing sentence/semicolon
boundaries outside inline code and links; authored Markdown structure and stored
content are unchanged. Paragraph spacing is 1.25em. Scope and provenance share the
available width; provenance excerpts render Markdown rather than literal markers.
Copy actions use the two-overlapping-sheets icon, an accessible name/tooltip and
live success/failure feedback instead of a visible Copy label.
Memory has four explicitly FAKE formatting examples, one per family, in a separate
expandable section. They reuse the real detail reader but never enter persistence,
indexing, link search or model recall, and expose no edit/delete/save actions.
Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope, Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope,
typed content, supporting excerpts, review items, then technical provenance. Curated v4 files typed content, supporting excerpts, review items, then technical provenance. Curated v4 files
use short, visible YAML frontmatter for identity and classification. The Markdown title and use short, visible YAML frontmatter for identity and classification. The Markdown title and
+14
View File
@@ -48,6 +48,20 @@ PSD Gitea push and production deployment are still pending.
## Current product shape ## Current product shape
### Knowledge reading and isolated fake Memory examples — 2026-09-13
Memory/Evidence details now use all available width, with display-only paragraph
splitting for long plain prose and preserved code/Markdown/source data. Evidence
provenance renders Markdown; copy controls are accessible two-sheet icons.
Memory's separate Formatting examples section contains four FAKE cards (one per
family), automatically expanded for an empty archive. These client-side examples
cannot be edited/saved/indexed and are never submitted to the model or Memory API.
No real archive content was changed. 762 tests, 18 browser scenarios, typecheck,
i18n and Docker build pass. Only the Mac frontend was recreated: `fc4286b5406d`,
image `cbe0fe0dce55`, healthy; other services unchanged. Rollback image:
`thothii-frontend:before-knowledge-reading-20260913`. Details in
`docs/reports/2026-09-13-knowledge-reading.md`; no PSD/Omics deployment.
### Unified Session entry and complete tab borders — 2026-09-13 ### Unified Session entry and complete tab borders — 2026-09-13
The sidebar has one Session/Sessione button: return to the current unfinished The sidebar has one Session/Sessione button: return to the current unfinished
@@ -0,0 +1,77 @@
# Lettura di Evidence e Memory
Data: 13 settembre 2026. Richiesta: eliminare il muro di testo nelle regole,
usare la larghezza disponibile, fornire Memory finte per la verifica grafica e
sostituire i pulsanti Copia con l'icona a due fogli.
## Intervento
Rimossi il limite di 75ch dal dettaglio Evidence e quello di 72ch dai campi Memory.
La variante di lettura Knowledge elimina anche il limite interno dei paragrafi
Markdown, mantenendo il comportamento responsive del contenitore. Gli altri
visualizzatori non cambiano impaginazione. Codice e identificatori lunghi possono
andare a capo quando necessario, senza allargare la pagina.
Per paragrafi di almeno 360 caratteri, la variante introduce separazioni visive
dopo almeno 180 caratteri, cercando punti, punti e virgola, punti interrogativi
o esclamativi nel testo ordinario. Non divide dentro codice inline, enfasi o link;
elenchi, titoli, paragrafi brevi e blocchi SQL già strutturati rimangono intatti.
La spaziatura tra paragrafi è 1,25em. Si tratta di un intervento sul rendering
Markdown, non di una riscrittura o un salvataggio dei documenti. Nessuna conoscenza
di dominio viene generata dal visualizzatore.
Ambito, Provenienza e Regola occupano l'intera larghezza del dettaglio. Gli estratti
di provenienza interpretano Markdown, senza mostrare letteralmente asterischi e
backtick. Copia usa l'icona Lucide Copy con nome accessibile e tooltip; il percorso
completo viene copiato senza trasformazioni e gli errori restano annunciati.
Impeccable ha guidato la leggibilità, la separazione dei paragrafi e l'accessibilità;
la larghezza piena segue la richiesta esplicita dell'utente anche oltre la misura
di lettura ordinaria del design system.
## Memory simulate
In **Administration → Memory → Formatting examples**, oppure **Amministrazione →
Memory → Esempi di formattazione**, sono disponibili quattro schede FAKE:
- chiarimento di dominio con una regola lunga;
- regola SQL con titoli, elenchi e identificatori;
- domanda risolta con SQL dimostrativo;
- errore spiegato con paragrafi distinti.
La sezione si apre automaticamente se l'archivio reale è vuoto. Gli esempi sono
fixture frontend in `frontend/src/shell/memoryFormattingExamples.ts`, separate
dai risultati dell'archivio e dai suoi conteggi. Riutilizzano il lettore reale,
ma non offrono Edit/Delete/Save, non hanno dipendenze o link a schede reali e non
vengono inviati ad alcuna API Memory. Nessun inserimento PostgreSQL, indicizzazione
Qdrant, consolidamento Evidence o invio al modello è avvenuto. I nomi demo e gli
identificatori non sono riferimenti al DWH operativo.
Si possono richiudere senza cancellazioni. Una futura rimozione degli esempi è
una modifica del frontend, non una pulizia dei dati. New card dopo un esempio
apre una scheda vuota, senza copiarne il contenuto simulato.
## Verifiche e consegna locale
- 762 test frontend in 91 file, tutti superati.
- 18 scenari Playwright, inclusi reader a 390, 1280 e 2400 px, tema scuro,
assenza di overflow, larghezza dei paragrafi e separazione effettiva.
- Test di conservazione di parole, codice e destinazioni dei link, struttura
Markdown e comportamento invariato dei visualizzatori ordinari.
- Clipboard: contenuto copiato esatto, nome accessibile e fallimento gestito.
- Fixture Memory: nessuna richiesta di scrittura o caricamento di una falsa
scheda dal server; nessuna azione di modifica disponibile.
- Typecheck, controllo di 1686 traduzioni italiane/1707 riferimenti statici e build
Docker superati. Screenshot desktop, desktop largo e mobile scuro controllati.
Il Mac serve la nuova variante CSS. Solo frontend è stato ricreato, container
`fc4286b5406d`, immagine `cbe0fe0dce55`, healthy. Core `3c3c0739b9af`, catalogo
`89755e439dc7`, Qdrant `52ebd5c47955` ed embedding `7f690e534eda` restano invariati
e healthy. Non è stato eseguito un deploy Omics o PSD.
Rollback locale, senza toccare altri servizi o volumi:
```bash
docker image tag thothii-frontend:before-knowledge-reading-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
+53
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures"; 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. // These tests never forward API requests to an installation or invoke a real model.
const workspace = "visual-fixture"; 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]) { for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => { test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 }); 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. // English gettext message IDs. Workspace content, SQL and identifiers are never translated.
export const itAdmin: Record<string, string> = { 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.", "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?", "Which sources are imported?": "Quali fonti vengono importate?",
"Place new Markdown drafts in": "Inserisci le nuove bozze Markdown in", "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 h2 { font-size: var(--text-section); }
.thot-prose h3 { font-size: var(--text-body); } .thot-prose h3 { font-size: var(--text-body); }
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; } .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 ul, .thot-prose ol { margin: 0.6em 0; padding-left: 1.3em; }
.thot-prose li { margin: 0.25em 0; } .thot-prose li { margin: 0.25em 0; }
.thot-prose a { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 2px; } .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", () => HttpResponse.json(page)),
http.get("/api/workspaces/sales/evidence/evidence:order-key", () => 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"); } 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 () => { test("switches Evidence controls and detail labels without translating archive content", async () => {
renderPage(); await choose(); renderPage(); await choose();
@@ -1,5 +1,6 @@
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useState } from "react"; import { useState } from "react";
import { Copy } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiFetch, apiErrorMessage } from "../api/client"; import { apiFetch, apiErrorMessage } from "../api/client";
import { listEvidence, getEvidence, type EvidencePage, type EvidenceUnit } from "../api/evidence"; 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 { t } = useI18n();
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
return <div className="flex min-w-0 flex-wrap items-start gap-2"> 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>} {showText && <code className="min-w-0 flex-1 [overflow-wrap:anywhere] text-xs select-text">{text}</code>}
<Button size="sm" variant="outline" aria-label={t("Copy {label}", { label: label })} onClick={async () => { <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"); } try { await navigator.clipboard.writeText(text); setNotice("Copied"); }
catch { setNotice("Select the text and copy it manually."); } 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>; </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> <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> <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>} {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("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("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>. <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 { t } = useI18n();
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance; const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null; 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> <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")} />} {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>} {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.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> <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> </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>} {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>; </article>;
@@ -6,6 +6,7 @@ import { http, HttpResponse } from "msw";
import { server } from "../test/msw"; import { server } from "../test/msw";
import { MemoryManagementPage } from "./MemoryManagementPage"; import { MemoryManagementPage } from "./MemoryManagementPage";
import type { MemoryCard } from "../api/memory"; import type { MemoryCard } from "../api/memory";
import { memoryFormattingExamples } from "./memoryFormattingExamples";
const id = "mem-11111111-1111-4111-8111-111111111111"; const id = "mem-11111111-1111-4111-8111-111111111111";
const fixture: MemoryCard = { id, workspace_id: "sales", family: "domain_clarification", subject: "Order", 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"); 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 () => { test("switches Memory editor controls in place and preserves the draft", async () => {
renderPage(); await chooseWorkspace(); renderPage(); await chooseWorkspace();
await userEvent.click(screen.getByRole("button", { name: "New card" })); await userEvent.click(screen.getByRole("button", { name: "New card" }));
+24 -9
View File
@@ -10,6 +10,8 @@ import {
import { useAuthGeneration } from "../auth/authState"; import { useAuthGeneration } from "../auth/authState";
import { AdministrationHeader } from "./administration/AdministrationPage"; import { AdministrationHeader } from "./administration/AdministrationPage";
import { useWorkingContextScope } from "../workspaces/WorkingContext"; import { useWorkingContextScope } from "../workspaces/WorkingContext";
import { MarkdownView } from "../viewers/MarkdownView";
import { memoryFormattingExamples } from "./memoryFormattingExamples";
const families: Record<MemoryFamily, string> = { const families: Record<MemoryFamily, string> = {
domain_clarification: "Domain clarification", sql_rule: "SQL rule", 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 [filters, setFilters] = useState<Record<string, string>>({ q: "", family: "", sort: "updated_at", direction: "desc" });
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState<MemoryCard | null>(null); const [selected, setSelected] = useState<MemoryCard | null>(null);
const [formattingExample, setFormattingExample] = useState(false);
const [draft, setDraft] = useState<MemoryInput | null>(null); const [draft, setDraft] = useState<MemoryInput | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
@@ -93,7 +96,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
async function open(id: string) { async function open(id: string) {
if (busy || !leaveDraft()) return; if (busy || !leaveDraft()) return;
setBusy(true); setError(""); setDeleteConfirm(false); 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)); } catch (e) { setError(apiErrorMessage(e)); }
finally { setBusy(false); } finally { setBusy(false); }
} }
@@ -106,7 +109,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
: outcome.action === "delete" ? "Card deleted. Index cleanup is incomplete; retry below." : 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."); : "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); } 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(); setDeleteConfirm(false); await refresh();
} catch (e) { setError(apiErrorMessage(e)); } } catch (e) { setError(apiErrorMessage(e)); }
finally { setBusy(false); } 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> <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> </select></label>
<Button variant="outline" onClick={() => filter("direction", filters.direction === "desc" ? "asc" : "desc")}>{filters.direction === "desc" ? t("Descending") : t("Ascending")}</Button> <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> </div>
<details className="shrink-0 border-b px-6 pb-4"><summary className="cursor-pointer text-sm font-medium">{t("More filters")}</summary> <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"> <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> <Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>{t("Next")}</Button>
</nav>} </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>
<section aria-label={t("Memory detail")} className="thot-administration-detail"> <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>} {!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> <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"> <dl className="space-y-4 text-sm">
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}> {(["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> <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>)} <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>{selected.origin === "manual" ? t("Created manually") : t("Workflow session {session}", { session: selected.session_id ?? "" })}</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>} {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("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> <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.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>} {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> </dl>
<div className="flex gap-2"><Button disabled={busy} onClick={() => setDraft(memoryInput(selected))}>{t("Edit card")}</Button> {!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> <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 && 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> <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>} <Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>{t("Keep card")}</Button></div></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 type { Components } from "react-markdown";
import { renderMermaid } from "./mermaid"; import { renderMermaid } from "./mermaid";
import { useShell } from "../shell/host/ShellProvider"; import { useShell } from "../shell/host/ShellProvider";
import { readableParagraphs } from "./readableParagraphs";
function MermaidBlock({ code }: { code: string }) { function MermaidBlock({ code }: { code: string }) {
const { theme } = useShell(); 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 ( return (
<div className="markdown-view thot-prose"> <div className={`markdown-view thot-prose${readable ? " thot-knowledge-prose" : ""}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> <ReactMarkdown remarkPlugins={readable ? [remarkGfm, readableParagraphs] : [remarkGfm]} components={components}>
{source} {source}
</ReactMarkdown> </ReactMarkdown>
</div> </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;
});
};
}