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