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
+ );
+}
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 (
+