From 16b0f1d79cbe394796b7899102d666fad137447b Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:28:39 -0700 Subject: [PATCH 1/6] Serve the run report as an MCP App so hosts can reuse the web UI. Co-authored-by: Cursor --- web/.gitignore | 3 + .../[instrumentId]/runs/[runId]/page.tsx | 87 +- web/app/mcp/v1/route.ts | 21 +- .../runs/aunty/aunty-plate-report.tsx | 31 +- .../runs/aunty/aunty-well-dialog.tsx | 69 +- web/components/runs/colony-data-table.tsx | 59 +- web/components/runs/image-carousel-report.tsx | 25 +- web/components/runs/pdf-carousel-report.tsx | 23 +- web/components/runs/raman-report-section.tsx | 13 +- web/components/runs/raman-spectrum-viewer.tsx | 29 +- .../runs/report-data-source-provider.tsx | 53 + web/components/runs/report-items-provider.tsx | 32 +- web/components/runs/run-report-section.tsx | 98 +- web/components/runs/run-video-player.tsx | 57 +- .../runs/variants/dishcam-run-detail.tsx | 2 - .../runs/variants/gel-doc-run-detail.tsx | 6 +- .../variants/hina-microscope-run-detail.tsx | 6 +- .../variants/instant-raman-run-detail.tsx | 6 +- .../runs/variants/tape-station-run-detail.tsx | 6 +- web/components/runs/video-carousel-report.tsx | 9 +- web/hooks/use-report-items.ts | 70 +- web/hooks/use-resolved-file-url.ts | 46 + web/lib/api/files.ts | 46 +- web/lib/mcp/absolute-url.ts | 12 + web/lib/mcp/catalog/register.ts | 7 + web/lib/mcp/catalog/tools.ts | 2 + web/lib/mcp/catalog/types.ts | 6 + web/lib/mcp/cors.ts | 26 + web/lib/mcp/resources.defs.ts | 14 + web/lib/mcp/resources.ts | 28 + web/lib/mcp/run-report-html.ts | 74 + web/lib/mcp/tools/index.ts | 2 + web/lib/mcp/tools/report-views.defs.ts | 123 ++ web/lib/mcp/tools/report-views.output.ts | 32 + web/lib/mcp/tools/report-views.ts | 256 ++++ web/lib/mcp/tools/runs.defs.ts | 7 + web/lib/mcp/ui-apps.ts | 24 + web/lib/mcp/ui-csp.ts | 48 + web/lib/runs/report-table.ts | 35 + web/lib/runs/rest-report-data-source.ts | 75 + web/lib/runs/view-data-source.ts | 21 + web/mcp-apps/run-report.css | 45 + web/mcp-apps/run-report.html | 13 + web/mcp-apps/run-report/app.tsx | 193 +++ web/mcp-apps/run-report/data-hub-origin.ts | 14 + web/mcp-apps/run-report/host-bridge.ts | 205 +++ web/mcp-apps/run-report/instrument-report.tsx | 310 ++++ web/mcp-apps/run-report/main.tsx | 18 + web/mcp-apps/run-report/mcp-data-source.ts | 124 ++ web/mcp-apps/vite.config.ts | 25 + web/package-lock.json | 1296 ++++++++++++----- web/package.json | 9 +- web/proxy.ts | 4 + web/tests/integration/mcp.test.ts | 2 +- web/tests/mcp/mcp-catalog.test.ts | 2 +- web/tests/mcp/mcp-protocol.test.ts | 108 +- .../unit/mcp-container-dimensions.test.ts | 44 + web/tests/unit/mcp-cors.test.ts | 25 + .../unit/rest-report-data-source.test.ts | 108 ++ 59 files changed, 3452 insertions(+), 682 deletions(-) create mode 100644 web/components/runs/report-data-source-provider.tsx create mode 100644 web/hooks/use-resolved-file-url.ts create mode 100644 web/lib/mcp/absolute-url.ts create mode 100644 web/lib/mcp/cors.ts create mode 100644 web/lib/mcp/run-report-html.ts create mode 100644 web/lib/mcp/tools/report-views.defs.ts create mode 100644 web/lib/mcp/tools/report-views.output.ts create mode 100644 web/lib/mcp/tools/report-views.ts create mode 100644 web/lib/mcp/ui-apps.ts create mode 100644 web/lib/mcp/ui-csp.ts create mode 100644 web/lib/runs/report-table.ts create mode 100644 web/lib/runs/rest-report-data-source.ts create mode 100644 web/lib/runs/view-data-source.ts create mode 100644 web/mcp-apps/run-report.css create mode 100644 web/mcp-apps/run-report.html create mode 100644 web/mcp-apps/run-report/app.tsx create mode 100644 web/mcp-apps/run-report/data-hub-origin.ts create mode 100644 web/mcp-apps/run-report/host-bridge.ts create mode 100644 web/mcp-apps/run-report/instrument-report.tsx create mode 100644 web/mcp-apps/run-report/main.tsx create mode 100644 web/mcp-apps/run-report/mcp-data-source.ts create mode 100644 web/mcp-apps/vite.config.ts create mode 100644 web/tests/unit/mcp-container-dimensions.test.ts create mode 100644 web/tests/unit/mcp-cors.test.ts create mode 100644 web/tests/unit/rest-report-data-source.test.ts diff --git a/web/.gitignore b/web/.gitignore index 120b8df9..c895a52b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -43,3 +43,6 @@ next-env.d.ts # Optional local dump from `npm run mcp-catalog:generate` (same idea; # production serves GET /mcp/v1/schema.json). /mcp-catalog.json + +# Self-contained MCP Apps View (built by `npm run mcp-apps:build`). +/mcp-apps/dist/ diff --git a/web/app/(app)/instruments/[instrumentId]/runs/[runId]/page.tsx b/web/app/(app)/instruments/[instrumentId]/runs/[runId]/page.tsx index fa2e14b8..22c50592 100644 --- a/web/app/(app)/instruments/[instrumentId]/runs/[runId]/page.tsx +++ b/web/app/(app)/instruments/[instrumentId]/runs/[runId]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import type { Metadata } from "next/types"; import { Suspense } from "react"; import { SignInRequired } from "@/components/auth/sign-in-required"; +import { RestReportDataSourceProvider } from "@/components/runs/report-data-source-provider"; import { RunAttributionsSection } from "@/components/runs/run-attributions-section"; import { RunCommentsSection } from "@/components/runs/run-comments-section"; import { @@ -207,48 +208,50 @@ async function RunDetailContentBody({ `/instruments/${instrumentId}/runs/${encodeURIComponent(rid)}`; return ( - - - } - auntyPlate={auntyPlate} - fileStats={fileStats} - files={filesPage.data} - filesDownloadableCount={filesPage.downloadableCount} - filesPagination={filesPage.pagination} - instrumentId={instrumentId} - reportFiles={reportFiles} - reportItems={reportItems} - run={run} - runId={runId} - runNavSlot={ - - } - wellData={wellData} - /> - + + + + } + auntyPlate={auntyPlate} + fileStats={fileStats} + files={filesPage.data} + filesDownloadableCount={filesPage.downloadableCount} + filesPagination={filesPage.pagination} + instrumentId={instrumentId} + reportFiles={reportFiles} + reportItems={reportItems} + run={run} + runId={runId} + runNavSlot={ + + } + wellData={wellData} + /> + + ); } diff --git a/web/app/mcp/v1/route.ts b/web/app/mcp/v1/route.ts index 4e93d457..d4699518 100644 --- a/web/app/mcp/v1/route.ts +++ b/web/app/mcp/v1/route.ts @@ -2,6 +2,7 @@ import { createMcpHandler, withMcpAuth } from "mcp-handler"; import { authBaseURL } from "@/lib/auth"; import { MCP_ADVERTISED_SCOPES } from "@/lib/mcp/advertised-scopes"; import { verifyMcpToken } from "@/lib/mcp/auth"; +import { mcpCorsPreflight, withMcpCors } from "@/lib/mcp/cors"; import { MCP_SERVER_INSTRUCTIONS } from "@/lib/mcp/instructions"; import { registerPrompts } from "@/lib/mcp/prompts"; import { registerResources } from "@/lib/mcp/resources"; @@ -49,19 +50,25 @@ async function authHandler(req: Request): Promise { const res = await mcpAuthHandler(req); const challenge = res.headers.get("WWW-Authenticate"); if (!challenge || challenge.includes(ADVERTISED_SCOPES)) { - return res; + return withMcpCors(res); } const rewritten = challenge.replace(/scope="[^"]*"/, ADVERTISED_SCOPES); if (rewritten === challenge) { - return res; + return withMcpCors(res); } const headers = new Headers(res.headers); headers.set("WWW-Authenticate", rewritten); - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); + return withMcpCors( + new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }) + ); +} + +export function OPTIONS(): Response { + return mcpCorsPreflight(); } export { authHandler as GET, authHandler as POST }; diff --git a/web/components/runs/aunty/aunty-plate-report.tsx b/web/components/runs/aunty/aunty-plate-report.tsx index 2cc51215..b1d790d2 100644 --- a/web/components/runs/aunty/aunty-plate-report.tsx +++ b/web/components/runs/aunty/aunty-plate-report.tsx @@ -1,7 +1,6 @@ "use client"; -import dynamic from "next/dynamic"; -import { useCallback, useMemo, useState } from "react"; +import { lazy, Suspense, useCallback, useMemo, useState } from "react"; import { AuntyPlateGrid } from "@/components/runs/aunty/aunty-plate-grid"; import { AuntySeriesToggle } from "@/components/runs/aunty/aunty-series-toggle"; import { AuntyWellsProvider } from "@/components/runs/aunty/aunty-wells-provider"; @@ -15,10 +14,10 @@ import { // The dialog pulls in Recharts and the CSV parser, which nothing on the page // needs until a well is opened, so they load as a separate chunk. -const AuntyWellDialog = dynamic(() => - import("@/components/runs/aunty/aunty-well-dialog").then( - (mod) => mod.AuntyWellDialog - ) +const AuntyWellDialog = lazy(() => + import("@/components/runs/aunty/aunty-well-dialog").then((mod) => ({ + default: mod.AuntyWellDialog, + })) ); export function AuntyPlateReport({ curvesFileId, plate }: AuntyPlateData) { @@ -87,15 +86,17 @@ function AuntyExperimentSection({ onWellClick={openDialog} seriesId={seriesId} /> - + + + ); diff --git a/web/components/runs/aunty/aunty-well-dialog.tsx b/web/components/runs/aunty/aunty-well-dialog.tsx index fe0d0de3..b1e673a8 100644 --- a/web/components/runs/aunty/aunty-well-dialog.tsx +++ b/web/components/runs/aunty/aunty-well-dialog.tsx @@ -1,6 +1,5 @@ "use client"; -import { parse } from "csv-parse/browser/esm/sync"; import { AlertTriangle } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { AuntySeriesToggle } from "@/components/runs/aunty/aunty-series-toggle"; @@ -9,6 +8,7 @@ import { useAuntyWellsActions, useAuntyWellsState, } from "@/components/runs/aunty/aunty-wells-provider"; +import { useReportDataSource } from "@/components/runs/report-data-source-provider"; import { SeekerToolbar } from "@/components/runs/report-item-seeker"; import { Button } from "@/components/ui/button"; import { @@ -28,6 +28,8 @@ import { parseAuntyCurvesCsv, presentWellValues, } from "@/lib/runs/aunty"; +import { fetchAllTableRows } from "@/lib/runs/report-table"; +import type { ReportDataSource } from "@/lib/runs/view-data-source"; const WELL_SEEKER_LABELS = { empty: "No wells found.", @@ -48,29 +50,22 @@ type CurvesState = const curvesCache = new Map>>(); async function downloadCurves( + dataSource: ReportDataSource, fileId: number ): Promise> { - // The download endpoint 302-redirects to a short-lived presigned S3 URL, so - // the browser follows the redirect and reads the bytes straight from S3. - const res = await fetch(`/api/v1/files/${fileId}/download`); - if (!res.ok) { - throw new Error(`Failed to load curves (HTTP ${res.status})`); - } - const text = await res.text(); - const rows = parse(text, { - columns: true, - skip_empty_lines: true, - trim: true, - }) as Record[]; + const { rows } = await fetchAllTableRows(dataSource, fileId); return indexAuntyCurves(parseAuntyCurvesCsv(rows)); } -function loadCurves(fileId: number): Promise> { +function loadCurves( + dataSource: ReportDataSource, + fileId: number +): Promise> { const cached = curvesCache.get(fileId); if (cached) { return cached; } - const pending = downloadCurves(fileId).catch((err: unknown) => { + const pending = downloadCurves(dataSource, fileId).catch((err: unknown) => { curvesCache.delete(fileId); throw err; }); @@ -95,6 +90,7 @@ export function AuntyWellDialog({ seriesId: AuntySeriesId; seriesOptions: AuntySeriesId[]; }) { + const dataSource = useReportDataSource(); const state = useAuntyWellsState(); const actions = useAuntyWellsActions(); const selectedWellLabel = state.selectedItem?.filename ?? null; @@ -103,26 +99,29 @@ export function AuntyWellDialog({ const [curves, setCurves] = useState({ status: "idle" }); const requestId = useRef(0); - const startLoad = useCallback((fileId: number) => { - const id = requestId.current + 1; - requestId.current = id; - setCurves({ status: "loading" }); - loadCurves(fileId) - .then((index) => { - if (requestId.current === id) { - setCurves({ status: "ready", index }); - } - }) - .catch((err: unknown) => { - if (requestId.current === id) { - setCurves({ - status: "error", - message: - err instanceof Error ? err.message : "Failed to load curves", - }); - } - }); - }, []); + const startLoad = useCallback( + (fileId: number) => { + const id = requestId.current + 1; + requestId.current = id; + setCurves({ status: "loading" }); + loadCurves(dataSource, fileId) + .then((index) => { + if (requestId.current === id) { + setCurves({ status: "ready", index }); + } + }) + .catch((err: unknown) => { + if (requestId.current === id) { + setCurves({ + status: "error", + message: + err instanceof Error ? err.message : "Failed to load curves", + }); + } + }); + }, + [dataSource] + ); useEffect(() => { if (open && curvesFileId != null) { diff --git a/web/components/runs/colony-data-table.tsx b/web/components/runs/colony-data-table.tsx index bc47f2f0..a6a17287 100644 --- a/web/components/runs/colony-data-table.tsx +++ b/web/components/runs/colony-data-table.tsx @@ -1,6 +1,5 @@ "use client"; -import { parse } from "csv-parse/browser/esm/sync"; import { AlertTriangle, ChevronLeft, @@ -8,6 +7,7 @@ import { ExternalLink, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useReportDataSource } from "@/components/runs/report-data-source-provider"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -18,27 +18,26 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { RunFile } from "@/lib/api/instrument-runs"; +import { useResolvedFileUrl } from "@/hooks/use-resolved-file-url"; +import { fetchAllTableRows } from "@/lib/runs/report-table"; +import type { ReportDataSource } from "@/lib/runs/view-data-source"; import { cn } from "@/lib/utils"; const PAGE_SIZE = 10; type CsvRow = Record; -async function fetchCsvRows(fileId: number): Promise { - // The download endpoint 302-redirects to a short-lived presigned S3 URL; - // the browser follows transparently, so CSV bytes flow directly from S3 - // with zero Vercel Fast Origin Transfer. - const res = await fetch(`/api/v1/files/${fileId}/download`); - if (!res.ok) { - throw new Error(`Failed to load CSV (HTTP ${res.status})`); - } - const text = await res.text(); - return parse(text, { - columns: true, - skip_empty_lines: true, - trim: true, - }) as CsvRow[]; +export interface ColonyTableFile { + filename: string; + id: number; +} + +async function fetchCsvRows( + dataSource: ReportDataSource, + fileId: number +): Promise { + const { rows } = await fetchAllTableRows(dataSource, fileId); + return rows; } type AsyncResult = @@ -50,8 +49,9 @@ type LoadState = | { status: "ready"; rows: CsvRow[] } | { status: "error"; message: string }; -export function ColonyDataTable({ file }: { file: RunFile }) { - const downloadUrl = `/api/v1/files/${file.id}/download`; +export function ColonyDataTable({ file }: { file: ColonyTableFile }) { + const dataSource = useReportDataSource(); + const downloadUrl = useResolvedFileUrl(file.id); const fileId = file.id; const [asyncResult, setAsyncResult] = useState(null); @@ -82,7 +82,7 @@ export function ColonyDataTable({ file }: { file: RunFile }) { return; } let cancelled = false; - fetchCsvRows(fileId) + fetchCsvRows(dataSource, fileId) .then((rows) => { cacheRef.current.set(fileId, rows); if (cancelled) { @@ -101,7 +101,7 @@ export function ColonyDataTable({ file }: { file: RunFile }) { return () => { cancelled = true; }; - }, [fileId, retryNonce]); + }, [dataSource, fileId, retryNonce]); function handleRetry() { cacheRef.current.delete(fileId); @@ -113,12 +113,19 @@ export function ColonyDataTable({ file }: { file: RunFile }) {

