From c62d268318a65e12d5d57519aa082ef3f9fd8571 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Fri, 17 Jul 2026 19:32:18 +0800
Subject: [PATCH 1/8] perf: reduce search and document latency
Add progressive search, bounded document loading, safe owner caches, and indexed persistence paths while preserving existing JSON and detail contracts. Includes Docker replay evidence and targeted offline verification.
---
bundle-budget.json | 7 +-
...r-apply-performance-latency-remediation.md | 25 +
scripts/check-bundle-budget.mjs | 92 ++-
src/app/api/answer/stream/route.ts | 29 +-
src/app/api/differentials/[slug]/route.ts | 66 +-
.../presentations/[slug]/route.ts | 40 +-
src/app/api/differentials/route.ts | 30 +-
src/app/api/documents/[id]/labels/route.ts | 8 +-
src/app/api/documents/[id]/route.ts | 271 +-------
src/app/api/medications/[slug]/route.ts | 32 +-
src/app/api/medications/route.ts | 27 +-
src/app/api/registry/records/[slug]/route.ts | 32 +-
src/app/api/registry/records/route.ts | 27 +-
src/app/api/search/universal/route.ts | 78 ++-
src/app/documents/[id]/page.tsx | 36 +-
src/components/ClinicalDashboard.tsx | 154 ++++-
src/components/DocumentViewer.tsx | 629 ++++++++++--------
.../clinical-dashboard/differentials-home.tsx | 18 +-
.../use-universal-search.ts | 42 +-
src/components/document-viewer-lazy.tsx | 10 +-
src/components/forms/form-detail-page.tsx | 5 +-
.../forms/forms-search-results-page.tsx | 3 +-
.../services/service-detail-page.tsx | 2 +-
.../services/services-navigator-page.tsx | 16 +-
src/lib/answer-client-payload.ts | 23 +-
src/lib/answer-response.ts | 23 +-
src/lib/api-rate-limit.ts | 57 ++
src/lib/clinical-safety.ts | 18 +-
src/lib/corpus-grounding.ts | 47 +-
src/lib/cross-mode-links.ts | 4 +-
src/lib/deep-memory.ts | 66 +-
src/lib/differential-search-composition.ts | 126 ++++
src/lib/differentials.ts | 125 +---
src/lib/document-detail-contract.ts | 99 +++
src/lib/document-detail.ts | 528 +++++++++++++++
src/lib/document-enrichment.ts | 38 +-
src/lib/fixture-response-cache.ts | 45 ++
src/lib/form-catalog.ts | 62 +-
src/lib/form-ranker.ts | 171 +++++
src/lib/forms.ts | 116 +---
src/lib/http.ts | 2 +-
src/lib/medication-seed.ts | 41 +-
src/lib/owner-catalogue-cache.ts | 204 ++++++
src/lib/rag-cache.ts | 50 +-
src/lib/rag-candidate-sources.ts | 101 ++-
src/lib/rag-retrieval-variants.ts | 14 +
src/lib/rag.ts | 107 ++-
src/lib/registry-seed.ts | 31 +-
src/lib/search-command-surface.ts | 3 +-
src/lib/service-ranker.ts | 165 +++++
src/lib/services.ts | 166 +----
src/lib/supabase/auth.ts | 5 +-
src/lib/supabase/database.types.ts | 20 +
src/lib/types.ts | 15 +
src/lib/universal-search-stream.ts | 84 +++
src/lib/universal-search.ts | 180 ++++-
src/proxy.ts | 4 +-
supabase/drift-manifest.json | 130 +++-
...0717130000_registry_projection_cleanup.sql | 51 ++
.../20260717131000_public_title_corrector.sql | 149 +++++
...60717132000_atomic_summary_rate_limits.sql | 169 +++++
supabase/schema.sql | 339 ++++++++++
tests/answer-client-payload.test.ts | 47 +-
tests/api-rate-limit-fallback.test.ts | 50 ++
tests/api-validation-contract.test.ts | 5 +
tests/bundle-budget.test.ts | 55 +-
tests/client-performance-boundaries.test.ts | 46 ++
tests/differentials-route.test.ts | 14 +
tests/document-detail-performance.test.ts | 111 ++++
.../document-enrichment-visual-counts.test.ts | 97 +++
tests/fixture-response-cache.test.ts | 33 +
tests/medications-route.test.ts | 14 +
tests/owner-catalogue-cache.test.ts | 195 ++++++
tests/private-access-routes.test.ts | 98 ++-
tests/private-error-cache.test.ts | 26 +
tests/private-rag-access.test.ts | 3 +-
tests/proxy-session-refresh.test.ts | 20 +-
tests/rag-abort-signal.test.ts | 17 +
tests/rag-cache-invalidation.test.ts | 10 +
tests/rag-variant-early-exit.test.ts | 8 +-
tests/registry-records-route.test.ts | 15 +
tests/supabase-schema.test.ts | 105 +++
tests/universal-search-stream.test.ts | 103 +++
tests/universal-search.test.ts | 201 +++++-
.../use-universal-search-stream.dom.test.tsx | 191 ++++++
85 files changed, 5309 insertions(+), 1412 deletions(-)
create mode 100644 docs/operator-apply-performance-latency-remediation.md
create mode 100644 src/lib/differential-search-composition.ts
create mode 100644 src/lib/document-detail-contract.ts
create mode 100644 src/lib/document-detail.ts
create mode 100644 src/lib/fixture-response-cache.ts
create mode 100644 src/lib/form-ranker.ts
create mode 100644 src/lib/owner-catalogue-cache.ts
create mode 100644 src/lib/service-ranker.ts
create mode 100644 src/lib/universal-search-stream.ts
create mode 100644 supabase/migrations/20260717130000_registry_projection_cleanup.sql
create mode 100644 supabase/migrations/20260717131000_public_title_corrector.sql
create mode 100644 supabase/migrations/20260717132000_atomic_summary_rate_limits.sql
create mode 100644 tests/client-performance-boundaries.test.ts
create mode 100644 tests/document-detail-performance.test.ts
create mode 100644 tests/document-enrichment-visual-counts.test.ts
create mode 100644 tests/fixture-response-cache.test.ts
create mode 100644 tests/owner-catalogue-cache.test.ts
create mode 100644 tests/private-error-cache.test.ts
create mode 100644 tests/universal-search-stream.test.ts
create mode 100644 tests/use-universal-search-stream.dom.test.tsx
diff --git a/bundle-budget.json b/bundle-budget.json
index 84feae937..9ebae9854 100644
--- a/bundle-budget.json
+++ b/bundle-budget.json
@@ -1,6 +1,7 @@
{
- "$comment": "Client JS bundle-size budget. Warn-only until a baseline is captured: after a known-good `npm run build`, run `npm run check:bundle-budget -- --update` to set totalGzipBytes, then flip enforce to true so CI fails on >tolerancePct growth. See scripts/check-bundle-budget.mjs.",
- "enforce": false,
+ "$comment": "Client JS bundle-size budget captured from a known-good production build. CI fails when total gzip size grows beyond tolerancePct; refresh intentionally with `npm run check:bundle-budget -- --update`.",
+ "enforce": true,
"tolerancePct": 10,
- "totalGzipBytes": null
+ "totalGzipBytes": 1363382,
+ "updatedAt": "2026-07-17T12:11:55.267Z"
}
diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md
new file mode 100644
index 000000000..d39678c72
--- /dev/null
+++ b/docs/operator-apply-performance-latency-remediation.md
@@ -0,0 +1,25 @@
+# Operator apply — performance and latency remediation
+
+This worktree does not apply migrations. Production rollout remains a separate,
+explicitly authorized operation after local replay, review, and backups.
+
+## Registry projection index on a busy database
+
+`20260717130000_registry_projection_cleanup.sql` creates
+`documents_registry_projection_lookup_idx` transactionally so clean local
+replay remains deterministic. On a busy production database, pre-create the
+exact index outside a transaction:
+
+```sql
+create index concurrently if not exists documents_registry_projection_lookup_idx
+ on public.documents (
+ (metadata->>'registry_record_kind'),
+ (metadata->>'registry_record_id')
+ )
+ where metadata->>'source_kind' = 'registry_record';
+```
+
+After the index is valid and ready, the migration's `create index if not
+exists` is a no-op. Do not mark the migration applied merely because the index
+exists: the cleanup function, hardened privileges, and three lifecycle triggers
+must still be installed through the normal authorized migration rollout.
diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs
index c1a17349c..47e6a7113 100644
--- a/scripts/check-bundle-budget.mjs
+++ b/scripts/check-bundle-budget.mjs
@@ -25,6 +25,30 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const CHUNKS_DIR = path.join(root, ".next", "static", "chunks");
const BUDGET_PATH = path.join(root, "bundle-budget.json");
+const APP_BUILD_MANIFEST_PATH = path.join(root, ".next", "app-build-manifest.json");
+const BUILD_MANIFEST_PATH = path.join(root, ".next", "build-manifest.json");
+const ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH = path.join(
+ root,
+ ".next",
+ "server",
+ "app",
+ "page_client-reference-manifest.js",
+);
+
+const fixtureSnapshotMarkerGroups = [
+ {
+ name: "services snapshot",
+ markers: ["deep_research_citation_tokens", "canonical_name_key", "source_table_lines"],
+ },
+ {
+ name: "forms fixture catalogue",
+ markers: ["transport-crisis-form", "extension-transport-order", "detention-examination-movement"],
+ },
+ {
+ name: "differentials snapshot",
+ markers: ["redFlagFlows", "searchAliases", "exportedAt"],
+ },
+];
function walkJsFiles(dir) {
const out = [];
@@ -74,6 +98,45 @@ export function compareToBudget(current, budget) {
};
}
+/** Resolve the JavaScript chunks required by the root App Router dashboard. */
+export function initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest) {
+ const pages = appBuildManifest?.pages ?? {};
+ const pageClientChunks = Object.values(pageClientReferenceManifest?.clientModules ?? {}).flatMap((module) =>
+ Array.isArray(module?.chunks) ? module.chunks : [],
+ );
+ const names = new Set([
+ ...(appBuildManifest?.rootMainFiles ?? []),
+ ...(pages["/layout"] ?? []),
+ ...(pages["/page"] ?? []),
+ ...pageClientChunks,
+ ]);
+ return [...names]
+ .filter((name) => typeof name === "string" && name.endsWith(".js"))
+ .map((name) => name.replace(/^\/?static\/chunks\//, ""));
+}
+
+function loadRootPageClientReferenceManifest() {
+ if (!existsSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH)) return null;
+ const source = readFileSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH, "utf8");
+ const marker = 'globalThis.__RSC_MANIFEST["/page"]=';
+ const start = source.indexOf(marker);
+ if (start < 0) return null;
+ const jsonStart = start + marker.length;
+ const jsonEnd = source.lastIndexOf(";");
+ if (jsonEnd <= jsonStart) return null;
+ return JSON.parse(source.slice(jsonStart, jsonEnd));
+}
+
+/** Identify large fixture payloads from stable groups of serialized keys/slugs.
+ * Requiring every marker in a group avoids failing on ordinary UI copy that
+ * happens to mention one fixture term. */
+export function findFixtureSnapshotsInChunks(files) {
+ const content = files.map(({ buffer }) => buffer.toString("utf8")).join("\n");
+ return fixtureSnapshotMarkerGroups
+ .filter((group) => group.markers.every((marker) => content.includes(marker)))
+ .map((group) => group.name);
+}
+
function kb(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
@@ -101,6 +164,30 @@ function main() {
name: path.relative(CHUNKS_DIR, full),
buffer: readFileSync(full),
}));
+ const manifestPath = existsSync(APP_BUILD_MANIFEST_PATH)
+ ? APP_BUILD_MANIFEST_PATH
+ : existsSync(BUILD_MANIFEST_PATH)
+ ? BUILD_MANIFEST_PATH
+ : null;
+ if (!manifestPath) {
+ console.error("[bundle-budget] FAIL — no build manifest is available; cannot verify initial dashboard chunks.");
+ process.exit(1);
+ }
+ const appBuildManifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+ const pageClientReferenceManifest = loadRootPageClientReferenceManifest();
+ const initialChunkNames = new Set(initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest));
+ const initialDashboardChunks = files.filter((file) => initialChunkNames.has(file.name.replace(/\\/g, "/")));
+ if (initialDashboardChunks.length === 0) {
+ console.error("[bundle-budget] FAIL — no root dashboard JavaScript chunks were resolved from the build manifest.");
+ process.exit(1);
+ }
+ const fixtureViolations = findFixtureSnapshotsInChunks(initialDashboardChunks);
+ if (fixtureViolations.length > 0) {
+ console.error(
+ `[bundle-budget] FAIL — initial dashboard chunks contain fixture payloads: ${fixtureViolations.join(", ")}.`,
+ );
+ process.exit(1);
+ }
const current = measureChunks(files);
const budget = loadBudget();
@@ -113,7 +200,7 @@ function main() {
const verdict = compareToBudget(current, budget);
if (asJson) {
- console.log(JSON.stringify({ current, verdict }, null, 2));
+ console.log(JSON.stringify({ current, verdict, initialDashboardChunks: initialDashboardChunks.length }, null, 2));
} else {
console.log(
`[bundle-budget] client chunks: ${current.files} files, ${kb(current.totalGzipBytes)} gzip (${kb(current.totalRawBytes)} raw).`,
@@ -122,6 +209,9 @@ function main() {
console.log(`[bundle-budget] baseline ${kb(verdict.baseline)} gzip; ${verdict.reason}.`);
console.log("[bundle-budget] largest chunks (gzip):");
for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`);
+ console.log(
+ `[bundle-budget] initial dashboard fixture assertion passed (${initialDashboardChunks.length} chunks).`,
+ );
}
if (verdict.status === "fail") {
diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts
index eff39611a..843ee7153 100644
--- a/src/app/api/answer/stream/route.ts
+++ b/src/app/api/answer/stream/route.ts
@@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env";
import { PublicApiError, jsonError } from "@/lib/http";
import {
allowRateLimitInMemoryFallbackOnUnavailable,
+ consumeSummaryRateLimits,
consumeSubjectApiRateLimit,
rateLimitJsonResponse,
type ApiRateLimitResult,
@@ -272,24 +273,24 @@ export async function POST(request: Request) {
const supabase = createAdminClient();
const access = await publicAccessContext(request, supabase);
- const rateLimit = await consumeSubjectApiRateLimit({
- supabase,
- subject: access.rateLimitSubject,
- bucket: "answer",
- allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
- });
- if (rateLimit.limited) return rateLimitStream(rateLimit);
-
if (body.summaryMode) {
- // Streamed full-document summaries use the same paid provider path as the
- // legacy summary endpoint. Preserve the general answer ceiling, then also
- // enforce the stricter summary quota before the SSE stream can start.
- const summaryRateLimit = await consumeSubjectApiRateLimit({
+ const decision = await consumeSummaryRateLimits({
+ supabase,
+ subject: access.rateLimitSubject,
+ });
+ if (decision.rateLimit.limited) {
+ return decision.bucket === "document_summarize"
+ ? documentSummaryRateLimitStream(decision.rateLimit)
+ : rateLimitStream(decision.rateLimit);
+ }
+ } else {
+ const rateLimit = await consumeSubjectApiRateLimit({
supabase,
subject: access.rateLimitSubject,
- bucket: "document_summarize",
+ bucket: "answer",
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
});
- if (summaryRateLimit.limited) return documentSummaryRateLimitStream(summaryRateLimit);
+ if (rateLimit.limited) return rateLimitStream(rateLimit);
}
return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal);
diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts
index e61e7fcf1..25fd4e50f 100644
--- a/src/app/api/differentials/[slug]/route.ts
+++ b/src/app/api/differentials/[slug]/route.ts
@@ -17,6 +17,7 @@ import {
import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed";
import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext } from "@/lib/public-api-access";
@@ -30,10 +31,13 @@ const differentialDetailQuerySchema = z.object({
kind: z.enum(["presentation", "diagnosis"]).optional().default("diagnosis"),
});
-function differentialResponse(payload: Record, init?: { status?: number }) {
+function differentialResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -53,20 +57,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (kind === "presentation") {
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- workflow,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
const record = getDifferentialRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- record,
- detailContext: getDifferentialDetailContext(record),
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ record,
+ detailContext: getDifferentialDetailContext(record),
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -91,20 +101,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (kind === "presentation") {
const workflow = getPresentationWorkflow(normalizedSlug);
if (!workflow) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- workflow,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const record = getDifferentialRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
- return differentialResponse({
- record,
- detailContext: getDifferentialDetailContext(record),
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ record,
+ detailContext: getDifferentialDetailContext(record),
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts
index afc883800..a4ccc4744 100644
--- a/src/app/api/differentials/presentations/[slug]/route.ts
+++ b/src/app/api/differentials/presentations/[slug]/route.ts
@@ -16,6 +16,7 @@ import {
import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed";
import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { safeErrorLogDetails } from "@/lib/privacy";
import { publicAccessContext } from "@/lib/public-api-access";
@@ -24,10 +25,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";
-function differentialResponse(payload: Record, init?: { status?: number }) {
+function differentialResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -50,12 +54,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!record) return [];
return [{ ...candidate, record }];
});
- return differentialResponse({
- workflow,
- candidates,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ candidates,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -84,12 +91,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!record) return [];
return [{ ...candidate, record }];
});
- return differentialResponse({
- workflow,
- candidates,
- governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ workflow,
+ candidates,
+ governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchPresentation = async () => {
diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts
index 256d6a187..ae4559303 100644
--- a/src/app/api/differentials/route.ts
+++ b/src/app/api/differentials/route.ts
@@ -22,6 +22,7 @@ import {
type DifferentialRecordMatch,
} from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -43,8 +44,11 @@ const differentialListQuerySchema = z.object({
limit: queryInteger({ fallback: 100, min: 1, max: 200 }),
});
-function differentialResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function differentialResponse(
+ payload: Record,
+ options: { request?: Request; fixture?: boolean } = {},
+) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function recordMatchesPayload(matches: DifferentialRecordMatch[]) {
@@ -82,10 +86,13 @@ export async function GET(request: Request) {
const { kind, q, limit } = parseRequestQuery(request, differentialListQuerySchema, "Invalid differential query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return differentialResponse({
- ...publicDifferentialPayload(kind, q, limit),
- demoMode: true,
- });
+ return differentialResponse(
+ {
+ ...publicDifferentialPayload(kind, q, limit),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -105,10 +112,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return differentialResponse({
- ...publicDifferentialPayload(kind, q, limit),
- publicAccess: true,
- });
+ return differentialResponse(
+ {
+ ...publicDifferentialPayload(kind, q, limit),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerDifferentialRowsWithSeed(supabase, access.ownerId, kind, DIFFERENTIAL_MAX_RECORDS);
diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts
index dde68684b..6cd574841 100644
--- a/src/app/api/documents/[id]/labels/route.ts
+++ b/src/app/api/documents/[id]/labels/route.ts
@@ -145,7 +145,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label, labels: await selectLabels(supabase, id) }, { status: 201 });
} catch (error) {
if (error instanceof AuthenticationError) {
@@ -200,7 +200,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label });
}
@@ -243,7 +243,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ label, labels: await selectLabels(supabase, id) });
} catch (error) {
if (error instanceof AuthenticationError) {
@@ -288,7 +288,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
.eq("source", "manual");
if (error) throw new Error(error.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
return NextResponse.json({ deleted: true, labelId: parsed.labelId, labels: await selectLabels(supabase, id) });
} catch (error) {
if (error instanceof AuthenticationError) {
diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts
index 5246c653c..9fbc912dd 100644
--- a/src/app/api/documents/[id]/route.ts
+++ b/src/app/api/documents/[id]/route.ts
@@ -2,19 +2,21 @@ import { NextResponse } from "next/server";
import type { Json } from "@/lib/supabase/database.types";
import { z } from "zod";
import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
-import { getDemoDocumentPayload } from "@/lib/demo-data";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import { buildStorageCleanupJobUpdate } from "@/lib/ingestion";
import { invalidateRagCachesForDocumentMutation } from "@/lib/rag";
-import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
-import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { writeAuditLog } from "@/lib/audit";
+import {
+ DocumentDetailRateLimitError,
+ documentDetailQuerySchema,
+ loadAuthorizedDocumentDetail,
+} from "@/lib/document-detail";
import { parseJsonBody } from "@/lib/validation/body";
import { parseRouteParams } from "@/lib/validation/params";
-import { optionalQueryString, parseRequestQuery, queryInteger } from "@/lib/validation/query";
+import { parseRequestQuery } from "@/lib/validation/query";
export const runtime = "nodejs";
@@ -28,27 +30,6 @@ const documentRouteParamsSchema = z.object({
});
const cleanupPageSize = 1000;
-const defaultPageWindow = 9;
-const maxPageWindow = 40;
-const defaultChunkWindow = 16;
-const maxChunkWindow = 80;
-const selectedChunkNeighborCount = 3;
-
-const documentDetailQuerySchema = z.object({
- chunk: optionalQueryString({ maxLength: 80 }),
- page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }),
- pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }),
- chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }),
- chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }),
-});
-
-function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) {
- const half = Math.floor(limit / 2);
- const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER);
- const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1)));
- const to = Math.min(max, from + limit - 1);
- return { from, to };
-}
function safeMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {};
@@ -80,59 +61,6 @@ async function selectDocumentRowsInPages(args: {
return rows;
}
-function metadataText(metadata: Record, key: string) {
- const value = metadata[key];
- return typeof value === "string" && value.trim() ? value.trim() : null;
-}
-
-function compactTableText(value: string | null, limit = 500) {
- if (!value) return null;
- const compact = value.replace(/\s+/g, " ").trim();
- if (!compact) return null;
- return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact;
-}
-
-function metadataStringArrayRows(metadata: Record, key: string) {
- const value = metadata[key];
- if (!Array.isArray(value)) return null;
- const rows = value
- .filter((row): row is unknown[] => Array.isArray(row))
- .map((row) => row.map((cell) => String(cell ?? "").trim()));
- return rows.length ? rows : null;
-}
-
-function metadataStringArray(metadata: Record, key: string) {
- const value = metadata[key];
- if (!Array.isArray(value)) return null;
- const items = value.map((item) => String(item ?? "").trim()).filter(Boolean);
- return items.length ? items : null;
-}
-
-function withImageTableMetadata(image: T) {
- const metadata = safeMetadata(image.metadata);
- const rawTableText = metadataText(metadata, "table_text");
- const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet");
- const publicImage = { ...image };
- delete publicImage.metadata;
- return {
- ...publicImage,
- tableLabel: metadataText(metadata, "table_label"),
- tableTitle: metadataText(metadata, "table_title"),
- tableRole: metadataText(metadata, "table_role"),
- tableTextSnippet: compactTableText(tableText),
- clinicalUseClass: metadataText(metadata, "clinical_use_class"),
- clinicalUseReason: metadataText(metadata, "clinical_use_reason"),
- accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText,
- tableRows: metadataStringArrayRows(metadata, "table_rows"),
- tableColumns: metadataStringArray(metadata, "table_columns"),
- };
-}
-
-function committedRows(document: { metadata?: unknown }, rows: T[]) {
- const committedGeneration = committedIndexGeneration(document.metadata);
- return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration }));
-}
-
function storageWarningsFrom(error: unknown, label: string) {
const message =
error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed.";
@@ -274,185 +202,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
try {
const { id: rawId } = await params;
const detailQuery = parseRequestQuery(request, documentDetailQuerySchema, "Invalid document detail query.");
- if (isDemoMode()) {
- const payload = getDemoDocumentPayload(rawId, detailQuery.chunk ?? null);
- if (!payload) {
- return NextResponse.json({ error: "Demo document not found." }, { status: 404 });
- }
- return NextResponse.json({ ...payload, demoMode: true });
- }
-
- const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id.");
- const supabase = createAdminClient();
- const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase);
- if (rateLimit.limited) {
- return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
- }
- const { data: document, error } = await withOwnerReadScope(
- supabase.from("documents").select("*").eq("id", id),
- access.ownerId,
- ).maybeSingle();
-
- if (error) throw new Error(error.message);
- if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
-
- // withOwnerReadScope also returns PUBLIC (owner_id IS NULL) documents to an authenticated
- // caller. The owner-only projection (raw metadata, storage_path/content_hash, index health,
- // image storage paths) must be gated on OWNERSHIP, not merely on being authenticated, so an
- // authed non-owner viewing a shared public document gets the same redacted view as an
- // anonymous caller (S1/D1).
- const isOwner = callerOwnsDocumentRow(document, access.ownerId);
-
- const chunkId = detailQuery.chunk ?? null;
- const requestedPage = Math.min(detailQuery.page, Math.max(1, document.page_count ?? 1));
- const pageLimit = detailQuery.pageLimit;
- const chunkLimit = detailQuery.chunkLimit;
- const chunkOffset = detailQuery.chunkOffset;
-
- let selectedChunk: {
- id: string;
- page_number: number | null;
- chunk_index: number;
- section_heading: string | null;
- content: string;
- image_ids: string[];
- } | null = null;
-
- if (chunkId) {
- const { data, error: selectedChunkError } = await supabase
- .from("document_chunks")
- .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
- .eq("document_id", id)
- .eq("id", chunkId)
- .maybeSingle();
-
- if (selectedChunkError) throw new Error(selectedChunkError.message);
- selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null;
- }
-
- const effectivePage = selectedChunk?.page_number ?? requestedPage;
- const pageWindow = pageWindowAround(effectivePage, pageLimit, document.page_count);
- const { data: pages, error: pagesError } = await supabase
- .from("document_pages")
- .select("id,page_number,text,ocr_used,metadata")
- .eq("document_id", id)
- .gte("page_number", pageWindow.from)
- .lte("page_number", pageWindow.to)
- .order("page_number", { ascending: true });
-
- if (pagesError) throw new Error(pagesError.message);
-
- const { data: images, error: imagesError } = await supabase
- .from("document_images")
- .select(
- "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata",
- )
- .eq("document_id", id)
- .neq("image_type", "logo_decorative")
- .or("searchable.eq.true,source_kind.eq.table_crop")
- .order("page_number", { ascending: true });
-
- if (imagesError) throw new Error(imagesError.message);
-
- const chunkQuery = supabase
- .from("document_chunks")
- .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata")
- .eq("document_id", id)
- .order("chunk_index", { ascending: true });
-
- const chunkRangeStart = selectedChunk
- ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount)
- : chunkOffset;
- const chunkRangeEnd = selectedChunk
- ? selectedChunk.chunk_index + selectedChunkNeighborCount
- : chunkOffset + chunkLimit - 1;
-
- const { data: chunks, error: chunksError } = selectedChunk
- ? await chunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd)
- : await chunkQuery.range(chunkRangeStart, chunkRangeEnd);
-
- if (chunksError) throw new Error(chunksError.message);
-
- const [labelsResult, summaryResult, tableFactsResult] = await Promise.all([
- supabase.from("document_labels").select("*").eq("document_id", id).order("confidence", { ascending: false }),
- supabase.from("document_summaries").select("*").eq("document_id", id).maybeSingle(),
- supabase
- .from("document_table_facts")
- .select("*")
- .eq("document_id", id)
- .order("page_number", { ascending: true })
- .limit(200),
- ]);
-
- if (labelsResult.error) throw new Error(labelsResult.error.message);
- if (summaryResult.error) throw new Error(summaryResult.error.message);
- if (tableFactsResult.error) throw new Error(tableFactsResult.error.message);
-
- const omitPublicInternalFields = (row: Record) => {
- const internalKeys = new Set([
- "owner_id",
- "storage_path",
- "content_hash",
- "source_path",
- "import_batch_id",
- "error_message",
- "metadata",
- // Summary provenance: only present on document_summaries rows (fetched with select("*")).
- // A non-owner viewing a public document's summary must not see the owner's chunk/image
- // source IDs or the generation model, matching the list route's PUBLIC_SUMMARY projection.
- "source_chunk_ids",
- "source_image_ids",
- "model",
- ]);
- return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key)));
- };
- const publicRows = >(rows: T[]) =>
- isOwner ? rows : rows.map(omitPublicInternalFields);
- const responseDocument = isOwner ? document : omitPublicInternalFields(document as Record);
-
- return NextResponse.json({
- document: {
- ...responseDocument,
- labels: publicRows((labelsResult.data ?? []) as Record[]),
- summary:
- isOwner || !summaryResult.data
- ? (summaryResult.data ?? null)
- : omitPublicInternalFields(summaryResult.data as Record),
- },
- pages: publicRows((pages ?? []) as Record[]),
- images: publicRows(
- committedRows(document, images ?? []).map(withImageTableMetadata) as Record[],
- ),
- tableFacts: publicRows(committedRows(document, tableFactsResult.data ?? []) as Record[]),
- chunks: publicRows(committedRows(document, chunks ?? []) as Record[]),
- pageWindow: {
- from: pageWindow.from,
- to: pageWindow.to,
- limit: pageLimit,
- total: document.page_count ?? null,
- hasBefore: pageWindow.from > 1,
- hasAfter: Boolean(document.page_count && pageWindow.to < document.page_count),
- },
- chunkWindow: {
- offset: chunkRangeStart,
- limit: selectedChunk ? chunkRangeEnd - chunkRangeStart + 1 : chunkLimit,
- total: document.chunk_count ?? null,
- hasBefore: chunkRangeStart > 0,
- hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count),
- selectedChunkId: selectedChunk?.id ?? null,
- },
- ...(isOwner
- ? {
- indexHealth: {
- extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null,
- indexedAt: safeMetadata(document.metadata).indexed_at ?? null,
- indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null,
- warnings: safeMetadata(document.metadata).extraction_warnings ?? [],
- },
- }
- : {}),
- });
+ const payload = await loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery });
+ return NextResponse.json(payload);
} catch (error) {
+ if (error instanceof DocumentDetailRateLimitError) {
+ return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", error.rateLimit);
+ }
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
@@ -504,7 +259,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
.single();
if (updateError) throw new Error(updateError.message);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
await writeAuditLog(supabase, {
ownerId: user.id,
action: "document_rename",
@@ -649,7 +404,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
});
if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning);
- invalidateRagCachesForDocumentMutation(user.id);
+ invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false });
await writeAuditLog(supabase, {
ownerId: user.id,
action: "document_delete",
diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts
index 48de02a16..a02dfaebe 100644
--- a/src/app/api/medications/[slug]/route.ts
+++ b/src/app/api/medications/[slug]/route.ts
@@ -6,6 +6,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { getMedicationRecord } from "@/lib/medication-snapshot";
import { ensureMedicationsSeeded } from "@/lib/medication-seed";
@@ -23,10 +24,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
export const runtime = "nodejs";
-function medicationResponse(payload: Record, init?: { status?: number }) {
+function medicationResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -55,10 +59,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (isDemoMode() || isLocalNoAuthMode()) {
const payload = publicMedicationDetailPayload(normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return medicationResponse({
- ...payload,
- demoMode: true,
- });
+ return medicationResponse(
+ {
+ ...payload,
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -80,10 +87,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!access.ownerId) {
const payload = publicMedicationDetailPayload(normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return medicationResponse({
- ...payload,
- publicAccess: true,
- });
+ return medicationResponse(
+ {
+ ...payload,
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index adb6e9743..7562cf418 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed";
import {
@@ -61,8 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
}));
}
-function medicationResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function medicationResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function matchesPayload(matches: MedicationSearchMatch[]) {
@@ -99,10 +100,13 @@ export async function GET(request: Request) {
const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return medicationResponse({
- ...publicMedicationPayload(q, limit, fields),
- demoMode: true,
- });
+ return medicationResponse(
+ {
+ ...publicMedicationPayload(q, limit, fields),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -122,10 +126,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return medicationResponse({
- ...publicMedicationPayload(q, limit, fields),
- publicAccess: true,
- });
+ return medicationResponse(
+ {
+ ...publicMedicationPayload(q, limit, fields),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS);
diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts
index e3bb5a6be..44d8f372d 100644
--- a/src/app/api/registry/records/[slug]/route.ts
+++ b/src/app/api/registry/records/[slug]/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { getFormRecord } from "@/lib/forms";
@@ -28,10 +29,13 @@ const registryDetailQuerySchema = z.object({
kind: z.enum(["service", "form"]),
});
-function registryResponse(payload: Record, init?: { status?: number }) {
+function registryResponse(
+ payload: Record,
+ init: { status?: number; request?: Request; fixture?: boolean } = {},
+) {
return NextResponse.json(payload, {
- status: init?.status ?? 200,
- headers: { "Cache-Control": "private, no-store" },
+ status: init.status ?? 200,
+ headers: fixtureResponseHeaders(init.request, init),
});
}
@@ -59,10 +63,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (isDemoMode() || isLocalNoAuthMode()) {
const payload = publicRegistryDetailPayload(kind, normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return registryResponse({
- ...payload,
- demoMode: true,
- });
+ return registryResponse(
+ {
+ ...payload,
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit before we serve the seed detail:
@@ -84,10 +91,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
if (!access.ownerId) {
const payload = publicRegistryDetailPayload(kind, normalizedSlug);
if (!payload) return notFoundResponse(normalizedSlug);
- return registryResponse({
- ...payload,
- publicAccess: true,
- });
+ return registryResponse(
+ {
+ ...payload,
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const fetchRecord = async () => {
diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts
index 309981079..8326c168d 100644
--- a/src/app/api/registry/records/route.ts
+++ b/src/app/api/registry/records/route.ts
@@ -7,6 +7,7 @@ import {
rateLimitJsonResponse,
} from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
+import { fixtureResponseHeaders } from "@/lib/fixture-response-cache";
import { jsonError } from "@/lib/http";
import { publicAccessContext } from "@/lib/public-api-access";
import { rankFormRecords, formRecords } from "@/lib/forms";
@@ -45,8 +46,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query:
return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit);
}
-function registryResponse(payload: Record) {
- return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
+function registryResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) {
+ return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) });
}
function matchesPayload(matches: ServiceSearchMatch[]) {
@@ -74,10 +75,13 @@ export async function GET(request: Request) {
const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query.");
if (isDemoMode() || isLocalNoAuthMode()) {
- return registryResponse({
- ...publicRegistryPayload(kind, q, limit),
- demoMode: true,
- });
+ return registryResponse(
+ {
+ ...publicRegistryPayload(kind, q, limit),
+ demoMode: true,
+ },
+ { request, fixture: true },
+ );
}
// Anonymous callers still resolve access + rate limit: publicAccessContext skips the
@@ -97,10 +101,13 @@ export async function GET(request: Request) {
}
if (!access.ownerId) {
- return registryResponse({
- ...publicRegistryPayload(kind, q, limit),
- publicAccess: true,
- });
+ return registryResponse(
+ {
+ ...publicRegistryPayload(kind, q, limit),
+ publicAccess: true,
+ },
+ { request, fixture: true },
+ );
}
const rows = await fetchOwnerRegistryRows(supabase, access.ownerId, kind, REGISTRY_MAX_RECORDS);
diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts
index ef4b0541b..32d4d1702 100644
--- a/src/app/api/search/universal/route.ts
+++ b/src/app/api/search/universal/route.ts
@@ -15,6 +15,7 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
import {
runUniversalSearch,
universalSearchDomains,
+ type RunUniversalSearchArgs,
type UniversalSearchDomain,
type UniversalSearchResponse,
} from "@/lib/universal-search";
@@ -35,6 +36,7 @@ const universalSearchQuerySchema = z.object({
q: z.string().trim().min(2).max(200),
limit: queryInteger({ fallback: 5, min: 1, max: 10 }),
mode: z.enum(appModeIds).optional(),
+ stream: z.enum(["ndjson"]).optional(),
domains: z
.string()
.trim()
@@ -64,22 +66,84 @@ function universalResponse(
return NextResponse.json(payload, { headers });
}
+function universalStreamResponse(
+ request: Request,
+ searchArgs: RunUniversalSearchArgs,
+ decoration: { demoMode?: true; publicAccess?: true } = {},
+) {
+ const encoder = new TextEncoder();
+ const searchController = new AbortController();
+ const abortFromRequest = () => searchController.abort(request.signal.reason);
+ if (request.signal.aborted) abortFromRequest();
+ else request.signal.addEventListener("abort", abortFromRequest, { once: true });
+
+ const body = new ReadableStream({
+ start(controller) {
+ const enqueue = (event: Record) => {
+ if (!searchController.signal.aborted) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
+ }
+ };
+
+ void runUniversalSearch({
+ ...searchArgs,
+ signal: searchController.signal,
+ onGroup: (group) => enqueue({ type: "group", query: searchArgs.query, group }),
+ })
+ .then((response) => {
+ if (searchController.signal.aborted) return;
+ enqueue({ type: "complete", response: { ...response, ...decoration } });
+ controller.close();
+ })
+ .catch((error: unknown) => {
+ if (searchController.signal.aborted) {
+ try {
+ controller.close();
+ } catch {
+ // The consumer may already have cancelled the stream.
+ }
+ return;
+ }
+ controller.error(error);
+ })
+ .finally(() => request.signal.removeEventListener("abort", abortFromRequest));
+ },
+ cancel(reason) {
+ searchController.abort(
+ reason instanceof Error ? reason : new DOMException("The search stream was cancelled.", "AbortError"),
+ );
+ request.signal.removeEventListener("abort", abortFromRequest);
+ },
+ });
+
+ return new Response(body, {
+ headers: {
+ "Cache-Control": "private, no-store",
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "X-Accel-Buffering": "no",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+}
+
export async function GET(request: Request) {
try {
- const { q, limit, domains, mode } = parseRequestQuery(
+ const { q, limit, domains, mode, stream } = parseRequestQuery(
request,
universalSearchQuerySchema,
"Invalid universal query.",
);
if (isDemoMode() || isLocalNoAuthMode()) {
- const payload = await runUniversalSearch({
+ const searchArgs: RunUniversalSearchArgs = {
query: q,
limitPerDomain: limit,
domains,
contextMode: mode,
demo: true,
- });
+ };
+ if (stream === "ndjson") return universalStreamResponse(request, searchArgs, { demoMode: true });
+ const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal });
return universalResponse({ ...payload, demoMode: true });
}
@@ -99,7 +163,7 @@ export async function GET(request: Request) {
// demo:false + supabase always run the live pipeline. An anonymous caller (ownerId
// undefined) is scoped to the public corpus via allowGlobalSearch and the real default
// catalogues — never the synthetic demo fixtures.
- const payload = await runUniversalSearch({
+ const searchArgs: RunUniversalSearchArgs = {
query: q,
limitPerDomain: limit,
domains,
@@ -107,7 +171,11 @@ export async function GET(request: Request) {
supabase,
ownerId: access.ownerId,
demo: false,
- });
+ };
+ if (stream === "ndjson") {
+ return universalStreamResponse(request, searchArgs, access.ownerId ? {} : { publicAccess: true });
+ }
+ const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal });
return universalResponse(access.ownerId ? payload : { ...payload, publicAccess: true });
} catch (error) {
if (error instanceof AuthenticationError) {
diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx
index 19a7cf04b..10413f356 100644
--- a/src/app/documents/[id]/page.tsx
+++ b/src/app/documents/[id]/page.tsx
@@ -1,4 +1,11 @@
+import { headers } from "next/headers";
import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy";
+import {
+ documentDetailQuerySchema,
+ loadAuthorizedDocumentDetail,
+ sanitizeDocumentDetailError,
+} from "@/lib/document-detail";
+import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
export default async function DocumentPage({
params,
@@ -10,5 +17,32 @@ export default async function DocumentPage({
const [{ id }, query] = await Promise.all([params, searchParams]);
const parsedPage = Number.parseInt(query.page ?? "", 10);
const initialPage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1;
- return ;
+ let initialDetail: DocumentDetailPayload | undefined;
+ let initialError: string | undefined;
+
+ try {
+ const detailQuery = documentDetailQuerySchema.parse({
+ page: initialPage,
+ chunk: query.chunk,
+ assetScope: "window",
+ });
+ const requestHeaders = new Headers(await headers());
+ const request = new Request(`http://document-detail.local/documents/${encodeURIComponent(id)}`, {
+ headers: requestHeaders,
+ });
+ initialDetail = await loadAuthorizedDocumentDetail({ request, rawId: id, query: detailQuery });
+ } catch (error) {
+ initialError = sanitizeDocumentDetailError(error);
+ }
+
+ return (
+
+ );
}
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 931335a1a..623187b84 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -187,8 +187,8 @@ import {
import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope";
import { parseApiErrorResponse } from "@/lib/api-client-error";
import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle";
-import { rankFormRecords } from "@/lib/forms";
-import { rankServiceRecords } from "@/lib/services";
+import { rankFormRecords } from "@/lib/form-ranker";
+import { rankServiceRecords } from "@/lib/service-ranker";
import { useRegistryRecords } from "@/lib/use-registry-records";
import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up";
import {
@@ -232,6 +232,7 @@ const stagedDashboardExtraction = {
type RefreshOptions = {
includeSetup?: boolean;
includeDashboardData?: boolean;
+ includeAdministrationData?: boolean;
includeDocumentMeta?: boolean;
};
type PollHint = {
@@ -446,16 +447,23 @@ export function ClinicalDashboard({
const scrollFrameRef = useRef(null);
const navSyncLockRef = useRef(null);
const autoRunSearchSignatureRef = useRef(null);
- const refreshInFlightRef = useRef<{ epoch: number; promise: Promise } | null>(null);
+ const refreshInFlightRef = useRef<{
+ epoch: number;
+ dataScope: number;
+ promise: Promise;
+ } | null>(null);
+ const dashboardDataLoadedRef = useRef(false);
+ const administrationDataLoadedRef = useRef(false);
const nextWorkStatePollRef = useRef(0);
const urlSearchBootstrappedRef = useRef(false);
const urlDocumentSearchBootstrappedRef = useRef(false);
const lastSyncedSearchParamsRef = useRef(searchParams.toString());
const modeChangeFromUiRef = useRef(false);
const [documents, setDocuments] = useState([]);
+ const documentsRef = useRef(documents);
const [documentsPagination, setDocumentsPagination] = useState(null);
const indexedDocumentTotal = documentsPagination?.total ?? documents.length;
- const [dashboardDataLoading, setDashboardDataLoading] = useState(true);
+ const [dashboardDataLoading, setDashboardDataLoading] = useState(false);
const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false);
const [jobs, setJobs] = useState([]);
const [batches, setBatches] = useState([]);
@@ -664,6 +672,7 @@ export function ClinicalDashboard({
);
const [indexingActionId, setIndexingActionId] = useState(null);
const [indexingActive, setIndexingActive] = useState(false);
+ const [userStartedIngestion, setUserStartedIngestion] = useState(false);
const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null);
const { theme, toggleTheme } = useTheme();
const auth = useAuthSession();
@@ -743,6 +752,9 @@ export function ClinicalDashboard({
setJobs([]);
setBatches([]);
setQualityItems([]);
+ dashboardDataLoadedRef.current = false;
+ administrationDataLoadedRef.current = false;
+ setUserStartedIngestion(false);
setSelectedDocumentIds([]);
setDocumentMatches([]);
setSearchScope(null);
@@ -987,6 +999,10 @@ export function ClinicalDashboard({
answerThreadOwnerId,
]);
+ useEffect(() => {
+ documentsRef.current = documents;
+ }, [documents]);
+
useEffect(() => {
jobsRef.current = jobs;
}, [jobs]);
@@ -995,10 +1011,22 @@ export function ClinicalDashboard({
batchesRef.current = batches;
}, [batches]);
- const refresh = useCallback(
+ const refresh: (options?: RefreshOptions) => Promise = useCallback(
async (options: RefreshOptions = {}) => {
- if (refreshInFlightRef.current?.epoch === authEpoch) {
- return refreshInFlightRef.current.promise;
+ const includeDashboardData = options.includeDashboardData ?? true;
+ const includeAdministrationData = options.includeAdministrationData ?? includeDashboardData;
+ const requestedDataScope = (includeDashboardData ? 1 : 0) | (includeAdministrationData ? 2 : 0);
+ while (refreshInFlightRef.current?.epoch === authEpoch) {
+ const activeRefresh = refreshInFlightRef.current;
+ const needsFollowUp = (requestedDataScope & ~activeRefresh.dataScope) !== 0;
+ await activeRefresh.promise;
+ // A setup-only refresh cannot satisfy a data request that arrived
+ // while it was in flight. Run one follow-up request; same-scope calls
+ // stay coalesced on the original promise.
+ if (!needsFollowUp) return;
+ // The promise is complete, so release its coalescing slot now. The
+ // owning call still releases its auth request in its own finally.
+ if (refreshInFlightRef.current === activeRefresh) refreshInFlightRef.current = null;
}
const controller = new AbortController();
@@ -1006,12 +1034,11 @@ export function ClinicalDashboard({
const canCommit = () => isAuthEpochCurrent(authRequest.epoch) && !controller.signal.aborted;
const promise = (async () => {
- const trackDashboardLoading = options.includeDashboardData ?? true;
+ const trackDashboardLoading = requestedDataScope !== 0;
await Promise.resolve();
if (trackDashboardLoading) setDashboardDataLoading(true);
const includeSetup = options.includeSetup ?? true;
- const includeDashboardData = options.includeDashboardData ?? true;
const includeDocumentMeta = options.includeDocumentMeta ?? true;
let nextDemoMode = clientDemoMode;
let routeIndexingActive = false;
@@ -1078,7 +1105,7 @@ export function ClinicalDashboard({
return;
}
- if (!includeDashboardData) {
+ if (requestedDataScope === 0) {
setIndexingActive(routeIndexingActive);
setNextRefreshDelayMs(routePollDelayMs);
return;
@@ -1091,14 +1118,17 @@ export function ClinicalDashboard({
}
const now = Date.now();
- const shouldRefreshWorkState = now >= nextWorkStatePollRef.current;
+ const shouldRefreshWorkState =
+ includeAdministrationData && (!administrationDataLoadedRef.current || now >= nextWorkStatePollRef.current);
if (shouldRefreshWorkState) nextWorkStatePollRef.current = now + indexingWorkDetailsPollMs;
const [documentsResponse, jobsResponse, batchesResponse, qualityResponse] = await Promise.all([
- fetch(`/api/documents?${documentParams.toString()}`, {
- headers: protectedHeaders,
- signal: controller.signal,
- }),
+ includeDashboardData
+ ? fetch(`/api/documents?${documentParams.toString()}`, {
+ headers: protectedHeaders,
+ signal: controller.signal,
+ })
+ : Promise.resolve(null as Response | null),
shouldRefreshWorkState
? fetch("/api/ingestion/jobs", { headers: protectedHeaders, signal: controller.signal })
: Promise.resolve(null as Response | null),
@@ -1110,9 +1140,8 @@ export function ClinicalDashboard({
: Promise.resolve(null as Response | null),
]);
if (!canCommit()) return;
-
if (
- documentsResponse.status === 401 ||
+ (documentsResponse !== null && documentsResponse.status === 401) ||
(jobsResponse !== null && jobsResponse.status === 401) ||
(batchesResponse !== null && batchesResponse.status === 401) ||
(qualityResponse !== null && qualityResponse.status === 401)
@@ -1128,22 +1157,23 @@ export function ClinicalDashboard({
return;
}
- let nextDocuments: ClinicalDocument[] = [];
+ let nextDocuments: ClinicalDocument[] = includeDashboardData ? [] : documentsRef.current;
let nextJobs: IngestionJob[] = shouldRefreshWorkState ? [] : jobsRef.current;
let nextBatches: ImportBatch[] = shouldRefreshWorkState ? [] : batchesRef.current;
- if (documentsResponse.ok) {
+ if (documentsResponse?.ok) {
const payload = (await documentsResponse.json()) as DocumentsPayload;
nextDocuments = payload.documents ?? [];
setDocuments((current) =>
includeDocumentMeta ? nextDocuments : mergeDocumentRefresh(current, nextDocuments),
);
setDocumentsPagination(payload.pagination ?? null);
+ dashboardDataLoadedRef.current = true;
routeIndexingActive ||= Boolean(payload.indexing?.active);
routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.indexing?.pollAfterMs);
if (payload.demoMode) setDemoMode(true);
if (payload.setupRequired) setSetupWarning(payload.error ?? null);
- } else {
+ } else if (includeDashboardData) {
setApiUnavailable(true);
}
@@ -1178,17 +1208,21 @@ export function ClinicalDashboard({
setApiUnavailable(true);
}
+ if (jobsResponse?.ok && batchesResponse?.ok && qualityResponse?.ok) {
+ administrationDataLoadedRef.current = true;
+ }
+
const activeWork = hasActiveIndexingWork(nextDocuments, nextJobs, nextBatches, routeIndexingActive);
setIndexingActive(activeWork);
setNextRefreshDelayMs(routePollDelayMs ?? (activeWork ? activeIndexingPollFallbackMs : null));
})();
- refreshInFlightRef.current = { epoch: authRequest.epoch, promise };
+ refreshInFlightRef.current = { epoch: authRequest.epoch, dataScope: requestedDataScope, promise };
try {
return await promise;
} finally {
authRequest.release();
- if ((options.includeDashboardData ?? true) === true && canCommit()) setDashboardDataLoading(false);
+ if (requestedDataScope !== 0 && canCommit()) setDashboardDataLoading(false);
if (refreshInFlightRef.current?.promise === promise) {
refreshInFlightRef.current = null;
}
@@ -1267,6 +1301,8 @@ export function ClinicalDashboard({
if (!response.ok) {
throw new Error(typeof payload.error === "string" ? payload.error : "Job retry could not be started.");
}
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setActionNotice({
tone: "success",
message: "Ingestion job retry queued.",
@@ -1312,6 +1348,8 @@ export function ClinicalDashboard({
: "Document reindex could not be started.",
);
}
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setActionNotice({
tone: "success",
message: mode === "enrichment" ? "Document enrichment refreshed." : "Document reindex queued.",
@@ -1475,32 +1513,79 @@ export function ClinicalDashboard({
[documents, jobs, batches, indexingActive],
);
const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]);
+ const dashboardDataSurfaceVisible = documentsDrawerOpen || uploadDrawerOpen;
+ const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin");
useEffect(() => {
- refresh({ includeSetup: true, includeDashboardData: true, includeDocumentMeta: true }).catch(() => undefined);
+ dashboardDataLoadedRef.current = false;
+ administrationDataLoadedRef.current = false;
+ }, [authEpoch]);
+
+ useEffect(() => {
+ refresh({ includeSetup: true, includeDashboardData: false, includeDocumentMeta: false }).catch(() => undefined);
}, [authStatus, authorizationHeader, clientDemoMode, refresh]);
useEffect(() => {
- const hasScheduledWork = activeIndexingWork || needsSetupRecheck;
- if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) {
+ const includeDashboardData = dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current;
+ const includeAdministrationData = administrationSurfaceVisible && !administrationDataLoadedRef.current;
+ if (!includeDashboardData && !includeAdministrationData) return;
+ refresh({
+ includeSetup: false,
+ includeDashboardData,
+ includeAdministrationData,
+ includeDocumentMeta: includeDashboardData,
+ }).catch(() => undefined);
+ }, [administrationSurfaceVisible, authEpoch, dashboardDataSurfaceVisible, refresh]);
+
+ useEffect(() => {
+ if (!userStartedIngestion || !dashboardDataLoadedRef.current || activeIndexingWork) return;
+ let cancelled = false;
+ queueMicrotask(() => {
+ if (!cancelled) setUserStartedIngestion(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [activeIndexingWork, userStartedIngestion]);
+
+ useEffect(() => {
+ const visibleSurfaceHasActiveWork = dashboardDataSurfaceVisible && activeIndexingWork;
+ const userOperationHasActiveWork = userStartedIngestion && activeIndexingWork;
+ const shouldPollDashboardData = visibleSurfaceHasActiveWork || userOperationHasActiveWork;
+ const hasScheduledWork = shouldPollDashboardData || needsSetupRecheck;
+ const pollingAllowed =
+ userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork);
+ if (!pollingAllowed) {
return;
}
- const delay = activeIndexingWork ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs;
+ const delay = shouldPollDashboardData ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs;
const timeout = window.setTimeout(() => {
- if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) {
+ const stillAllowed =
+ userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork);
+ if (!stillAllowed) {
return;
}
refresh({
- includeSetup: !activeIndexingWork,
- includeDashboardData: activeIndexingWork,
+ includeSetup: !shouldPollDashboardData,
+ includeDashboardData: shouldPollDashboardData,
+ includeAdministrationData: shouldPollDashboardData && (administrationSurfaceVisible || userStartedIngestion),
includeDocumentMeta: false,
}).catch(() => undefined);
}, delay);
return () => window.clearTimeout(timeout);
- }, [activeIndexingWork, demoMode, needsSetupRecheck, nextRefreshDelayMs, refresh]);
+ }, [
+ activeIndexingWork,
+ administrationSurfaceVisible,
+ dashboardDataSurfaceVisible,
+ demoMode,
+ needsSetupRecheck,
+ nextRefreshDelayMs,
+ refresh,
+ userStartedIngestion,
+ ]);
useEffect(() => {
const refreshVisibleDashboard = () => {
@@ -1510,7 +1595,8 @@ export function ClinicalDashboard({
refresh({
includeSetup: true,
- includeDashboardData: activeIndexingWork || canUsePrivateApis || clientDemoMode,
+ includeDashboardData: dashboardDataSurfaceVisible || (userStartedIngestion && activeIndexingWork),
+ includeAdministrationData: administrationSurfaceVisible || (userStartedIngestion && activeIndexingWork),
includeDocumentMeta: false,
}).catch(() => undefined);
};
@@ -1521,7 +1607,7 @@ export function ClinicalDashboard({
document.removeEventListener("visibilitychange", refreshVisibleDashboard);
window.removeEventListener("focus", refreshVisibleDashboard);
};
- }, [activeIndexingWork, canUsePrivateApis, clientDemoMode, refresh]);
+ }, [activeIndexingWork, administrationSurfaceVisible, dashboardDataSurfaceVisible, refresh, userStartedIngestion]);
useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
@@ -2567,6 +2653,8 @@ export function ClinicalDashboard({
const payload = await response.json().catch(() => ({}));
if (!isAuthEpochCurrent(requestEpoch)) return;
if (!response.ok) throw new Error(payload.error || errorCopy.bulkReindexFailed);
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setBulkActionStatus(
`${payload.results?.filter((result: { ok: boolean }) => result.ok).length ?? 0} selected documents updated.`,
);
@@ -3174,6 +3262,8 @@ export function ClinicalDashboard({
},
];
const handleUploadQueued = () => {
+ setUserStartedIngestion(true);
+ setIndexingActive(true);
setUploadMobileTab("jobs");
void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false });
};
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 2ac217e8c..35e08602a 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -103,6 +103,7 @@ import {
} from "@/lib/document-summary-formatting";
import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges";
import { documentSummaryQuestion } from "@/lib/answer-contract";
+import type { DocumentDetailPayload } from "@/lib/document-detail-contract";
type PageRow = {
id: string;
@@ -230,39 +231,6 @@ async function requestSignedUrlPayload(
return payload;
}
-// Fetch the preview + download signed URLs together (serving the client cache
-// first when allowed). Returns their settled results for the caller to apply.
-function fetchSignedUrlPair(
- signedUrlEndpoint: string,
- downloadSignedUrlEndpoint: string,
- options: {
- signal: AbortSignal;
- headers: HeadersInit | undefined;
- onUnauthorized: () => void;
- useCache: boolean;
- },
-) {
- const cachedSigned = options.useCache ? getCachedSignedUrl(signedUrlEndpoint) : null;
- const cachedDownload = options.useCache ? getCachedSignedUrl(downloadSignedUrlEndpoint) : null;
- const signedUrlRequest: Promise = cachedSigned
- ? Promise.resolve(cachedSigned)
- : requestSignedUrlPayload(signedUrlEndpoint, {
- signal: options.signal,
- headers: options.headers,
- onUnauthorized: options.onUnauthorized,
- errorMessage: "Source preview could not be loaded.",
- });
- const signedDownloadUrlRequest: Promise = cachedDownload
- ? Promise.resolve(cachedDownload)
- : requestSignedUrlPayload(downloadSignedUrlEndpoint, {
- signal: options.signal,
- headers: options.headers,
- onUnauthorized: options.onUnauthorized,
- errorMessage: "Download URL could not be loaded.",
- });
- return Promise.allSettled([signedUrlRequest, signedDownloadUrlRequest]);
-}
-
function getInitialPdfViewerMode() {
if (typeof window === "undefined") {
return {
@@ -290,6 +258,12 @@ function getInitialPdfViewerMode() {
};
}
+function rowsById(incoming: T[]) {
+ const rows = new Map();
+ for (const row of incoming) rows.set(row.id, row);
+ return Array.from(rows.values());
+}
+
function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] {
return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item);
}
@@ -602,7 +576,7 @@ function DocumentViewerAnchors({
className,
}: {
evidenceHref: "#source-evidence" | "#source-evidence-rail";
- textHref: "#source-text-mobile" | "#source-text-desktop";
+ textHref: "#source-text";
className?: string;
}) {
const anchors = [
@@ -897,7 +871,7 @@ const IndexedTextPanel = memo(function IndexedTextPanel({
searchingDocument: boolean;
documentSearchError: string | null;
idPrefix: string;
- sectionId?: "source-text-mobile" | "source-text-desktop";
+ sectionId?: "source-text";
selectedChunkId?: string;
onSearchChange: (value: string) => void;
}) {
@@ -1412,12 +1386,33 @@ function documentKeySections(document: ClinicalDocument) {
return Array.from(new Set(labels)).slice(0, 3);
}
-function DocumentPagePreview({ href, pageNumber }: { href: string; pageNumber: number | null }) {
+function DocumentPagePreview({
+ href,
+ pageNumber,
+ onNavigate,
+}: {
+ href: string;
+ pageNumber: number | null;
+ onNavigate: (page: number) => void;
+}) {
// A real "jump to page" chip rather than a fake wireframe thumbnail that looks
// like a skeleton that never resolves.
return (
{
+ if (
+ pageNumber === null ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ )
+ return;
+ event.preventDefault();
+ onNavigate(pageNumber);
+ }}
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]"
>
@@ -1438,8 +1433,11 @@ function DocumentOverviewLanding({
downloadUrl,
pages,
pageHref,
+ onPageChange,
onAskFromDocument,
onAddToScope,
+ onDownload,
+ downloading,
canSummarizeDocument,
}: {
document: ClinicalDocument;
@@ -1448,8 +1446,11 @@ function DocumentOverviewLanding({
downloadUrl: string | null;
pages: PageRow[];
pageHref: (page: number) => string;
+ onPageChange: (page: number) => void;
onAskFromDocument: () => void;
onAddToScope: () => void;
+ onDownload: () => void;
+ downloading: boolean;
canSummarizeDocument: boolean;
}) {
const keySections = documentKeySections(document);
@@ -1516,7 +1517,16 @@ function DocumentOverviewLanding({
>
Download
- ) : null}
+ ) : (
+
+ {downloading ? "Preparing" : "Download"}
+
+ )}
Most relevant pages for this document.
{(usefulPages.length ? usefulPages : [initialPage]).map((page) => (
-
+
))}
@@ -1600,12 +1610,42 @@ export function DocumentViewer({
documentId,
initialPage,
chunkId,
+ initialDetail,
+ initialError,
}: {
documentId: string;
initialPage: number;
chunkId?: string;
+ initialDetail?: DocumentDetailPayload;
+ initialError?: string;
}) {
const router = useRouter();
+ const [activeRoute, setActiveRoute] = useState(() => ({ page: initialPage, chunkId }));
+ const activePage = activeRoute.page;
+ const activeChunkId = activeRoute.chunkId;
+
+ useEffect(() => {
+ const syncFromHistory = () => {
+ const params = new URLSearchParams(window.location.search);
+ const parsedPage = Number.parseInt(params.get("page") ?? "", 10);
+ setActiveRoute({
+ page: Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1,
+ chunkId: params.get("chunk") ?? undefined,
+ });
+ };
+ window.addEventListener("popstate", syncFromHistory);
+ return () => window.removeEventListener("popstate", syncFromHistory);
+ }, []);
+
+ const navigateToPage = useCallback(
+ (page: number) => {
+ const nextPage = Math.max(1, Math.trunc(page));
+ if (nextPage === activePage) return;
+ window.history.pushState(null, "", documentPageHref(documentId, nextPage));
+ setActiveRoute({ page: nextPage, chunkId: undefined });
+ },
+ [activePage, documentId],
+ );
useEffect(() => {
const previousOpenStates = new Map();
const expandPrintableDisclosures = () => {
@@ -1639,21 +1679,23 @@ export function DocumentViewer({
window.removeEventListener("afterprint", restorePrintableDisclosures);
};
}, []);
- const [document, setDocument] = useState(null);
- const [pages, setPages] = useState([]);
- const [images, setImages] = useState([]);
- const [tableFacts, setTableFacts] = useState([]);
- const [chunks, setChunks] = useState([]);
- const [indexHealth, setIndexHealth] = useState(null);
+ const [document, setDocument] = useState(() => initialDetail?.document ?? null);
+ const [pages, setPages] = useState(() => initialDetail?.pages ?? []);
+ const [images, setImages] = useState(() => initialDetail?.images ?? []);
+ const [tableFacts, setTableFacts] = useState(() => initialDetail?.tableFacts ?? []);
+ const [chunks, setChunks] = useState(() => initialDetail?.chunks ?? []);
+ const [indexHealth, setIndexHealth] = useState(() => initialDetail?.indexHealth ?? null);
const [signedUrl, setSignedUrl] = useState(null);
const [downloadSignedUrl, setDownloadSignedUrl] = useState(null);
const [summary, setSummary] = useState(null);
const [summaryQuery, setSummaryQuery] = useState(documentSummaryQuestion);
const [summaryProgressEvents, setSummaryProgressEvents] = useState([]);
const [summaryProgressStartedAt, setSummaryProgressStartedAt] = useState(null);
- const [loadingDocument, setLoadingDocument] = useState(true);
- const [viewerError, setViewerError] = useState(null);
+ const [loadingDocument, setLoadingDocument] = useState(() => !initialDetail && !initialError);
+ const [viewerError, setViewerError] = useState(() => initialError ?? null);
const [previewError, setPreviewError] = useState(null);
+ const [downloadError, setDownloadError] = useState(null);
+ const [downloadingSource, setDownloadingSource] = useState(false);
const [loadingSummary, setLoadingSummary] = useState(false);
const [summaryError, setSummaryError] = useState(null);
const [previewAttempt, setPreviewAttempt] = useState(0);
@@ -1700,7 +1742,7 @@ export function DocumentViewer({
}, []);
const scrollHidden = useHideOnScroll({
...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}),
- resetKey: `${documentId}:${initialPage}:${chunkId ?? ""}`,
+ resetKey: `${documentId}:${activePage}:${activeChunkId ?? ""}`,
});
const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused;
// Read localStorage once on mount, then seed both derived states from it.
@@ -1727,7 +1769,9 @@ export function DocumentViewer({
markSessionExpired,
} = useAuthSession();
const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false);
- const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true");
+ const [serverDemoMode, setServerDemoMode] = useState(
+ () => initialDetail?.demoMode ?? process.env.NEXT_PUBLIC_DEMO_MODE === "true",
+ );
const localNoAuthMode = isLocalNoAuthMode();
const clientDemoMode = localNoAuthMode || serverDemoMode;
const canViewSourceDocuments = localProjectReady;
@@ -1790,80 +1834,43 @@ export function DocumentViewer({
}
}, [useNativePdfViewer, viewerModeInitialized, hasExplicitPdfViewerMode]);
- useEffect(() => {
- let active = true;
- readLocalProjectIdentity()
- .then((identity) => {
- if (!active) return null;
- if (!identity?.localServer?.safeLocalOrigin) {
- setLocalProjectReady(false);
- setViewerError(unsafeLocalProjectMessage(identity));
- setLoadingDocument(false);
- return null;
- }
- setLocalProjectReady(true);
- return fetch("/api/setup-status", { headers: authorizationHeader });
- })
- .then((response) => (response?.ok ? response.json() : null))
- .then((payload) => {
- if (active && typeof payload?.demoMode === "boolean") setServerDemoMode(payload.demoMode);
- })
- .catch(() => undefined);
- return () => {
- active = false;
- };
- }, [authorizationHeader, isConfigured]);
-
- // Apply the settled preview + download signed-URL results to state. Shared by
- // the initial load effect and the expired-URL refresh so a re-issued URL takes
- // the exact same path (cache write, previewError, download fallback).
- const applySignedUrlResults = useCallback(
- (
- signedUrlResult: PromiseSettledResult,
- downloadResult: PromiseSettledResult,
- signedUrlEndpoint: string,
- downloadSignedUrlEndpoint: string,
- ) => {
- if (signedUrlResult.status === "fulfilled") {
- const payload = signedUrlResult.value;
- if (payload?.url) setCachedSignedUrl(signedUrlEndpoint, { ...payload, url: payload.url });
- setSignedUrl(payload?.url ?? null);
+ const applyPreviewSignedUrlResult = useCallback(
+ (result: PromiseSettledResult, endpoint: string) => {
+ if (result.status === "fulfilled") {
+ const payload = result.value;
+ if (payload.url) setCachedSignedUrl(endpoint, { ...payload, url: payload.url });
+ setSignedUrl(payload.url ?? null);
setPreviewError(null);
- } else {
- setSignedUrl(null);
- setDownloadSignedUrl(null);
- setPreviewError(
- signedUrlResult.reason instanceof Error
- ? signedUrlResult.reason.message
- : "Source preview could not be loaded.",
- );
- }
-
- if (downloadResult.status === "fulfilled") {
- const payload = downloadResult.value;
- if (payload?.url) {
- setCachedSignedUrl(downloadSignedUrlEndpoint, { ...payload, url: payload.url });
- setDownloadSignedUrl(payload.url);
- return;
- }
- }
-
- if (signedUrlResult.status === "fulfilled") {
- setDownloadSignedUrl(signedUrlResult.value?.url ?? null);
- } else {
- setDownloadSignedUrl(null);
+ return;
}
+ setSignedUrl(null);
+ setPreviewError(result.reason instanceof Error ? result.reason.message : "Source preview could not be loaded.");
},
[],
);
- // Re-issue only the signed URLs (no document-detail refetch) when a PDF's URL
+ const openSourcePreview = useCallback(
+ (options: { signal: AbortSignal; useCache: boolean }) => {
+ const endpoint = `/api/documents/${documentId}/signed-url`;
+ const cached = options.useCache ? getCachedSignedUrl(endpoint) : null;
+ return cached
+ ? Promise.resolve(cached)
+ : requestSignedUrlPayload(endpoint, {
+ signal: options.signal,
+ headers: clientDemoMode ? undefined : authorizationHeader,
+ onUnauthorized: markSessionExpired,
+ errorMessage: "Source preview could not be loaded.",
+ });
+ },
+ [authorizationHeader, clientDemoMode, documentId, markSessionExpired],
+ );
+
+ // Re-issue only the preview URL (no document-detail or download request) when a PDF's URL
// expires mid-session, so the viewer refreshes in place without the full
// reload/flicker. Its AbortController is cancelled on the next refresh and on unmount.
const refreshControllerRef = useRef(null);
const refreshSignedUrls = useCallback(() => {
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
- const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`;
refreshControllerRef.current?.abort();
const controller = new AbortController();
@@ -1879,16 +1886,11 @@ export function DocumentViewer({
throw new Error(unsafeLocalProjectMessage(identity));
}
// handleSignedUrlExpired already cleared the cache, so always mint fresh.
- return fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, {
- signal: controller.signal,
- headers: clientDemoMode ? undefined : authorizationHeader,
- onUnauthorized: markSessionExpired,
- useCache: false,
- });
+ return openSourcePreview({ signal: controller.signal, useCache: false });
})
- .then(([signedUrlResult, downloadResult]) => {
+ .then((payload) => {
if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
- applySignedUrlResults(signedUrlResult, downloadResult, signedUrlEndpoint, downloadSignedUrlEndpoint);
+ applyPreviewSignedUrlResult({ status: "fulfilled", value: payload }, signedUrlEndpoint);
})
.catch((error) => {
if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
@@ -1898,23 +1900,91 @@ export function DocumentViewer({
authRequest.release();
if (refreshControllerRef.current === controller) refreshControllerRef.current = null;
});
+ }, [documentId, registerAuthRequest, isAuthEpochCurrent, openSourcePreview, applyPreviewSignedUrlResult]);
+
+ useEffect(() => () => refreshControllerRef.current?.abort(), []);
+
+ const downloadActionRef = useRef | null>(null);
+ const downloadControllerRef = useRef(null);
+ const currentDocumentFileName = document?.file_name;
+ const openSourceDownload = useCallback(() => {
+ if (downloadActionRef.current) return downloadActionRef.current;
+
+ const endpoint = `/api/documents/${documentId}/signed-url?download=true`;
+ const controller = new AbortController();
+ downloadControllerRef.current = controller;
+ const authRequest = registerAuthRequest(controller);
+ const action = (async () => {
+ setDownloadingSource(true);
+ setDownloadError(null);
+ try {
+ const identity = await readLocalProjectIdentity();
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ if (!identity?.localServer?.safeLocalOrigin) throw new Error(unsafeLocalProjectMessage(identity));
+
+ const cached = getCachedSignedUrl(endpoint);
+ const payload =
+ cached ??
+ (await requestSignedUrlPayload(endpoint, {
+ signal: controller.signal,
+ headers: clientDemoMode ? undefined : authorizationHeader,
+ onUnauthorized: markSessionExpired,
+ errorMessage: "Download URL could not be loaded.",
+ }));
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch) || !payload.url) return;
+
+ setCachedSignedUrl(endpoint, { ...payload, url: payload.url });
+ setDownloadSignedUrl(payload.url);
+ const anchor = window.document.createElement("a");
+ anchor.href = payload.url;
+ anchor.rel = "noreferrer";
+ anchor.download = currentDocumentFileName || "clinical-source";
+ anchor.click();
+ } catch (error) {
+ if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ setDownloadError(error instanceof Error ? error.message : "Download URL could not be loaded.");
+ } finally {
+ authRequest.release();
+ if (downloadControllerRef.current === controller) {
+ downloadControllerRef.current = null;
+ setDownloadingSource(false);
+ }
+ }
+ })();
+ downloadActionRef.current = action;
+ void action.finally(() => {
+ if (downloadActionRef.current === action) downloadActionRef.current = null;
+ });
+ return action;
}, [
+ authorizationHeader,
+ clientDemoMode,
+ currentDocumentFileName,
documentId,
- registerAuthRequest,
isAuthEpochCurrent,
- clientDemoMode,
- authorizationHeader,
markSessionExpired,
- applySignedUrlResults,
+ registerAuthRequest,
]);
- useEffect(() => () => refreshControllerRef.current?.abort(), []);
+ useEffect(
+ () => () => {
+ downloadControllerRef.current?.abort();
+ downloadControllerRef.current = null;
+ downloadActionRef.current = null;
+ },
+ [documentId],
+ );
// Distinguishes a full document (re)load — a new documentId or an explicit
// retry (previewAttempt) — from page/chunk navigation on the already-loaded
// document. Navigation only re-windows the detail; a full load also resets the
- // preview and re-issues signed URLs.
+ // preview and re-issues only its signed URL.
const loadedKeyRef = useRef(null);
+ const detailControllerRef = useRef(null);
+ const detailRequestSequenceRef = useRef(0);
+ const localProjectIdentityPromiseRef = useRef | null>(null);
+ const initialRouteRef = useRef({ documentId, initialPage, chunkId });
+ const navigatedFromInitialRouteRef = useRef(false);
useEffect(() => {
if (!canViewSourceDocuments && authStatus === "loading") {
@@ -1924,33 +1994,70 @@ export function DocumentViewer({
return () => undefined;
}
+ const matchesInitialRoute =
+ initialRouteRef.current.documentId === documentId &&
+ initialRouteRef.current.initialPage === activePage &&
+ initialRouteRef.current.chunkId === activeChunkId;
+ if (!matchesInitialRoute) navigatedFromInitialRouteRef.current = true;
+ const useInitialResult =
+ previewAttempt === 0 &&
+ matchesInitialRoute &&
+ !navigatedFromInitialRouteRef.current &&
+ Boolean(initialDetail || initialError);
+
+ detailControllerRef.current?.abort();
const controller = new AbortController();
+ detailControllerRef.current = controller;
+ const requestSequence = ++detailRequestSequenceRef.current;
const authRequest = registerAuthRequest(controller);
const loadKey = documentLoadKey(documentId, previewAttempt);
const isFullReload = isFullDocumentReload(loadedKeyRef.current, loadKey);
const reset = window.setTimeout(() => {
// Skip the reset on navigation so the mounted PDF and current content stay
// visible (no loading flash) while the new page window loads in the background.
- if (!controller.signal.aborted && isFullReload) {
+ if (!controller.signal.aborted && isFullReload && !useInitialResult) {
setLoadingDocument(true);
setViewerError(null);
setPreviewError(null);
+ setDownloadError(null);
+ setDownloadingSource(false);
setSignedUrl(null);
setDownloadSignedUrl(null);
}
}, 0);
const detailParams = new URLSearchParams({
- page: String(Math.max(1, initialPage || 1)),
+ page: String(Math.max(1, activePage || 1)),
pageLimit: "9",
chunkLimit: "16",
+ assetScope: "window",
});
- if (chunkId) detailParams.set("chunk", chunkId);
+ if (activeChunkId) detailParams.set("chunk", activeChunkId);
const detailUrl = `/api/documents/${documentId}?${detailParams.toString()}`;
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
- const downloadSignedUrlEndpoint = `${signedUrlEndpoint}?download=true`;
- readLocalProjectIdentity()
+ if (!localProjectIdentityPromiseRef.current) {
+ const pendingIdentity = readLocalProjectIdentity();
+ localProjectIdentityPromiseRef.current = pendingIdentity;
+ void pendingIdentity.then(
+ (identity) => {
+ if (!identity?.localServer?.safeLocalOrigin && localProjectIdentityPromiseRef.current === pendingIdentity) {
+ localProjectIdentityPromiseRef.current = null;
+ }
+ },
+ () => {
+ if (localProjectIdentityPromiseRef.current === pendingIdentity) {
+ localProjectIdentityPromiseRef.current = null;
+ }
+ },
+ );
+ }
+ const identityRequest = localProjectIdentityPromiseRef.current!;
+ identityRequest
.then((identity) => {
- if (!isAuthEpochCurrent(authRequest.epoch)) {
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ ) {
throw new DOMException("Stale authentication epoch", "AbortError");
}
if (!identity?.localServer?.safeLocalOrigin) {
@@ -1959,47 +2066,57 @@ export function DocumentViewer({
}
setLocalProjectReady(true);
- const detailRequest = fetch(detailUrl, {
- signal: controller.signal,
- headers: clientDemoMode ? undefined : authorizationHeader,
- }).then(async (response) => {
- const payload = await response.json();
- if (response.status === 401) markSessionExpired();
- if (!response.ok) throw new Error(payload.error || "Document details could not be loaded.");
- return payload;
- });
- // Navigation keeps the current signed URLs; only a full load re-issues them.
- const signedUrlPair = isFullReload
- ? fetchSignedUrlPair(signedUrlEndpoint, downloadSignedUrlEndpoint, {
+ const detailRequest: Promise = useInitialResult
+ ? initialDetail
+ ? Promise.resolve(initialDetail)
+ : Promise.reject(new Error(initialError || "Document could not be loaded."))
+ : fetch(detailUrl, {
signal: controller.signal,
headers: clientDemoMode ? undefined : authorizationHeader,
- onUnauthorized: markSessionExpired,
- useCache: true,
- })
+ }).then(async (response) => {
+ const payload = await response.json();
+ if (response.status === 401) markSessionExpired();
+ if (!response.ok) throw new Error(payload.error || "Document details could not be loaded.");
+ return payload as DocumentDetailPayload;
+ });
+ // Navigation keeps the current preview; a full load re-issues only the preview URL.
+ const previewRequest = isFullReload
+ ? Promise.allSettled([openSourcePreview({ signal: controller.signal, useCache: true })])
: Promise.resolve(null);
- return Promise.all([Promise.allSettled([detailRequest]), signedUrlPair]);
+ return Promise.all([Promise.allSettled([detailRequest]), previewRequest]);
})
- .then(([[detailResult], signedUrlPair]) => {
- if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
+ .then(([[detailResult], previewResults]) => {
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ )
+ return;
const detailLoaded = detailResult.status === "fulfilled";
- // Advance the loaded key only on a successful detail load. A failed full
- // load must stay "not loaded" so the next page/chunk navigation is still
- // treated as a full reload — re-fetching signed URLs and refreshing the
- // error — rather than a cheap navigation that skips that recovery.
- loadedKeyRef.current = nextLoadedDocumentKey(loadedKeyRef.current, loadKey, detailLoaded);
+ // The server-rendered initial result (including a sanitized failure) is
+ // already authoritative for this attempt. Mark it handled so an auth
+ // state refresh cannot duplicate the initial detail/preview requests;
+ // an explicit retry increments previewAttempt and gets a fresh key.
+ loadedKeyRef.current = useInitialResult
+ ? loadKey
+ : nextLoadedDocumentKey(loadedKeyRef.current, loadKey, detailLoaded);
if (detailLoaded) {
const detail = detailResult.value;
setDocument(detail.document ?? null);
- setPages(detail.pages ?? []);
- setImages(detail.images ?? []);
- setTableFacts(detail.tableFacts ?? []);
- setChunks(detail.chunks ?? []);
+ // Keep the previous window visible while loading, then atomically
+ // replace it so client memory and mounted DOM stay bounded.
+ setPages(rowsById(detail.pages));
+ setImages(rowsById(detail.images));
+ setTableFacts(rowsById(detail.tableFacts));
+ setChunks(rowsById(detail.chunks));
setIndexHealth(detail.indexHealth ?? null);
- } else if (isFullReload) {
- // Only clear the viewer on a full load; a transient detail failure
- // during navigation keeps the current content on screen.
+ setServerDemoMode(detail.demoMode);
+ setViewerError(null);
+ } else {
+ // Never retain evidence from the previous page under a newly selected
+ // route. A navigation failure becomes an explicit retryable error.
setDocument(null);
setPages([]);
setImages([]);
@@ -2019,23 +2136,38 @@ export function DocumentViewer({
}
}
- if (signedUrlPair) {
- const [signedUrlResult, signedDownloadUrlResult] = signedUrlPair;
- applySignedUrlResults(signedUrlResult, signedDownloadUrlResult, signedUrlEndpoint, downloadSignedUrlEndpoint);
+ if (previewResults) {
+ const previewResult = previewResults[0];
+ if (previewResult) applyPreviewSignedUrlResult(previewResult, signedUrlEndpoint);
}
})
.catch((error) => {
- if (controller.signal.aborted || !isAuthEpochCurrent(authRequest.epoch)) return;
- if (isFullReload) setViewerError(error instanceof Error ? error.message : "Document could not be loaded.");
+ if (
+ controller.signal.aborted ||
+ requestSequence !== detailRequestSequenceRef.current ||
+ !isAuthEpochCurrent(authRequest.epoch)
+ )
+ return;
+ setDocument(null);
+ setPages([]);
+ setImages([]);
+ setTableFacts([]);
+ setChunks([]);
+ setIndexHealth(null);
+ setViewerError(error instanceof Error ? error.message : "Document could not be loaded.");
})
.finally(() => {
- if (!controller.signal.aborted) setLoadingDocument(false);
+ if (!controller.signal.aborted && requestSequence === detailRequestSequenceRef.current) {
+ setLoadingDocument(false);
+ if (detailControllerRef.current === controller) detailControllerRef.current = null;
+ }
});
return () => {
window.clearTimeout(reset);
controller.abort();
authRequest.release();
+ if (detailControllerRef.current === controller) detailControllerRef.current = null;
};
}, [
authStatus,
@@ -2044,14 +2176,17 @@ export function DocumentViewer({
canViewSourceDocuments,
clientDemoMode,
documentId,
- chunkId,
- initialPage,
+ activeChunkId,
+ activePage,
isConfigured,
markSessionExpired,
registerAuthRequest,
isAuthEpochCurrent,
previewAttempt,
- applySignedUrlResults,
+ initialDetail,
+ initialError,
+ openSourcePreview,
+ applyPreviewSignedUrlResult,
]);
useEffect(() => {
@@ -2240,9 +2375,9 @@ export function DocumentViewer({
? "Document"
: "Source unavailable";
const headerSubtitle = readyDocument
- ? `page ${initialPage} · ${readyDocument.file_name}`
+ ? `page ${activePage} · ${readyDocument.file_name}`
: viewerState === "loading"
- ? `page ${initialPage} · loading source`
+ ? `page ${activePage} · loading source`
: (effectiveViewerError ?? "Source unavailable");
const documentHomeHref = "/?mode=documents";
const scopedDocumentHref = readyDocument
@@ -2251,8 +2386,10 @@ export function DocumentViewer({
const usefulPageHref = (page: number) => documentPageHref(documentId, page);
const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis;
const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering";
- const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0];
- const selectedChunk = chunkId ? chunks.find((chunk) => chunk.id === chunkId) : undefined;
+ const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]);
+ const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]);
+ const selectedPage = pageByNumber.get(activePage) ?? pages[0];
+ const selectedChunk = activeChunkId ? chunkById.get(activeChunkId) : undefined;
const { clinicalImages, auditImages } = partitionViewerImages(images);
const generatedSummaryText = summary ? cleanClinicalSummaryText(summary.answer) : "";
const generatedAnswerIsSummary = summaryQuery === documentSummaryQuestion;
@@ -2269,30 +2406,18 @@ export function DocumentViewer({
? [indexHealth.warnings]
: [];
useEffect(() => {
- if (!chunkId || loadingDocument) return;
- // Both the mobile and desktop IndexedTextPanel render the pinned chunk, so a
- // plain querySelector returns the first match in DOM order — the mobile one,
- // which is display:none on lg+ and lives in a collapsed on phones.
- // Scroll the copy the user can actually see: skip display:none matches and
- // expand the mobile when the pinned chunk only exists inside it.
- const matches = Array.from(
- window.document.querySelectorAll(`[data-source-chunk-id="${CSS.escape(chunkId)}"]`),
- );
- const isDisplayed = (element: HTMLElement) => element.offsetParent !== null || element.getClientRects().length > 0;
- const inClosedDetails = (element: HTMLElement) => Boolean(element.closest("details:not([open])"));
- let target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element));
- if (!target) {
- const collapsed = matches
- .map((element) => element.closest("details"))
- .find((node): node is HTMLDetailsElement => node instanceof HTMLDetailsElement && !node.open);
- if (collapsed) collapsed.open = true;
- target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)) ?? matches[0];
- }
- target?.scrollIntoView({ block: "center", behavior: "smooth" });
- }, [chunkId, loadingDocument, chunks.length]);
+ if (!activeChunkId || loadingDocument) return;
+ window.document
+ .querySelector(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`)
+ ?.scrollIntoView({ block: "center", behavior: "smooth" });
+ }, [activeChunkId, loadingDocument, chunks.length]);
const retryPreview = () => {
setViewerError(null);
setPreviewError(null);
+ setDownloadError(null);
+ // Re-open the guarded load path after a transient identity failure; the
+ // cleared identity promise is still revalidated before any API request.
+ setLocalProjectReady(true);
setLoadingDocument(true);
setPreviewAttempt((current) => current + 1);
};
@@ -2300,8 +2425,8 @@ export function DocumentViewer({
signedUrlRefreshCountRef.current = 0;
}, [documentId]);
// The PDF signed URL has a 10-min TTL and pdf.js holds a dead reference once it
- // expires. When the canvas reports an expiry, drop the cached URLs and re-run
- // the fetch pipeline to mint fresh ones (bounded so a broken URL can't loop).
+ // expires. When the canvas reports an expiry, drop cached URLs and mint a fresh
+ // preview only (bounded so a broken URL can't loop). Download remains click-gated.
// Stable identity (useCallback) so the memoised PdfCanvasViewer isn't re-rendered
// — and its page re-rastered — every time an unrelated parent state (source-search
// keystroke, composer focus, online/offline) changes.
@@ -2311,6 +2436,7 @@ export function DocumentViewer({
const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`;
clearCachedSignedUrl(signedUrlEndpoint);
clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`);
+ setDownloadSignedUrl(null);
refreshSignedUrls();
}, [documentId, refreshSignedUrls]);
// A successful reload means the refreshed URL was accepted, so the recovery
@@ -2490,7 +2616,24 @@ export function DocumentViewer({
Download PDF
- ) : null}
+ ) : (
+
+ )}