diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index a8053f7e..9c742336 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -21,7 +21,7 @@ import { } from "../api/sessions"; import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; /** * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar @@ -157,6 +157,7 @@ export function AppShell() { const pendingWidget = useSessionStore((s) => s.pendingWidget); const resetSession = useSessionStore((s) => s.resetSession); const setPhase = useSessionStore((s) => s.setPhase); + const lastSystemEvent = useSessionStore((s) => s.lastSystemEvent); const working = Boolean(activeSessionId) && !pendingWidget; // Processing time counts only while the harness works, not while a finalized // session sits idle or a gate awaits the reviewer (pendingWidget). @@ -164,6 +165,18 @@ export function AppShell() { useSessionStream(activeSessionId); + // A backend "session_exit" system event (e.g. the replay server emitting it + // when the reviewer picks "Esci") asks us to leave the live session view and + // return to the landing state. We deliberately do NOT also POST /close here — + // stopSession already does, and this effect must stay side-effect-free on the + // backend if the session is already inactive. + useEffect(() => { + if (lastSystemEvent?.type === "system_event" && (lastSystemEvent as any).event === "session_exit") { + stopSession(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastSystemEvent]); + function startNewSession() { resetSession(); setActiveSessionId(null); @@ -206,7 +219,7 @@ export function AppShell() { > {showActivity ? : } -
+
-
+
{activeSessionId ? ( <> @@ -231,7 +244,7 @@ export function AppShell() { {/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
-
+
e.stopPropagation()} > diff --git a/frontend/src/shell/WidgetHost.tsx b/frontend/src/shell/WidgetHost.tsx index 72dbc9cf..b8c8867e 100644 --- a/frontend/src/shell/WidgetHost.tsx +++ b/frontend/src/shell/WidgetHost.tsx @@ -37,7 +37,7 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) { // widget render. resetKeys on the descriptor id so the next gate starts clean. return ( - + ); } diff --git a/frontend/src/widgets/ArtifactGateWidget.test.tsx b/frontend/src/widgets/ArtifactGateWidget.test.tsx index 7c7eb102..8008d59c 100644 --- a/frontend/src/widgets/ArtifactGateWidget.test.tsx +++ b/frontend/src/widgets/ArtifactGateWidget.test.tsx @@ -1,11 +1,30 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; import { ArtifactGateWidget } from "./ArtifactGateWidget"; vi.mock("../viewers/mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue(''), })); +vi.mock("../api/sessions", () => ({ + getSessionDocuments: vi.fn().mockResolvedValue([ + { + phase: "F3", + key: "revised_question", + title: "Revised question", + format: "markdown", + content: "## Domanda\n\nQuanti pazienti hanno avuto un impianto?", + }, + ]), +})); + +function withClient(ui: ReactElement): ReactElement { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {ui}; +} + test("renders inside a dialog and shows the artifact via ArtifactView (data path)", () => { const onRespond = vi.fn(); render( @@ -24,6 +43,29 @@ test("renders inside a dialog and shows the artifact via ArtifactView (data path expect(screen.getByText("SELECT 42")).toBeInTheDocument(); }); +test("a file-reference artifact resolves to the session document, rendered with its path", async () => { + render( + withClient( + + ) + ); + expect(await screen.findByText(/Quanti pazienti/)).toBeInTheDocument(); + expect(screen.getByText("sessions/s-123/question.md")).toBeInTheDocument(); + // The raw filename reference is no longer shown as the whole body. + expect(screen.queryByText("question.md", { exact: true })).toBeNull(); +}); + test("clicking an option without opens responds immediately", async () => { const onRespond = vi.fn(); render( diff --git a/frontend/src/widgets/ArtifactGateWidget.tsx b/frontend/src/widgets/ArtifactGateWidget.tsx index 13c51e09..59cd471e 100644 --- a/frontend/src/widgets/ArtifactGateWidget.tsx +++ b/frontend/src/widgets/ArtifactGateWidget.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import type { WidgetProps } from "./types"; import type { UiResponse, WidgetDescriptor } from "../api/types"; import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog"; -import { ArtifactView } from "../viewers/ArtifactView"; +import { GateArtifactBody } from "./GateArtifactBody"; import { ReservedControls } from "./ReservedControls"; import { LinkageHost } from "./LinkageHost"; @@ -13,7 +13,7 @@ import { LinkageHost } from "./LinkageHost"; * dialog has no close button and is not closeable by Esc/backdrop — the only way * out is an action or a reserved control, both of which call onRespond. */ -export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) { +export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetProps) { const [pendingLinkage, setPendingLinkage] = useState<{ parentResponse: UiResponse; childDescriptor: WidgetDescriptor; @@ -36,7 +36,7 @@ export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.artifact ? ( - + ) : (

No artifact.

)} diff --git a/frontend/src/widgets/GateArtifactBody.tsx b/frontend/src/widgets/GateArtifactBody.tsx new file mode 100644 index 00000000..ae33d43b --- /dev/null +++ b/frontend/src/widgets/GateArtifactBody.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getSessionDocuments } from "../api/sessions"; +import type { SessionDocument } from "../api/types"; +import { ArtifactView } from "../viewers/ArtifactView"; +import { MarkdownView } from "../viewers/MarkdownView"; +import { resolveFileArtifact } from "./gateArtifact"; + +type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown }; + +/** + * Body of an artifact-review gate. Most gates embed their content and render via + * ArtifactView. A phase-completion gate, however, may carry only a file reference + * (e.g. `{ file: "question.md" }`): in that case we fetch the referenced session + * document and render its content in full, with the session-relative path as a + * quiet trailing line. Anything we cannot resolve falls back to ArtifactView, so + * no existing gate regresses. + */ +export function GateArtifactBody({ + artifact, + sessionId, +}: { + artifact: ArtifactData; + sessionId?: string; +}): ReactElement { + const ref = resolveFileArtifact(artifact); + if (!sessionId || !ref) return ; + return ( + } + /> + ); +} + +function ResolvedDoc({ + sessionId, + docKey, + filename, + fallback, +}: { + sessionId: string; + docKey: string; + filename: string; + fallback: ReactElement; +}): ReactElement { + const { data: docs, isLoading, isError } = useQuery({ + queryKey: ["session-documents", sessionId], + queryFn: () => getSessionDocuments(sessionId), + }); + if (isLoading) return

Loading…

; + const doc = docs?.find((d) => d.key === docKey); + if (isError || !doc || doc.format !== "markdown") return fallback; + return ( +
+ +

+ sessions/{sessionId}/{filename} +

+
+ ); +} diff --git a/frontend/src/widgets/IntroBody.tsx b/frontend/src/widgets/IntroBody.tsx new file mode 100644 index 00000000..69293757 --- /dev/null +++ b/frontend/src/widgets/IntroBody.tsx @@ -0,0 +1,42 @@ +import { parseIntroBlocks, splitLead } from "./introFormat"; + +/** + * Renders the body of a gate widget: paragraphs plus real bullet lists with a + * hanging indent, and the leading term of each bullet emphasised. Fills the + * card width (no narrow measure cap) so the text runs close to the border. + */ +export function IntroBody({ text }: { text: string }) { + const blocks = parseIntroBlocks(text); + return ( +
+ {blocks.map((b, i) => + b.kind === "para" ? ( +

{b.text}

+ ) : ( +
    + {b.items.map((item, j) => { + const lead = splitLead(item); + return ( +
  • + {lead ? ( + <> + + {lead.lead} + + {lead.rest} + + ) : ( + item + )} +
  • + ); + })} +
+ ) + )} +
+ ); +} diff --git a/frontend/src/widgets/SelectWidget.test.tsx b/frontend/src/widgets/SelectWidget.test.tsx index aaf9afa9..82b8f971 100644 --- a/frontend/src/widgets/SelectWidget.test.tsx +++ b/frontend/src/widgets/SelectWidget.test.tsx @@ -9,6 +9,26 @@ test("picking an option responds with its id", async () => { await userEvent.click(screen.getByRole("button", { name: /A/ })); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] }); }); +test("intro bullets render as a list with the lead term bolded", () => { + render( + + ); + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(2); + // Lead term is emphasised via , remainder stays normal weight. + const lead = screen.getByText("fact_impianto"); + expect(lead.tagName).toBe("STRONG"); + // The "•" is now a CSS marker, not literal text in the content. + expect(screen.queryByText(/•/)).toBeNull(); +}); test("a reserved control responds with control, not a choice", async () => { const onRespond = vi.fn(); render(); diff --git a/frontend/src/widgets/SelectWidget.tsx b/frontend/src/widgets/SelectWidget.tsx index 1c476acd..6d0b3816 100644 --- a/frontend/src/widgets/SelectWidget.tsx +++ b/frontend/src/widgets/SelectWidget.tsx @@ -1,5 +1,6 @@ import type { WidgetProps } from "./types"; import { ReservedControls } from "./ReservedControls"; +import { IntroBody } from "./IntroBody"; export function SelectWidget({ descriptor, onRespond }: WidgetProps) { return ( @@ -11,11 +12,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) { {descriptor.title}

)} - {descriptor.intro && ( -

- {descriptor.intro} -

- )} + {descriptor.intro && }
)}
diff --git a/frontend/src/widgets/gateArtifact.test.ts b/frontend/src/widgets/gateArtifact.test.ts new file mode 100644 index 00000000..17ea9f85 --- /dev/null +++ b/frontend/src/widgets/gateArtifact.test.ts @@ -0,0 +1,23 @@ +import { resolveFileArtifact } from "./gateArtifact"; + +test("resolves a bare question.md file reference to its document key", () => { + expect(resolveFileArtifact({ kind: "phase", data: { file: "question.md" } })).toEqual({ + filename: "question.md", + docKey: "revised_question", + }); +}); + +test("resolves a path reference by its basename", () => { + expect( + resolveFileArtifact({ kind: "phase", data: { path: "sessions/abc/question.md" } }) + ).toEqual({ filename: "question.md", docKey: "revised_question" }); +}); + +test("returns null for inline string content (nothing to resolve)", () => { + expect(resolveFileArtifact({ kind: "sql", data: "SELECT 42" })).toBeNull(); + expect(resolveFileArtifact({ kind: "cte", content: "SELECT 1" })).toBeNull(); +}); + +test("returns null for an unmapped file", () => { + expect(resolveFileArtifact({ kind: "phase", data: { file: "notes.txt" } })).toBeNull(); +}); diff --git a/frontend/src/widgets/gateArtifact.ts b/frontend/src/widgets/gateArtifact.ts new file mode 100644 index 00000000..3f7db79b --- /dev/null +++ b/frontend/src/widgets/gateArtifact.ts @@ -0,0 +1,33 @@ +// A phase-completion gate sometimes arrives with an artifact that is only a file +// *reference* (e.g. `{ kind: "phase", data: { file: "question.md" } }`) instead of +// the embedded content. The full content is already served by the session-documents +// endpoint, so we resolve the referenced file to the document key that carries it. + +// Filename -> session-document key (see harness build_documents). Only markdown +// documents are listed: those we can render in full from the reference alone. +export const DOC_KEY_BY_FILE: Record = { + "question.md": "revised_question", + "validation_report.md": "validation_report", +}; + +type ArtifactLike = { data?: unknown; content?: unknown; [k: string]: unknown }; + +/** + * If the artifact is a bare file reference we can resolve, return the filename and + * the session-document key holding its content. Returns null when the artifact + * carries inline content (nothing to resolve) or points at an unmapped file. + */ +export function resolveFileArtifact( + artifact: ArtifactLike +): { filename: string; docKey: string } | null { + const payload = artifact.data ?? artifact.content; + const rec = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; + const raw = rec?.file ?? rec?.path ?? artifact.file ?? artifact.path; + if (typeof raw !== "string") return null; + const filename = raw.split("/").pop() || raw; + const docKey = DOC_KEY_BY_FILE[filename]; + return docKey ? { filename, docKey } : null; +} diff --git a/frontend/src/widgets/introFormat.test.ts b/frontend/src/widgets/introFormat.test.ts new file mode 100644 index 00000000..2644b554 --- /dev/null +++ b/frontend/src/widgets/introFormat.test.ts @@ -0,0 +1,47 @@ +import { parseIntroBlocks, splitLead } from "./introFormat"; + +test("splits paragraphs and bullet lists on blank lines", () => { + const blocks = parseIntroBlocks( + "Domanda: «X».\n\nEcco le famiglie:\n• uno → A\n• due → B\n\nConclusione." + ); + expect(blocks).toEqual([ + { kind: "para", text: "Domanda: «X»." }, + { kind: "para", text: "Ecco le famiglie:" }, + { kind: "list", items: ["uno → A", "due → B"] }, + { kind: "para", text: "Conclusione." }, + ]); +}); + +test("joins soft-wrapped paragraph lines into one flowing paragraph", () => { + const blocks = parseIntroBlocks("prima riga\nseconda riga"); + expect(blocks).toEqual([{ kind: "para", text: "prima riga seconda riga" }]); +}); + +test("accepts -, * and • as bullet markers", () => { + const blocks = parseIntroBlocks("- a\n* b\n• c"); + expect(blocks).toEqual([{ kind: "list", items: ["a", "b", "c"] }]); +}); + +test("bolds the lead term up to an arrow separator", () => { + expect(splitLead("fact_impianto → IMPIANTO INIZIALE (dettaglio)")).toEqual({ + lead: "fact_impianto", + rest: " → IMPIANTO INIZIALE (dettaglio)", + }); +}); + +test("bolds the lead term up to a colon separator", () => { + expect(splitLead("Nota: i controlli non sono impianto")).toEqual({ + lead: "Nota", + rest: ": i controlli non sono impianto", + }); +}); + +test("does not bold when there is no separator", () => { + expect(splitLead("un punto elenco semplice")).toBeNull(); +}); + +test("does not bold a long leading clause", () => { + expect( + splitLead("questa è una frase lunga con molte parole prima del: due punti") + ).toBeNull(); +}); diff --git a/frontend/src/widgets/introFormat.ts b/frontend/src/widgets/introFormat.ts new file mode 100644 index 00000000..78a7c69c --- /dev/null +++ b/frontend/src/widgets/introFormat.ts @@ -0,0 +1,71 @@ +// Pure, LLM-free formatting for the intro/body of a gate widget. +// The body arrives as a plain string with blank-line-separated paragraphs and +// bullet lines prefixed by "•", "-" or "*". We fold it into structured blocks +// so the renderer can draw a real list (hanging indent) and emphasise the +// leading term of each bullet. + +export type IntroBlock = + | { kind: "para"; text: string } + | { kind: "list"; items: string[] }; + +const BULLET_RE = /^\s*[•\-*]\s+/; + +/** Fold raw intro text into paragraph and list blocks. */ +export function parseIntroBlocks(intro: string): IntroBlock[] { + const lines = intro.replace(/\r\n/g, "\n").split("\n"); + const blocks: IntroBlock[] = []; + let para: string[] = []; + let list: string[] = []; + + const flushPara = () => { + if (para.length) { + blocks.push({ kind: "para", text: para.join(" ").trim() }); + para = []; + } + }; + const flushList = () => { + if (list.length) { + blocks.push({ kind: "list", items: list }); + list = []; + } + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (BULLET_RE.test(line)) { + flushPara(); + list.push(line.replace(BULLET_RE, "").trim()); + } else if (line.trim() === "") { + flushPara(); + flushList(); + } else { + flushList(); + para.push(line.trim()); + } + } + flushPara(); + flushList(); + return blocks; +} + +// Separators that mark the end of a leading term ("term → definition", +// "term: definition"). Ordered longest-first is not needed since indexOf picks +// the earliest match across all of them. +const LEAD_SEPARATORS = [" → ", " -> ", " — ", " – ", ": "]; + +/** + * Split a bullet into its leading term (to emphasise) and the remainder, + * or return null when there is no clear short lead worth bolding. + */ +export function splitLead(item: string): { lead: string; rest: string } | null { + let at = -1; + for (const sep of LEAD_SEPARATORS) { + const idx = item.indexOf(sep); + if (idx > 0 && (at === -1 || idx < at)) at = idx; + } + if (at === -1) return null; + const lead = item.slice(0, at); + // Only emphasise a genuinely short lead: a label, not half a sentence. + if (lead.length > 60 || lead.trim().split(/\s+/).length > 6) return null; + return { lead, rest: item.slice(at) }; +} diff --git a/frontend/src/widgets/types.ts b/frontend/src/widgets/types.ts index ccf6c382..737476b8 100644 --- a/frontend/src/widgets/types.ts +++ b/frontend/src/widgets/types.ts @@ -1,3 +1,7 @@ import type { WidgetDescriptor, UiResponse } from "../api/types"; -export type WidgetProps = { descriptor: WidgetDescriptor; onRespond: (r: UiResponse) => void }; +export type WidgetProps = { + descriptor: WidgetDescriptor; + onRespond: (r: UiResponse) => void; + sessionId?: string; +};