Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
21 changes: 12 additions & 9 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,11 @@
"preview": ".design-sync/previews/Breadcrumb.tsx",
"previewValid": true
},
"testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/information-page-shell.dom.test.tsx"],
"testFiles": [
"tests/design-sync-visual-exports.test.ts",
"tests/in-page-nav-playwright-contract.test.ts",
"tests/information-page-shell.dom.test.tsx"
],
"baseline": {
"targetLayer": "v2",
"liveLayer": "v2",
Expand Down
6 changes: 4 additions & 2 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ const SECTIONS: Section[] = [
<>
Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text
is also omitted from durable query logs by default. A short-lived response cache can contain the answer while
its read TTL is valid.
its read TTL is valid. To make recent answers reappear quickly, completed answer threads may also remain in this
browser tab for up to 12 hours. That tab-only copy stays in this tab, is not shared across tabs or devices, and
is never sent to the application service.
</>
),
},
Expand All @@ -64,7 +66,7 @@ const SECTIONS: Section[] = [
},
{
heading: "Retention",
body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Completed answer threads in the current browser tab expire no later than 12 hours after the most recent answer and are also cleared by New chat, sign-out, or an account change. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.",
},
{
heading: "Your responsibilities",
Expand Down
138 changes: 57 additions & 81 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ import {
UploadPanel,
} from "@/components/clinical-dashboard/clinical-dashboard-lazy";

import { clearLegacyRecentQueries, demoRecentQueryOwnerId, recentQueryStorageKey } from "@/lib/recent-query-storage";
import { clearLegacyRecentQueries, recentQueryStorageKey } from "@/lib/recent-query-storage";
import type { SearchFacets } from "@/components/clinical-dashboard/document-search-results";
import { isWeakRelevance } from "@/components/clinical-dashboard/relevance";
import {
Expand Down Expand Up @@ -212,10 +212,15 @@ import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-d
import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up";
import {
clearPersistedAnswerThread,
loadPersistedAnswerThread,
createAnswerThreadSnapshotMetadata,
maxStoredAnswerTurns,
savePersistedAnswerThread,
} from "@/lib/answer-thread-storage";
import { useAnswerThreadBootstrap } from "@/components/clinical-dashboard/use-answer-thread-bootstrap";
import {
resolveDashboardAnswerThreadOwnerId,
usePersistedAnswerThread,
type AnswerThreadSnapshotMetadata,
} from "@/components/clinical-dashboard/use-persisted-answer-thread";
import { buildAnswerClipboardText } from "@/components/clinical-dashboard/answer-copy-payload";
import { buildAnswerRenderModel, isAnswerSourceBacked } from "@/lib/answer-render-policy";
import {
Expand Down Expand Up @@ -342,6 +347,7 @@ export function ClinicalDashboard({
const threadRestoreScrolledRef = useRef(false);
const restoredThreadFromStorageRef = useRef(false);
const latestAnswerTurnRef = useRef<Omit<AnswerTurn, "id"> | null>(null);
const latestAnswerSnapshotMetadataRef = useRef<AnswerThreadSnapshotMetadata | null>(null);
const answerTurnSeqRef = useRef(0);
const [documentMatches, setDocumentMatches] = useState<DocumentMatch[]>([]);
const [searchRelevance, setSearchRelevance] = useState<EvidenceRelevance | null>(null);
Expand Down Expand Up @@ -401,6 +407,7 @@ export function ClinicalDashboard({
setLatestAnswerQuery(null);
setCollapsedTurnIds(new Set());
setShowEarlierTurns(false);
latestAnswerSnapshotMetadataRef.current = null;
const ownerId = activeAnswerThreadOwnerIdRef.current;
if (ownerId) clearPersistedAnswerThread(ownerId);
}, []);
Expand Down Expand Up @@ -656,57 +663,33 @@ export function ClinicalDashboard({
openAccountProfile,
setSettingsOpen: settingsState.setSettingsOpen,
});
const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? demoRecentQueryOwnerId : null);
const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId);
useEffect(() => {
const previousOwnerId = previousAnswerThreadOwnerIdRef.current;
previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId;
activeAnswerThreadOwnerIdRef.current = answerThreadOwnerId;
if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return;
answerThreadBootstrappedRef.current = false;
queueMicrotask(() => {
setPriorAnswerTurns([]);
setLatestAnswerQuery(null);
setCollapsedTurnIds(new Set());
setAnswer(null);
setSources([]);
latestAnswerTurnRef.current = null;
setAnswerThreadBootstrapped(false);
});
}, [answerThreadOwnerId]);
useEffect(() => {
if (authStatus === "loading" || answerThreadBootstrappedRef.current) return;
queueMicrotask(() => {
const persisted = answerThreadOwnerId ? loadPersistedAnswerThread(answerThreadOwnerId) : null;
if (persisted) {
restoredThreadFromStorageRef.current = true;
setPriorAnswerTurns(persisted.priorTurns);
setLatestAnswerQuery(persisted.latestTurn?.query ?? null);
if (persisted.latestTurn) {
latestAnswerTurnRef.current = persisted.latestTurn;
setAnswer(persisted.latestTurn.answer);
setSources(persisted.latestTurn.sources);
setModeSearchSubmitted(true);
setQuery("");
const restoredQuery = persisted.latestTurn.query.trim();
if (restoredQuery) autoRunSearchSignatureRef.current = `answer:${restoredQuery}`;
}
answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => {
const match = /^answer-turn-(\d+)$/.exec(turn.id);
return match ? Math.max(max, Number(match[1])) : max;
}, 0);
setCollapsedTurnIds(
persisted.collapsedTurnIds.length
? new Set(persisted.collapsedTurnIds)
: new Set(persisted.priorTurns.map((turn) => turn.id)),
);
} else if (!answerThreadOwnerId) {
clearPersistedAnswerThread();
}
answerThreadBootstrappedRef.current = true;
setAnswerThreadBootstrapped(true);
});
}, [answerThreadOwnerId, authStatus]);
const answerThreadOwnerId = resolveDashboardAnswerThreadOwnerId(auth.session?.user.id, clientDemoMode, authStatus);
useAnswerThreadBootstrap({
answerThreadOwnerId,
authStatus,
searchMode,
submittedUrlQuery,
expectedSubmissionSignature:
searchMode === "answer" && submittedUrlQuery
? searchSubmissionSignature(searchMode, submittedUrlQuery, routedSearchContext)
: undefined,
activeAnswerThreadOwnerIdRef,
answerThreadBootstrappedRef,
restoredThreadFromStorageRef,
latestAnswerTurnRef,
latestAnswerSnapshotMetadataRef,
answerTurnSeqRef,
autoRunSearchSignatureRef,
setPriorAnswerTurns,
setLatestAnswerQuery,
setCollapsedTurnIds,
setShowEarlierTurns,
setAnswer,
setSources,
setModeSearchSubmitted,
setQuery,
setAnswerThreadBootstrapped,
});
// Local no-auth can still exercise public-read APIs, but administration is always
// derived separately from the immutable account role claim.
const uploadReadOnlyMode = resolveUploadReadOnlyMode({
Expand Down Expand Up @@ -839,29 +822,16 @@ export function ClinicalDashboard({
[answerThreadOwnerId],
);

useEffect(() => {
if (!answerThreadBootstrapped) return;
if (searchMode !== "answer") return;
if (!answer && priorAnswerTurns.length === 0) {
if (answerThreadOwnerId) clearPersistedAnswerThread(answerThreadOwnerId);
return;
}
if (!answerThreadOwnerId) return;
savePersistedAnswerThread(answerThreadOwnerId, {
version: 1,
priorTurns: priorAnswerTurns,
latestTurn: latestAnswerTurnRef.current,
collapsedTurnIds: [...collapsedTurnIds],
});
}, [
searchMode,
usePersistedAnswerThread({
ownerId: answerThreadOwnerId,
enabled: answerThreadBootstrapped && searchMode === "answer",
answer,
priorAnswerTurns,
priorTurns: priorAnswerTurns,
latestTurn: latestAnswerTurnRef.current,
collapsedTurnIds,
latestAnswerQuery,
answerThreadBootstrapped,
answerThreadOwnerId,
]);
showEarlierTurns,
metadata: latestAnswerSnapshotMetadataRef.current,
});

useEffect(() => {
documentsRef.current = documents;
Expand Down Expand Up @@ -1820,7 +1790,7 @@ export function ClinicalDashboard({
const priorTurn = archivePreviousAnswer ? latestAnswerTurnRef.current : null;
if (priorTurn) {
const turnId = `answer-turn-${++answerTurnSeqRef.current}`;
setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }].slice(-maxStoredAnswerTurns));
setPriorAnswerTurns((turns) => [...turns, { id: turnId, ...priorTurn }].slice(-(maxStoredAnswerTurns - 1)));
setCollapsedTurnIds((current) => new Set(current).add(turnId));
}
const committedQuery = displayQuery ?? payload.query;
Expand Down Expand Up @@ -2100,6 +2070,15 @@ export function ClinicalDashboard({

// M10: discard a stale response — a newer search owns the UI state.
if (requestIsCurrent()) {
if (successfulPayload.kind === "answer") {
latestAnswerSnapshotMetadataRef.current = createAnswerThreadSnapshotMetadata(
searchSubmissionSignature(targetMode, trimmedQuery, {
queryMode: targetQueryMode,
scopeFilters: filtersOverride,
scopeRef: privateScopeRef,
}),
);
}
applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer);
if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery);
if (successfulPayload.kind === "answer") {
Expand All @@ -2108,11 +2087,8 @@ export function ClinicalDashboard({
// effect. Seed their completed context so a later in-place route to
// the same query with different intent/scope is recognized as a
// replacement search instead of leaving the old answer on screen.
autoRunSearchSignatureRef.current = searchSubmissionSignature(targetMode, trimmedQuery, {
queryMode: targetQueryMode,
scopeFilters: filtersOverride,
scopeRef: privateScopeRef,
});
autoRunSearchSignatureRef.current =
latestAnswerSnapshotMetadataRef.current?.latestSubmissionSignature ?? null;
// The composer is a draft box in a conversation: clear it so the
// user can type the next follow-up immediately.
setQuery("");
Expand Down
8 changes: 4 additions & 4 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CircleAlert,
Expand All @@ -17,6 +16,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
import { ContextualBackLink } from "@/components/contextual-back-link";
import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal";
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal";
Expand Down Expand Up @@ -1091,14 +1091,14 @@ export function DocumentViewer({
className="edge-glass-header relative z-30 border-b border-[color:var(--border)] py-2 shadow-[var(--shadow-tight)] backdrop-blur-xl max-sm:pt-2 sm:sticky sm:top-0 sm:pt-[max(0.5rem,env(safe-area-inset-top))]"
>
<div className="mx-auto flex min-h-12 min-w-0 max-w-[1440px] items-center gap-2">
<Link
href={documentHomeHref}
<ContextualBackLink
fallbackHref={documentHomeHref}
className="inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-full pl-1.5 pr-3 text-sm font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]"
aria-label="Back to documents"
>
<ArrowLeft aria-hidden="true" className="h-5 w-5 shrink-0" />
<span className="hidden sm:inline">Documents</span>
</Link>
</ContextualBackLink>
{documentSections.length > 0 ? (
// The title is the section-list disclosure. Line two names where you
// are, which the track can place but never label.
Expand Down
Loading
Loading