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
12 changes: 7 additions & 5 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import { isPdfDocument } from "@/lib/pdf-documents";
import { useSubmittedModeSearch } from "@/components/clinical-dashboard/use-submitted-mode-search";
import { useSettingsState } from "./clinical-dashboard/SettingsStateProvider";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { useAnswerSearchParams, usePrivateAnswerRouter } from "@/components/clinical-dashboard/use-private-answer-url";
import { resolveAnswerHandoffParams } from "@/lib/answer-question-handoff";
import { Clock3, ExternalLink, FolderOpen, Activity } from "lucide-react";
import {
type CSSProperties,
Expand Down Expand Up @@ -234,8 +236,8 @@ function ClinicalDashboardContent({
autoRunSearch = false,
clinicalAskAvailableModeIds = [],
}: ClinicalDashboardProps = {}) {
const router = useRouter();
const searchParams = useSearchParams();
const router = usePrivateAnswerRouter();
const searchParams = useAnswerSearchParams();
const pathname = usePathname();
const [lastAppMode, setLastAppMode] = useLastAppMode();
const [initialSearchNavigationContext] = useState(() => readSearchNavigationContext(searchParams));
Expand Down Expand Up @@ -1017,7 +1019,7 @@ function ClinicalDashboardContent({

useEffect(() => {
if (urlSearchBootstrappedRef.current) return;
const params = new URLSearchParams(window.location.search);
const params = resolveAnswerHandoffParams(new URLSearchParams(window.location.search));
const mode = params.get("mode");
const searchText = params.get("q")?.trim();
const shouldFocusComposer = params.get("focus") === "1";
Expand Down Expand Up @@ -1046,7 +1048,7 @@ function ClinicalDashboardContent({
useEffect(() => {
if (urlDocumentSearchBootstrappedRef.current) return;
if (authStatus === "loading") return;
const params = new URLSearchParams(window.location.search);
const params = resolveAnswerHandoffParams(new URLSearchParams(window.location.search));
const mode = params.get("mode");
const searchText = params.get("q")?.trim();
if (!searchText || !isAppModeId(mode) || !isAppModeVisible(mode)) return;
Expand Down
4 changes: 2 additions & 2 deletions src/components/clinical-dashboard/cross-mode-links.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePrivateAnswerRouter } from "@/components/clinical-dashboard/use-private-answer-url";
import { useEffect, useId, useMemo, useState } from "react";
import { ArrowUpRight, ChevronDown, Layers, Search, type LucideIcon } from "lucide-react";

Expand Down Expand Up @@ -210,7 +210,7 @@ export function CrossModeLinksSection({
*/
universalMode?: AppModeId;
}) {
const router = useRouter();
const router = usePrivateAnswerRouter();
const services = useRegistryRecords("service", { enabled, view: "search" });
const forms = useRegistryRecords("form", { enabled, view: "search" });
// fields=index keeps this to the ~30 KB identity slice of the catalog.
Expand Down
14 changes: 10 additions & 4 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import {
useAnswerSearchParams,
useMoveAnswerQuestionOutOfAddress,
usePrivateAnswerRouter,
} from "@/components/clinical-dashboard/use-private-answer-url";
import {
Suspense,
type CSSProperties,
Expand Down Expand Up @@ -243,8 +248,9 @@ function GlobalSearchShellRoute(props: GlobalSearchShellProps & { pathname: stri

function GlobalSearchShellDashboardGate(props: GlobalSearchShellProps) {
const pathname = usePathname() ?? "/";
const router = useRouter();
const searchParams = useSearchParams();
const router = usePrivateAnswerRouter();
const searchParams = useAnswerSearchParams();
useMoveAnswerQuestionOutOfAddress(searchParams.toString());
const landingPreferenceAppliedRef = useRef(false);
useEffect(() => {
if (landingPreferenceAppliedRef.current) return;
Expand Down Expand Up @@ -402,7 +408,7 @@ function GlobalStandaloneSearchShellBody({
mobileChromeVisible = true,
searchParamString,
}: GlobalSearchShellProps & { searchParamString: string }) {
const router = useRouter();
const router = usePrivateAnswerRouter();
const pathname = usePathname() ?? "/";
const searchParams = useMemo(() => new URLSearchParams(searchParamString), [searchParamString]);
const inputRef = useRef<HTMLInputElement>(null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AppModeId } from "@/lib/app-modes";
import { resolveAnswerHandoffParams } from "@/lib/answer-question-handoff";
import { isSmartLocalOnlyMode, interpretSmartSearch } from "@/lib/smart-search-intent";

/**
Expand All @@ -13,7 +14,8 @@ import { isSmartLocalOnlyMode, interpretSmartSearch } from "@/lib/smart-search-i
export function shouldRunUniversalAlsoMatches(modeId: AppModeId, locationSearch: string | null, query = "") {
if (isSmartLocalOnlyMode(modeId) && interpretSmartSearch(modeId, query).naturalLanguage) return false;
if (modeId !== "answer" || locationSearch === null) return true;
const params = new URLSearchParams(locationSearch);
// A submitted Answer URL names its question by an `ask` id (FE-01).
const params = resolveAnswerHandoffParams(new URLSearchParams(locationSearch));
const submittedQuery = (params.get("q") ?? params.get("query") ?? "").trim();
return params.get("run") === "1" && submittedQuery.length > 0;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { TriangleAlert, Clock, CornerDownLeft, Heart, Loader2, Search, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { usePrivateAnswerRouter } from "@/components/clinical-dashboard/use-private-answer-url";
import {
useCallback,
useDeferredValue,
Expand Down Expand Up @@ -555,7 +555,7 @@ export function UniversalSearchCommandSurface({
const smartNaturalSearch = smartInterpretation.naturalLanguage;
const suppressLocalOnlySmartActions = smartNaturalSearch && isSmartLocalOnlyMode(modeId);
const listboxId = useId();
const router = useRouter();
const router = usePrivateAnswerRouter();
// The highlighted row, held by item id and the query it was picked under
// rather than by position: typing, or streamed groups inserting rows above
// it, otherwise moved the highlight onto a different record that Enter then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { isDashboardModeHref } from "@/lib/search-route-ownership";
import type { SearchScopeFilters } from "@/lib/search-scope";
import type { SourceGovernanceWarning } from "@/lib/source-governance";
import { errorCopy } from "@/lib/ui-copy";
import { answerHandoffParam, privateAnswerHref, resolveAnswerHandoffParams } from "@/lib/answer-question-handoff";
import type {
ClinicalQueryMode,
DocumentMatch,
Expand All @@ -84,11 +85,13 @@ const maxClientRetryWaitMs = 10_000;
* different, earlier question belongs to the result still on screen. */
function clearRefusedQueryFromAddress(refused: string) {
const url = new URL(window.location.href);
// The dashboard runs a deep link from `q`, or from its older alias `query`.
const held = (url.searchParams.get("q") ?? url.searchParams.get("query"))?.trim();
// The dashboard runs a deep link from `q`, its older alias `query`, or an Answer `ask` id.
const params = resolveAnswerHandoffParams(url.searchParams);
const held = (params.get("q") ?? params.get("query"))?.trim();
if (held !== refused) return;
url.searchParams.delete("q");
url.searchParams.delete("query");
url.searchParams.delete(answerHandoffParam);
url.searchParams.delete("run");
const rest = url.searchParams.toString();
window.history.replaceState(window.history.state, "", `${url.pathname}${rest ? `?${rest}` : ""}${url.hash}`);
Expand Down Expand Up @@ -796,13 +799,15 @@ export function useDashboardSearchExecution(deps: DashboardSearchExecutionDeps)
window.history.replaceState(
null,
"",
appModeHomeHref(targetMode, {
query: trimmedQuery,
run: true,
queryMode: queryModeOverride,
scopeFilters: filtersOverride,
scopeRef: privateScopeRef,
}),
privateAnswerHref(
appModeHomeHref(targetMode, {
query: trimmedQuery,
run: true,
queryMode: queryModeOverride,
scopeFilters: filtersOverride,
scopeRef: privateScopeRef,
}),
),
);
if (isAnswerFollowUp) {
window.requestAnimationFrame(() => {
Expand Down
59 changes: 59 additions & 0 deletions src/components/clinical-dashboard/use-private-answer-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useSyncExternalStore } from "react";

import {
answerHandoffParam,
privateAnswerHref,
readStashedAnswerQuestion,
resolveAnswerHandoffParams,
} from "@/lib/answer-question-handoff";

function subscribeNoop() {
return () => undefined;
}

/**
* `useSearchParams()` with a submitted Answer URL's `ask` id resolved back to `q`
* (FE-01). The server render has no tab storage, so it sees the bare id and the
* question appears on the first client render after hydration.
*/
export function useAnswerSearchParams() {
const searchParams = useSearchParams();
const ref = searchParams.get(answerHandoffParam);
const question = useSyncExternalStore(
subscribeNoop,
() => readStashedAnswerQuestion(ref),
() => null,
);
return useMemo(
() => resolveAnswerHandoffParams(searchParams, question) as typeof searchParams,
[searchParams, question],
);
}

/** `useRouter()` whose push and replace keep a submitted Answer question out of the address. */
export function usePrivateAnswerRouter() {
const router = useRouter();
return useMemo<typeof router>(
() => ({
...router,
push: (href, options) => router.push(privateAnswerHref(href), options),
replace: (href, options) => router.replace(privateAnswerHref(href), options),
}),
[router],
);
}

/**
* A submitted Answer link that still carries the question (a bookmark, an older
* link, a server redirect) has it moved out of the address as soon as it opens.
*/
export function useMoveAnswerQuestionOutOfAddress(searchParamString: string) {
useEffect(() => {
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const next = privateAnswerHref(current);
if (next !== current) window.history.replaceState(window.history.state, "", next);
}, [searchParamString]);
}
7 changes: 6 additions & 1 deletion src/lib/account-device-sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ const DEVICE_PREFERENCE_KEYS: ReadonlySet<string> = new Set([

/** Clinical drafts outside the `psychsift:` namespace that carry patient free text. */
const CLINICAL_DRAFT_KEYS: readonly string[] = ["psychsift_formulation_draft"];
const CLINICAL_DRAFT_PREFIXES: readonly string[] = ["psychsift_dsm_draft_", "clinical.private-search-scope."];
const CLINICAL_DRAFT_PREFIXES: readonly string[] = [
"psychsift_dsm_draft_",
"clinical.private-search-scope.",
// Answered questions kept out of the address bar (FE-01).
"clinical.answer-question.",
];

const SERVICE_WORKER_CACHE_PREFIX = "clinical-kb-pwa-";

Expand Down
146 changes: 146 additions & 0 deletions src/lib/answer-question-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Keeps an answered Clinical question out of the address bar and browser history
* (privacy audit FE-01, owner decision 2026-10-26).
*
* A submitted Answer URL used to read `/?mode=answer&q=<question>&run=1`, so the
* question sat in the address bar, in browser history and in any link copied from
* it. It now reads `/?mode=answer&ask=<random id>&run=1`, and the question itself
* is kept in this tab's sessionStorage under that id. Refresh and Back in the same
* tab still find it. A copied link, a new tab or a closed tab do not, and land on
* the Answer home instead of re-asking.
*
* Only submitted (`run=1`) Answer URLs change. Drafts and other modes keep `q`.
*/

import { subscribeAccountTransition } from "@/lib/account-scoped-browser-state";

export const answerHandoffParam = "ask";

const storagePrefix = "clinical.answer-question.";
const indexKey = `${storagePrefix}index`;
// Enough for Back through a long session without letting the tab's storage grow unbounded.
const maxStoredQuestions = 30;
const maxQuestionLength = 4000;
const refPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

type HandoffStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;

// When sessionStorage is blocked the question still never goes in the address.
// It survives in-app navigation through this map, but not a refresh.
const memoryStore = new Map<string, string>();
// Sign-out and account switches sweep the sessionStorage copies (account-device-sweep.ts);
// the in-memory copies go with them, so the next person cannot resolve an old `ask` id.
subscribeAccountTransition(() => memoryStore.clear());

function sessionStorageOrNull(): HandoffStorage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}

function readIndex(storage: HandoffStorage): string[] {
try {
const parsed: unknown = JSON.parse(storage.getItem(indexKey) ?? "[]");
return Array.isArray(parsed)
? parsed.filter((ref): ref is string => typeof ref === "string" && refPattern.test(ref))
: [];
} catch {
return [];
}
}

export function readStashedAnswerQuestion(
ref: string | null | undefined,
storage: HandoffStorage | null = sessionStorageOrNull(),
): string | null {
if (!ref || !refPattern.test(ref)) return null;
try {
const stored = storage?.getItem(`${storagePrefix}${ref}`);
if (stored?.trim()) return stored;
} catch {
// Fall through to the in-memory copy.
}
return memoryStore.get(ref) ?? null;
}

/** Stores the question and returns the id that stands for it in the address. */
export function stashAnswerQuestion(
question: string,
storage: HandoffStorage | null = sessionStorageOrNull(),
createRef: () => string = () => crypto.randomUUID(),
): string | null {
const trimmed = question.trim().slice(0, maxQuestionLength);
if (!trimmed) return null;
// The same question keeps its id, so rewriting the address after an answer
// does not churn it.
for (const [ref, stored] of memoryStore) if (stored === trimmed) return ref;
const index = storage ? readIndex(storage) : [];
for (const ref of index) if (readStashedAnswerQuestion(ref, storage) === trimmed) return ref;

const ref = createRef();
if (!refPattern.test(ref)) return null;
memoryStore.set(ref, trimmed);
while (memoryStore.size > maxStoredQuestions) memoryStore.delete(memoryStore.keys().next().value as string);
if (!storage) return ref;
try {
storage.setItem(`${storagePrefix}${ref}`, trimmed);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
const nextIndex = [...index, ref];
for (const dropped of nextIndex.splice(0, Math.max(0, nextIndex.length - maxStoredQuestions))) {
storage.removeItem(`${storagePrefix}${dropped}`);
}
storage.setItem(indexKey, JSON.stringify(nextIndex));
} catch {
// Quota or privacy settings: the in-memory copy still serves this page.
}
return ref;
}

function isSubmittedAnswerParams(pathname: string, params: URLSearchParams) {
if (pathname !== "/" || params.get("run") !== "1") return false;
const mode = params.get("mode");
// A bare `/?q=…&run=1` renders Answer, the default mode.
return mode === "answer" || mode === null;
}

/**
* Rewrites a submitted Answer href so it carries an id instead of the question.
* Every other href is returned unchanged. Client-side only: on the server, or when
* the question cannot be stored at all, the href is returned as it was.
*/
export function privateAnswerHref(href: string, storage: HandoffStorage | null = sessionStorageOrNull()): string {
if (typeof window === "undefined") return href;
let url: URL;
try {
url = new URL(href, window.location.origin);
} catch {
return href;
}
if (url.origin !== window.location.origin || !isSubmittedAnswerParams(url.pathname, url.searchParams)) return href;
const question = (url.searchParams.get("q") ?? url.searchParams.get("query") ?? "").trim();
if (!question) return href;
const ref = stashAnswerQuestion(question, storage);
if (!ref) return href;
url.searchParams.delete("q");
url.searchParams.delete("query");
url.searchParams.set(answerHandoffParam, ref);
return `${url.pathname}${url.search}${url.hash}`;
}

/**
* The URL's params as the dashboard reads them: an `ask` id is resolved back to
* `q` from this tab's storage. An id this tab does not hold resolves to nothing.
*/
export function resolveAnswerHandoffParams<T extends URLSearchParams>(
params: T,
question?: string | null,
): T | URLSearchParams {
const ref = params.get(answerHandoffParam);
if (!ref || params.has("q") || params.has("query")) return params;
const resolved = question === undefined ? readStashedAnswerQuestion(ref) : question;
if (!resolved) return params;
const next = new URLSearchParams(params);
next.set("q", resolved);
return next;
}
Loading
Loading