feat(frontend): richer gate bodies + replay-friendly session exit

Improves how reviewer gates render their content, and adds two small
hooks that make the frontend cooperate with the standalone replay server
(also useful as generic primitives).

Gate body rendering:
- IntroBody: gate intros now render paragraphs plus real bullet lists
  with a hanging indent and emphasised bullet lead term, filling the
  card width (no narrow measure cap). Backed by introFormat (parser) +
  IntroBody (renderer), with tests.
- GateArtifactBody: artifact-review gates whose artifact is a bare file
  reference (e.g. {kind:"phase", data:{file:"question.md"}}) now fetch
  the referenced session document and render its full content, instead
  of showing the file reference as text. Falls back to ArtifactView for
  inline-content artifacts and unmapped files. Backed by gateArtifact
  (resolver) with tests. ArtifactGateWidget passes sessionId through.
- SelectWidget renders intros via IntroBody.

Replay-friendly session exit:
- AppShell: a system_event {event:"session_exit"} now triggers
  stopSession(), returning to the landing view. Generic primitive (the
  real backend can emit it too); used by the replay server's "Esci".
- SessionMenu: the ⋮ trigger is now always visible (was opacity-0 /
  group-hover only), so Resume is reachable without hovering.

Verified offline against the replay server (no VPN needed); tsc clean.
This commit is contained in:
2026-07-05 18:25:02 +02:00
parent c3a3cb8da5
commit e11ffecfc4
14 changed files with 371 additions and 15 deletions
+17 -4
View File
@@ -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 ? <ArrowLeft className="size-4" /> : <ArrowRight className="size-4" />}
</button>
<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-4xl">
<WorkflowBar
finalized={finalized}
running={running}
@@ -217,7 +230,7 @@ export function AppShell() {
)}
<main className="flex-1 overflow-y-auto px-6 py-8">
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<div className="mx-auto flex max-w-4xl flex-col gap-6">
{activeSessionId ? (
<>
<CentralStatus working={working} />
@@ -231,7 +244,7 @@ export function AppShell() {
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
<div className="shrink-0 px-6 pb-6 pt-2">
<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-4xl">
<div className="rounded-2xl border border-border/80 bg-card shadow-md transition-colors focus-within:border-primary/50 focus-within:ring-3 focus-within:ring-ring/15">
<div className="px-2.5 py-2">
<SteerInput
+1 -1
View File
@@ -25,7 +25,7 @@ export function SessionMenu({ session, groups, onResume, onView, onRename, onMov
<Menu.Root>
<Menu.Trigger
aria-label="Session actions"
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-popup-open:opacity-100"
className="rounded-md p-1 text-muted-foreground transition-opacity hover:bg-accent data-popup-open:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="size-4" />
+1 -1
View File
@@ -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 (
<ErrorBoundary resetKeys={[pending.id]} label="step">
<Renderer descriptor={pending} onRespond={onRespond} />
<Renderer descriptor={pending} onRespond={onRespond} sessionId={sessionId ?? undefined} />
</ErrorBoundary>
);
}
@@ -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('<svg data-testid="mm"></svg>'),
}));
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 <QueryClientProvider client={qc}>{ui}</QueryClientProvider>;
}
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(
<ArtifactGateWidget
descriptor={{
id: "u1",
session_id: "s-123",
widget: "artifact-gate",
title: "Fase 3 completata",
artifact: { kind: "phase", data: { file: "question.md" } },
options: [{ id: "approve", label: "Salva e procedi" }],
}}
onRespond={vi.fn()}
sessionId="s-123"
/>
)
);
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(
+3 -3
View File
@@ -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) {
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-3">
{descriptor.artifact ? (
<ArtifactView artifact={descriptor.artifact} />
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
) : (
<p className="text-sm text-muted-foreground">No artifact.</p>
)}
+64
View File
@@ -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 <ArtifactView artifact={artifact} />;
return (
<ResolvedDoc
sessionId={sessionId}
docKey={ref.docKey}
filename={ref.filename}
fallback={<ArtifactView artifact={artifact} />}
/>
);
}
function ResolvedDoc({
sessionId,
docKey,
filename,
fallback,
}: {
sessionId: string;
docKey: string;
filename: string;
fallback: ReactElement;
}): ReactElement {
const { data: docs, isLoading, isError } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", sessionId],
queryFn: () => getSessionDocuments(sessionId),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
const doc = docs?.find((d) => d.key === docKey);
if (isError || !doc || doc.format !== "markdown") return fallback;
return (
<div className="flex flex-col gap-3">
<MarkdownView source={doc.content} />
<p className="border-t border-border/50 pt-2 text-right text-xs italic text-muted-foreground">
sessions/{sessionId}/{filename}
</p>
</div>
);
}
+42
View File
@@ -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 (
<div className="space-y-2.5 text-sm leading-relaxed text-muted-foreground">
{blocks.map((b, i) =>
b.kind === "para" ? (
<p key={i}>{b.text}</p>
) : (
<ul
key={i}
className="list-disc space-y-1.5 pl-5 marker:text-muted-foreground/50"
>
{b.items.map((item, j) => {
const lead = splitLead(item);
return (
<li key={j} className="pl-1">
{lead ? (
<>
<strong className="font-semibold text-foreground">
{lead.lead}
</strong>
{lead.rest}
</>
) : (
item
)}
</li>
);
})}
</ul>
)
)}
</div>
);
}
@@ -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(
<SelectWidget
descriptor={{
id: "u1",
widget: "select",
intro: "Le famiglie:\n• fact_impianto → IMPIANTO INIZIALE\n• fact_controllo → CONTROLLO",
options: [{ id: "a", label: "A" }],
}}
onRespond={vi.fn()}
/>
);
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(2);
// Lead term is emphasised via <strong>, 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(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }], reserved: ["back"] }} onRespond={onRespond} />);
+2 -5
View File
@@ -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}
</p>
)}
{descriptor.intro && (
<p className="max-w-[68ch] whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground">
{descriptor.intro}
</p>
)}
{descriptor.intro && <IntroBody text={descriptor.intro} />}
</div>
)}
<div className="flex flex-col gap-2">
+23
View File
@@ -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();
});
+33
View File
@@ -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<string, string> = {
"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<string, unknown>)
: 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;
}
+47
View File
@@ -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();
});
+71
View File
@@ -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) };
}
+5 -1
View File
@@ -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;
};