Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .design-sync/config.json

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions data/repo-awareness-snapshot.json
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
{
"version": "repo-awareness-snapshot-v1",
"captured_revision": {
"sha": "dc68600786530b60c732aefe050a02e742eb2421",
"committed_at": "2026-08-26T05:55:49+08:00"
"sha": "c391de340e5946a35d5c7d9bf580ebe019e90c31",
"committed_at": "2026-08-26T00:26:15+00:00"
},
"routes": {
"modes": [
Expand DownExpand Up@@ -1239,6 +1239,10 @@
"path": "/api/documents/[id]",
"file": "src/app/api/documents/[id]/route.ts"
},
{
"path": "/api/documents/[id]/cover",
"file": "src/app/api/documents/[id]/cover/route.ts"
},
{
"path": "/api/documents/[id]/labels",
"file": "src/app/api/documents/[id]/labels/route.ts"
Expand DownExpand Up@@ -1378,7 +1382,7 @@
"product_pages": 55,
"mockup_pages": 137,
"redirects": 17,
"api": 56
"api": 57
}
},
"documentation": {
Expand Down
1 change: 1 addition & 0 deletions docs/site-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -1303,6 +1303,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir
- `/api/differentials/presentations/[slug]` - Presentation workflow comparison data endpoint. Source: `src/app/api/differentials/presentations/[slug]/route.ts`.
- `/api/documents` - Document collection operations. Source: `src/app/api/documents/route.ts`.
- `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`.
- `/api/documents/[id]/cover` - Route discovered from app directory Source: `src/app/api/documents/[id]/cover/route.ts`.
- `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`.
- `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`.
- `/api/documents/[id]/reviews` - Document clinical review audit log. Source: `src/app/api/documents/[id]/reviews/route.ts`.
Expand Down
119 changes: 119 additions & 0 deletions src/app/api/documents/[id]/cover/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { z } from "zod";

import { rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { demoImages } from "@/lib/demo-data";
import { isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError, publicErrorResponse } from "@/lib/http";
import { fetchDocumentCoverImageIds } from "@/lib/document-enrichment";
import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline";
import { parseRouteParams } from "@/lib/validation/params";
import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";

export const runtime = "nodejs";

const coverRouteParamsSchema = z.object({ id: z.string().uuid() });
const coverImageIdSchema = z.string().uuid();

/**
* The document's first-page cover thumbnail id, for surfaces that show what a
* cited document looks like rather than what it says.
*
* It exists as its own route because the only alternatives were worse. The
* cover id rides `RelatedDocument` on the search payload, but the answer
* surface never calls `/api/search`; adding it to the answer's own source rows
* would mean editing retrieval hydration, which is a protected RAG surface and
* a far larger blast radius than a thumbnail earns. `/api/documents/[id]`
* already carries the id but returns pages, chunks and images with it — a
* kilobyte-scale payload to render one 90px picture.
*
* Authorization is the same shape the rest of the document API uses: the read
* rate limit first, then an owner-scoped existence check on `documents` BEFORE
* `document_images` is touched, so an unauthorized caller cannot learn whether
* a document id is real from the difference between two responses. The id it
* returns is not itself a capability — `/api/images/[id]/signed-url` re-checks
* ownership and committed-generation before it signs anything.
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: rawId } = await params;

if (isDemoMode()) {
const cover = demoImages.find((image) => image.document_id === rawId && image.source_kind === "cover_page");
return NextResponse.json({ coverImageId: cover?.id ?? null, demoMode: true });
}

const { id } = parseRouteParams({ id: rawId }, coverRouteParamsSchema, "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);
}
request.signal.throwIfAborted();

const { data: document, error: documentError } = await withOwnerReadScope(
supabase.from("documents").select("id,metadata").eq("id", id),
access.ownerId,
)
.abortSignal(request.signal)
.maybeSingle();
if (documentError) throw new Error(documentError.message);
if (!document) return publicErrorResponse("Document not found.", 404, { code: "document_not_found" });

/*
* `documents.metadata.cover_image_id` is the SELECTED cover: the worker
* writes it in the same committed-core metadata patch as
* `index_generation_id` (worker/main.ts), so it names the cover belonging to
* the generation the document currently serves.
*
* Prefer it over scanning `document_images` for a `cover_page` row. That
* scan takes whichever row comes back first, with no ordering and no
* generation filter, so a document mid-reindex or mid-cover-repair can hand
* back a staged row — which `/api/images/[id]/signed-url` then refuses as
* uncommitted, silently losing the thumbnail — or an obsolete duplicate,
* which shows the wrong front page beside a citation.
*/
const metadata =
document.metadata && typeof document.metadata === "object" && !Array.isArray(document.metadata)
? (document.metadata as Record<string, unknown>)
: null;
if (metadata && Object.hasOwn(metadata, "cover_image_id")) {
const parsedPointer = coverImageIdSchema.safeParse(metadata.cover_image_id);
if (!parsedPointer.success) return NextResponse.json({ coverImageId: null });

const { data: cover, error: coverError } = await supabase
.from("document_images")
.select("id,metadata")
.eq("id", parsedPointer.data)
.eq("document_id", id)
.eq("source_kind", "cover_page")
.abortSignal(request.signal)
.maybeSingle();
if (coverError) throw new Error(coverError.message);
if (
!cover ||
!isCommittedGenerationMetadata({
rowMetadata: cover.metadata,
committedGeneration: committedIndexGeneration(metadata),
})
) {
return NextResponse.json({ coverImageId: null });
}

return NextResponse.json({ coverImageId: cover.id });
}

// Documents indexed before the pointer existed carry no such key. Fall back
// to the scan rather than drop their thumbnail: it is the same resolution
// the document search cards already use, and the signed-url route still
// re-checks ownership and committed generation before it hands anything out.
const covers = await fetchDocumentCoverImageIds(supabase, [id], request.signal);
Comment thread
BigSimmo marked this conversation as resolved.
return NextResponse.json({ coverImageId: covers.get(id) ?? null });
} catch (error) {
if (error instanceof AuthenticationError) return unauthorizedResponse();
if (error instanceof PublicApiError) return jsonError(error);
return jsonError(error);
}
}
15 changes: 15 additions & 0 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -1876,6 +1876,21 @@ summary::-webkit-details-marker {
--answer-mark-gap-star: 0.03em;
}

/* The chat answer message's left gutter: the assistant badge column plus its
gap. Two non-nested places have to agree on it — the badge lives inside
`plain-answer-response` (answer-content.tsx) while the verification notice
and support word sit in the card header above it (answer-card.tsx, bare
frame) — and when they disagree the governance line hangs off the left of the
answer it belongs to. A shared value is what keeps them in one column.
px-1 (0.25rem) + badge h-8/w-8 (2rem) + gap-2 (0.5rem) = 2.75rem, plus the
1px transparent border on that section. The border is invisible but it is
real geometry: without it in the sum the notice lands 1px left of the prose,
which on a crisp display is a visible ragged edge rather than a rounding
artefact. */
:root {
--answer-message-gutter: calc(2.75rem + 1px);
}

.answer-source-mark {
position: relative;
top: -0.625em;
Expand Down
5 changes: 4 additions & 1 deletion src/components/clinical-dashboard/answer-content.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -489,7 +489,10 @@ export function NaturalLanguageAnswer({
))}
</span>
</p>
<div className="space-y-1 -mb-2">
{/* No negative bottom margin. It pulled the rail up by 8px, and the rail
heading used to carry a top border — the two collided and drew a rule
straight through the Source-only pill. */}
<div className="space-y-1">
{sourceOnly ? (
<section
data-testid="source-only-disclosure"
Expand Down
11 changes: 9 additions & 2 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,7 +183,10 @@ function StagedAnswerResultSurfaceImpl({
// `state` cannot narrow that at the call site.
const answerVerification = {
state: answerState.kind,
presentation: "responsive-compact" as const,
// Chat framing: one quiet governed line above the prose at every width, with
// the complete wording still printed. Clinical owner approved 2026-08-25 —
// see the `inline` docstring in verification-notice.tsx.
presentation: "inline" as const,
// From the quality tier, never from the state kind: #207 precedence lets
// stale/partial/ungrounded outrank source_only, so keying on the kind announced
// "AI-generated" directly above the Source-only disclosure saying no model wrote
Expand DownExpand Up@@ -260,14 +263,15 @@ function StagedAnswerResultSurfaceImpl({
reader nothing. */}
<UserQuestionBubble query={query} />
{answerState.kind === "ready" ? (
<AnswerCard state={answerState} verification={answerVerification} support={answerSupport}>
<AnswerCard state={answerState} verification={answerVerification} support={answerSupport} frame="bare">
{answerProse}
</AnswerCard>
) : (
<AnswerCard
state={answerState}
verification={answerVerification}
support={answerSupport}
frame="bare"
// Navigate to the cited page — do not reuse onScopeDocument. That
// handler only replaces selectedDocumentIds and leaves the clinician
// on the answer screen with a silent filter change while the button
Expand All@@ -290,6 +294,9 @@ function StagedAnswerResultSurfaceImpl({
onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined}
pendingFeedback={pendingFeedback}
onSubmitFeedback={onSubmitFeedback}
// Chat framing: safety keeps its row, the other two collapse to
// one line of buttons rather than two 56px stacked rows.
density="compact"
/>
) : null}

Expand Down
42 changes: 39 additions & 3 deletions src/components/clinical-dashboard/answer-source-drawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import { cn, glassOverlaySurface, subtleStatusPill, textMuted } from "@/componen
import { logSourceOpen } from "@/components/clinical-dashboard/source-actions";
import { cleanDisplayTitle, sourceQuoteDisplayText } from "@/components/clinical-dashboard/display-text";
import { SignedImage } from "@/components/clinical-dashboard/signed-image";
import { useDocumentCoverImageId } from "@/components/clinical-dashboard/use-document-cover";
import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-evidence";
import {
answerSourceRailRowId,
Expand DownExpand Up@@ -139,6 +140,9 @@ export function AnswerSourceDrawer({
const sourceTables = open ? tablesForSource(tables, sources, openIndex) : [];
const sourceImages = open ? imagesForSource(visualEvidence, sources, openIndex) : [];
const stale = source ? sourceRowIsStale(source) : false;
// Hooks cannot be conditional, so this asks for the open source's cover on
// every render and resolves to null while the drawer is closed.
const coverImageId = useDocumentCoverImageId(source?.documentId);
const numbered = sources.length <= NUMBERED_PAGER_LIMIT;

return (
Expand DownExpand Up@@ -252,9 +256,41 @@ export function AnswerSourceDrawer({
>
{source ? (
<div className="grid gap-3 pb-3">
<p data-testid="answer-source-drawer-support" className="text-sm leading-6 text-[color:var(--text)]">
{sourceSupportSentence(source, activeSupportIndex, activeClaimSupport)}
</p>
{/* What the document looks like, next to what it says. A citation is a
pointer into a physical-looking artefact, and a clinician who has
seen the front page of the protocol recognises it faster than they
read its title.

The caption is not decoration. This is the FRONT page, never a
render of the cited page — the index stores one cover thumbnail per
document and no per-page renders — so an uncaptioned picture beside
"p. 12" would read as page 12 and quietly misrepresent the
evidence. Say which page it is, and say where the passage actually
sits. */}
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-3">
{coverImageId ? (
<figure data-testid="answer-source-drawer-cover" className="w-20 shrink-0">
{/* Same 3:4 frame, surface and accent edge as `DocumentPagePreview`
on the document search card: one document, two surfaces, one
look. */}
<SignedImage
endpoint={`/api/images/${coverImageId}/signed-url`}
alt={`Front page of ${cleanDisplayTitle(source.title)}`}
aspectRatio={3 / 4}
className="rounded-lg border border-t-[3px] border-[color:var(--border-lux)] border-t-[color:var(--clinical-accent)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]"
rootMargin="0px"
priority
/>
<figcaption className={cn("mt-1 text-3xs leading-4", textMuted)}>
Front page
{typeof source.pageNumber === "number" ? ` · passage on p. ${source.pageNumber}` : null}
</figcaption>
</figure>
) : null}
<p data-testid="answer-source-drawer-support" className="text-sm leading-6 text-[color:var(--text)]">
{sourceSupportSentence(source, activeSupportIndex, activeClaimSupport)}
</p>
</div>

{stale ? (
<p
Expand Down
5 changes: 4 additions & 1 deletion src/components/clinical-dashboard/answer-source-rail.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,10 @@ export function AnswerSourceRail({
<p
data-testid="answer-source-rail-heading"
className={cn(
"mb-1.5 flex items-baseline justify-between gap-2 border-t border-[color:var(--border)] pt-2.5 text-2xs font-semibold uppercase tracking-wide",
// No top border. The rule ran the full column width while the
// Source-only pill above it is `w-fit`, so on a source-only answer it
// read as a line struck through the pill rather than as a separator.
"mb-1.5 flex items-baseline justify-between gap-2 text-2xs font-semibold uppercase tracking-wide",
textMuted,
)}
>
Expand Down
Loading
Loading