{file.filename}

- + {downloadUrl && ( + + )}
{state.status === "loading" && ( diff --git a/web/components/runs/image-carousel-report.tsx b/web/components/runs/image-carousel-report.tsx index 95135945..717d3e45 100644 --- a/web/components/runs/image-carousel-report.tsx +++ b/web/components/runs/image-carousel-report.tsx @@ -7,9 +7,11 @@ import { type ReportViewerProps, useReportItemsContext, } from "@/components/runs/report-items-provider"; +import { useResolvedFileUrl } from "@/hooks/use-resolved-file-url"; function SelectedImage() { const { state } = useReportItemsContext(); + const src = useResolvedFileUrl(state.selectedItem?.id); if (!state.selectedItem) { return ( @@ -20,6 +22,14 @@ function SelectedImage() { ); } + if (!src) { + return ( +
+ Loading{"\u2026"} +
+ ); + } + return (
{/* biome-ignore lint/performance/noImgElement: auth-gated download URLs are not next/image candidates */} @@ -27,7 +37,7 @@ function SelectedImage() { alt={state.selectedItem.filename} className="mx-auto block max-h-[70vh] w-auto object-contain" height={600} - src={`/api/v1/files/${state.selectedItem.id}/download`} + src={src} width={800} />
@@ -36,19 +46,10 @@ function SelectedImage() { // For instruments whose report data is purely imagery (Hina microscope, gel // doc): one image at a time, seeked against the run's full image set. -export function ImageCarouselReport({ - initialPage, - instrumentId, - runId, -}: ReportViewerProps) { +export function ImageCarouselReport({ initialPage }: ReportViewerProps) { return ( - + diff --git a/web/components/runs/pdf-carousel-report.tsx b/web/components/runs/pdf-carousel-report.tsx index f9d3a7b7..a2adafd9 100644 --- a/web/components/runs/pdf-carousel-report.tsx +++ b/web/components/runs/pdf-carousel-report.tsx @@ -9,9 +9,11 @@ import { useReportItemsContext, } from "@/components/runs/report-items-provider"; import { Button } from "@/components/ui/button"; +import { useResolvedFileUrl } from "@/hooks/use-resolved-file-url"; function SelectedPdf() { const { state } = useReportItemsContext(); + const downloadUrl = useResolvedFileUrl(state.selectedItem?.id); if (!state.selectedItem) { return ( @@ -21,7 +23,13 @@ function SelectedPdf() { ); } - const downloadUrl = `/api/v1/files/${state.selectedItem.id}/download`; + if (!downloadUrl) { + return ( +
+ Loading{"\u2026"} +
+ ); + } return ( <> @@ -49,19 +57,10 @@ function SelectedPdf() { // TapeStation and other PDF-primary instruments: one PDF at a time, seeked // against the run's full PDF set. -export function PdfCarouselReport({ - initialPage, - instrumentId, - runId, -}: ReportViewerProps) { +export function PdfCarouselReport({ initialPage }: ReportViewerProps) { return ( - + diff --git a/web/components/runs/raman-report-section.tsx b/web/components/runs/raman-report-section.tsx index 0830b591..b3ad3836 100644 --- a/web/components/runs/raman-report-section.tsx +++ b/web/components/runs/raman-report-section.tsx @@ -8,19 +8,10 @@ import { type ReportViewerProps, } from "@/components/runs/report-items-provider"; -export function RamanReportSection({ - initialPage, - instrumentId, - runId, -}: ReportViewerProps) { +export function RamanReportSection({ initialPage }: ReportViewerProps) { return ( - + diff --git a/web/components/runs/raman-spectrum-viewer.tsx b/web/components/runs/raman-spectrum-viewer.tsx index 7727e0b3..26600325 100644 --- a/web/components/runs/raman-spectrum-viewer.tsx +++ b/web/components/runs/raman-spectrum-viewer.tsx @@ -1,9 +1,9 @@ "use client"; -import { parse } from "csv-parse/browser/esm/sync"; import { AlertTriangle } from "lucide-react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { useReportDataSource } from "@/components/runs/report-data-source-provider"; import { useReportItemsContext } from "@/components/runs/report-items-provider"; import { Button } from "@/components/ui/button"; import { @@ -14,6 +14,8 @@ import { } from "@/components/ui/chart"; import { Skeleton } from "@/components/ui/skeleton"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { fetchAllTableRows } from "@/lib/runs/report-table"; +import type { ReportDataSource } from "@/lib/runs/view-data-source"; interface SpectrumPoint { intensity: number; @@ -43,21 +45,11 @@ const CSV_HEADER_INTENSITY = "Intensity"; const CSV_HEADER_DARK = "Intensity (dark-subtracted)"; const CSV_HEADER_WAVENUMBER = "Wavenumber"; -async function fetchSpectrum(fileId: number): Promise { - // The download endpoint 302-redirects to a short-lived presigned S3 URL. - // The browser follows the redirect transparently, so the bytes flow - // directly from S3 to the user with zero Vercel Fast Origin Transfer. - const res = await fetch(`/api/v1/files/${fileId}/download`); - if (!res.ok) { - throw new Error(`Failed to load spectrum (HTTP ${res.status})`); - } - const text = await res.text(); - - const rows = parse(text, { - columns: true, - skip_empty_lines: true, - trim: true, - }) as Record[]; +async function fetchSpectrum( + dataSource: ReportDataSource, + fileId: number +): Promise { + const { rows } = await fetchAllTableRows(dataSource, fileId); if (rows.length === 0) { throw new Error("Spectrum CSV is empty"); @@ -220,6 +212,7 @@ type AsyncResult = | { fileId: number; status: "error"; message: string }; export function RamanSpectrumViewer() { + const dataSource = useReportDataSource(); const { state: items } = useReportItemsContext(); const selectedId = items.selectedItem?.id ?? null; const [visible, setVisible] = useState(ALL_SERIES); @@ -262,7 +255,7 @@ export function RamanSpectrumViewer() { } let cancelled = false; - fetchSpectrum(selectedId) + fetchSpectrum(dataSource, selectedId) .then((points) => { cacheRef.current.set(selectedId, points); if (cancelled) { @@ -286,7 +279,7 @@ export function RamanSpectrumViewer() { return () => { cancelled = true; }; - }, [selectedId, retryNonce]); + }, [dataSource, selectedId, retryNonce]); function handleRetry() { if (selectedId == null) { diff --git a/web/components/runs/report-data-source-provider.tsx b/web/components/runs/report-data-source-provider.tsx new file mode 100644 index 00000000..ea2b8023 --- /dev/null +++ b/web/components/runs/report-data-source-provider.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { createContext, type ReactNode, use, useMemo } from "react"; +import { createRestReportDataSource } from "@/lib/runs/rest-report-data-source"; +import type { ReportDataSource } from "@/lib/runs/view-data-source"; + +const ReportDataSourceContext = createContext(null); + +export function ReportDataSourceProvider({ + children, + dataSource, +}: { + children: ReactNode; + dataSource: ReportDataSource; +}) { + return ( + + {children} + + ); +} + +// Created here rather than on the server page because the source is a bag of +// functions and cannot cross the server/client boundary as a prop. +export function RestReportDataSourceProvider({ + children, + instrumentId, + runId, +}: { + children: ReactNode; + instrumentId: string; + runId: string; +}) { + const dataSource = useMemo( + () => createRestReportDataSource({ instrumentId, runId }), + [instrumentId, runId] + ); + return ( + + {children} + + ); +} + +export function useReportDataSource(): ReportDataSource { + const dataSource = use(ReportDataSourceContext); + if (!dataSource) { + throw new Error( + "useReportDataSource must be used within a " + ); + } + return dataSource; +} diff --git a/web/components/runs/report-items-provider.tsx b/web/components/runs/report-items-provider.tsx index 52bbad78..6950cfec 100644 --- a/web/components/runs/report-items-provider.tsx +++ b/web/components/runs/report-items-provider.tsx @@ -1,6 +1,7 @@ "use client"; -import { createContext, type ReactNode, use } from "react"; +import { createContext, type ReactNode, use, useEffect } from "react"; +import { useReportDataSource } from "@/components/runs/report-data-source-provider"; import { type UseReportItemsResult, useReportItems, @@ -18,32 +19,45 @@ export interface ReportItemsContextValue extends UseReportItemsResult { // Props every report-data viewer receives from its run-detail variant. export interface ReportViewerProps { initialPage: ReportItemsPage; - instrumentId: string; - runId: string; } const ReportItemsContext = createContext(null); +// Optional. The MCP View wraps carousels so the seeker can restore position +// without threading a persist key through every shared renderer. +export const ReportPersistKeyContext = createContext( + undefined +); + export function ReportItemsProvider({ children, initialPage, - instrumentId, kind, - runId, }: { children: ReactNode; initialPage: ReportItemsPage; - instrumentId: string; kind: ReportItemKind; - runId: string; }) { + const dataSource = useReportDataSource(); + const persistKey = use(ReportPersistKeyContext); const { state, actions } = useReportItems({ + fetchReportItems: dataSource.fetchReportItems, initialPage, - instrumentId, kind, - runId, }); + useEffect(() => { + const fileId = state.selectedItem?.id; + if (!persistKey || fileId == null) { + return; + } + try { + window.localStorage.setItem(persistKey, JSON.stringify({ fileId })); + } catch { + // Private mode and quota failures are fine; restore is optional. + } + }, [persistKey, state.selectedItem?.id]); + return ( {children} diff --git a/web/components/runs/run-report-section.tsx b/web/components/runs/run-report-section.tsx index 22f37b45..181b6f73 100644 --- a/web/components/runs/run-report-section.tsx +++ b/web/components/runs/run-report-section.tsx @@ -1,9 +1,11 @@ +"use client"; + import { ExternalLink } from "lucide-react"; import { ColonyDataTable } from "@/components/runs/colony-data-table"; import { ReportDataShell } from "@/components/runs/report-data-shell"; import { RunVideoPlayer } from "@/components/runs/run-video-player"; import { Button } from "@/components/ui/button"; -import type { RunFile } from "@/lib/api/instrument-runs"; +import { useResolvedFileUrl } from "@/hooks/use-resolved-file-url"; import { fileStem, isCsvFile, @@ -13,54 +15,88 @@ import { posterFileIdsByVideoFilename, } from "@/lib/runs/run-file-types"; -function ProcessedImagePreview({ file }: { file: RunFile }) { - const downloadUrl = `/api/v1/files/${file.id}/download`; +export interface ReportSectionFile { + category: "processed" | "raw"; + contentType: string | null; + deletedAt: Date | null; + filename: string; + id: number; +} + +function ProcessedImagePreview({ file }: { file: ReportSectionFile }) { + const downloadUrl = useResolvedFileUrl(file.id); return (

{file.filename}

- + {downloadUrl && ( + + )}
- {/* biome-ignore lint/performance/noImgElement: auth-gated download URLs are not next/image candidates */} - {file.filename} + {downloadUrl ? ( + // biome-ignore lint/performance/noImgElement: auth-gated download URLs are not next/image candidates + {file.filename} + ) : ( +
+ Loading{"\u2026"} +
+ )}
); } -function PdfPreview({ file }: { file: RunFile }) { - const downloadUrl = `/api/v1/files/${file.id}/download`; +function PdfPreview({ file }: { file: ReportSectionFile }) { + const downloadUrl = useResolvedFileUrl(file.id); return (

{file.filename}

- + {downloadUrl && ( + + )}
-