fix(ui): improve knowledge reading and add isolated formatting examples
This commit is contained in:
@@ -351,6 +351,19 @@ default, hover, focus, active, disabled, loading, and error behavior where those
|
||||
|
||||
### 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,
|
||||
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
|
||||
|
||||
@@ -48,6 +48,20 @@ PSD Gitea push and production deployment are still pending.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<kind>/</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" }));
|
||||
|
||||
@@ -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,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;
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user