From 117c0e8cde6fd30d29d7c0a93146192bd5d75a8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:06:24 +0000 Subject: [PATCH 1/2] Keep answered questions out of the address bar and history (FE-01) A submitted Answer URL now carries a random id (?mode=answer&ask=&run=1) instead of the question. The question is kept in this tab's sessionStorage, so refresh and Back still work, while a copied link, new tab or closed tab lands on the Answer home without re-asking. Older links that still carry q are rewritten on arrival. Drafts and other modes are unchanged. Owner decision: Q11, 26 Oct 2026 (recorded in #3827). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Lc51zEGdkbyyuYesESHTbn --- src/components/ClinicalDashboard.tsx | 12 +- .../clinical-dashboard/cross-mode-links.tsx | 4 +- .../global-search-shell.tsx | 14 +- .../universal-search-also-matches-state.ts | 4 +- .../universal-search-command-surface.tsx | 4 +- .../use-dashboard-search-execution.ts | 23 +-- .../use-private-answer-url.ts | 59 ++++++++ src/lib/answer-question-handoff.ts | 141 ++++++++++++++++++ tests/answer-question-handoff.test.ts | 116 ++++++++++++++ tests/ui-smoke.spec.ts | 76 +++++++++- 10 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 src/components/clinical-dashboard/use-private-answer-url.ts create mode 100644 src/lib/answer-question-handoff.ts create mode 100644 tests/answer-question-handoff.test.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a0043c5a61..12df278ccd 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -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, @@ -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)); @@ -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"; @@ -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; diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index 5af963c8a1..3186856cc0 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -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"; @@ -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. diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 7cc14b6c0d..bfaf0daef5 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -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, @@ -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; @@ -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(null); diff --git a/src/components/clinical-dashboard/universal-search-also-matches-state.ts b/src/components/clinical-dashboard/universal-search-also-matches-state.ts index c5c63b7588..1588feac42 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches-state.ts +++ b/src/components/clinical-dashboard/universal-search-also-matches-state.ts @@ -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"; /** @@ -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; } diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 3417dcae29..25dcf52e30 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -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, @@ -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 diff --git a/src/components/clinical-dashboard/use-dashboard-search-execution.ts b/src/components/clinical-dashboard/use-dashboard-search-execution.ts index 837fb02541..8973a3f6db 100644 --- a/src/components/clinical-dashboard/use-dashboard-search-execution.ts +++ b/src/components/clinical-dashboard/use-dashboard-search-execution.ts @@ -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, @@ -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}`); @@ -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(() => { diff --git a/src/components/clinical-dashboard/use-private-answer-url.ts b/src/components/clinical-dashboard/use-private-answer-url.ts new file mode 100644 index 0000000000..21363005db --- /dev/null +++ b/src/components/clinical-dashboard/use-private-answer-url.ts @@ -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( + () => ({ + ...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]); +} diff --git a/src/lib/answer-question-handoff.ts b/src/lib/answer-question-handoff.ts new file mode 100644 index 0000000000..42b2de1c00 --- /dev/null +++ b/src/lib/answer-question-handoff.ts @@ -0,0 +1,141 @@ +/** + * 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=&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=&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`. + */ + +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; + +// 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(); + +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); + 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( + 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; +} diff --git a/tests/answer-question-handoff.test.ts b/tests/answer-question-handoff.test.ts new file mode 100644 index 0000000000..10022c559d --- /dev/null +++ b/tests/answer-question-handoff.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; + +import { + answerHandoffParam, + privateAnswerHref, + readStashedAnswerQuestion, + resolveAnswerHandoffParams, + stashAnswerQuestion, +} from "../src/lib/answer-question-handoff"; + +// Synthetic, non-patient question text only. +const question = "lithium monitoring after dose change"; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + }; +} + +describe("answer question handoff (FE-01)", () => { + afterEach(() => window.sessionStorage.clear()); + + it("writes an id, never the question, into a submitted Answer href", () => { + const href = privateAnswerHref(`/?mode=answer&q=${encodeURIComponent(question)}&run=1&queryMode=auto`); + const url = new URL(href, window.location.origin); + expect(href).not.toContain("lithium"); + expect(url.searchParams.has("q")).toBe(false); + expect(url.searchParams.get("run")).toBe("1"); + expect(url.searchParams.get("mode")).toBe("answer"); + expect(url.searchParams.get("queryMode")).toBe("auto"); + expect(readStashedAnswerQuestion(url.searchParams.get(answerHandoffParam))).toBe(question); + }); + + it("covers the older `query` alias and a bare default-mode link", () => { + for (const href of [ + `/?mode=answer&query=${encodeURIComponent(question)}&run=1`, + `/?q=${encodeURIComponent(question)}&run=1`, + ]) { + const rewritten = privateAnswerHref(href); + expect(rewritten).not.toContain("lithium"); + expect(new URL(rewritten, window.location.origin).searchParams.has(answerHandoffParam)).toBe(true); + } + }); + + it("leaves drafts, other modes, other paths and other sites alone", () => { + for (const href of [ + `/?mode=answer&q=${encodeURIComponent(question)}`, + `/?mode=documents&q=${encodeURIComponent(question)}&run=1`, + `/dsm/search?q=${encodeURIComponent(question)}&run=1`, + `https://example.org/?mode=answer&q=${encodeURIComponent(question)}&run=1`, + "/?mode=answer&focus=1", + ]) { + expect(privateAnswerHref(href)).toBe(href); + } + }); + + it("keeps the same id for the same question, so rewriting after an answer does not churn", () => { + const first = privateAnswerHref(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`); + const second = privateAnswerHref(`/?mode=answer&q=${encodeURIComponent(` ${question} `)}&run=1`); + expect(second).toBe(first); + }); + + it("resolves an id back to q for the dashboard, and an unknown id to nothing", () => { + const ref = stashAnswerQuestion(question); + const resolved = resolveAnswerHandoffParams(new URLSearchParams(`mode=answer&${answerHandoffParam}=${ref}&run=1`)); + expect(resolved.get("q")).toBe(question); + + const unknown = resolveAnswerHandoffParams( + new URLSearchParams(`mode=answer&${answerHandoffParam}=33333333-3333-4333-8333-333333333333&run=1`), + ); + expect(unknown.has("q")).toBe(false); + }); + + it("does not override a q that is already in the URL", () => { + const params = new URLSearchParams(`q=other&${answerHandoffParam}=${stashAnswerQuestion(question)}`); + expect(resolveAnswerHandoffParams(params)).toBe(params); + }); + + it("rejects ids that are not random UUIDs", () => { + expect(readStashedAnswerQuestion("../../etc")).toBeNull(); + expect(stashAnswerQuestion("synthetic never-stored question", memoryStorage(), () => "not-a-uuid")).toBeNull(); + }); + + it("caps how many questions a tab keeps", () => { + const storage = memoryStorage(); + let n = 0; + const createRef = () => `${String(n++).padStart(8, "0")}-0000-4000-8000-000000000000`; + for (let i = 0; i < 45; i += 1) stashAnswerQuestion(`synthetic question ${i}`, storage, createRef); + const questionKeys = [...storage.values.keys()].filter((key) => !key.endsWith("index")); + expect(questionKeys.length).toBe(30); + }); + + it("still keeps the question out of the address when storage throws", () => { + const broken = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + removeItem: () => undefined, + }; + const href = privateAnswerHref( + `/?mode=answer&q=${encodeURIComponent("synthetic blocked-storage question")}&run=1`, + broken, + ); + expect(href).not.toContain("synthetic"); + const ref = new URL(href, window.location.origin).searchParams.get(answerHandoffParam); + expect(readStashedAnswerQuestion(ref, broken)).toBe("synthetic blocked-storage question"); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b3aadda1bf..7cbe184a48 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3675,7 +3675,9 @@ test.describe("PsychSift UI smoke coverage", () => { (url) => url.pathname === "/" && url.searchParams.get("mode") === "answer" && - url.searchParams.get("q") === question && + // The question stays out of the address (FE-01): an `ask` id stands for it. + !url.searchParams.has("q") && + Boolean(url.searchParams.get("ask")) && url.searchParams.get("run") === "1", { timeout: 45_000 }, ); @@ -3720,7 +3722,15 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(page.getByTestId("plain-answer-response")).toHaveCount(1); await expect(page.locator('[data-dashboard-stage="answer-thread-turn"][data-collapsed="true"]')).toHaveCount(1); await expect(composer).toHaveValue(""); - await expect(page).toHaveURL(/\?mode=answer&q=what\+about\+renal\+impairment\%3F&run=1/); + // Only the latest question is named, by an `ask` id rather than the question itself (FE-01). + await expect(page).toHaveURL( + (url) => + url.searchParams.get("mode") === "answer" && + url.searchParams.get("run") === "1" && + /^[0-9a-f-]{36}$/.test(url.searchParams.get("ask") ?? "") && + !url.searchParams.has("q"), + ); + expect(page.url()).not.toContain("renal"); await expectNoPageHorizontalOverflow(page); await waitForPersistedAnswerThread(page, 1); @@ -3734,6 +3744,68 @@ test.describe("PsychSift UI smoke coverage", () => { await expect(page.locator('[data-dashboard-stage="answer-thread-turn"][data-collapsed="true"]')).toHaveCount(1); }); + test("an answered question stays out of the address bar and history (FE-01)", async ({ page, browser }) => { + const answerRequests: string[] = []; + await mockDemoApi(page, { onAnswerRequest: (query) => answerRequests.push(query) }); + // Synthetic question. An older-style link still carries it in `q`. + const question = "lithium monitoring after a dose change"; + await gotoApp(page, `/?mode=answer&q=${encodeURIComponent(question)}&run=1`); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + const noQuestionInAddress = (url: URL) => + url.pathname === "/" && + url.searchParams.get("mode") === "answer" && + url.searchParams.get("run") === "1" && + Boolean(url.searchParams.get("ask")) && + !url.searchParams.has("q") && + !url.href.includes("lithium"); + await expect(page).toHaveURL(noQuestionInAddress); + const answeredUrl = page.url(); + expect(answerRequests).toEqual([question]); + + // A new question from the composer never writes its text into the address either. + const followUp = "what about renal impairment?"; + await fillVisibleQuestionInput(page, followUp); + await visibleAnswerSubmitButton(page).click(); + await expect(page.getByTestId("user-question-bubble")).toHaveCount(2, { timeout: uiAssertionTimeoutMs }); + await expect(page).toHaveURL((url) => noQuestionInAddress(url) && !url.href.includes("renal")); + // A follow-up is sent with its earlier question as context. + expect(answerRequests).toHaveLength(2); + expect(answerRequests[1]).toContain(followUp); + + // Refresh in the same tab keeps the answer and does not ask again. + await waitForPersistedAnswerThread(page, 1); + await page.reload(); + await waitForDemoDashboardReady(page); + await expect(page.getByTestId("user-question-bubble").last()).toContainText(followUp, { + timeout: uiAssertionTimeoutMs, + }); + expect(answerRequests).toHaveLength(2); + + // Back from another page returns to the answer without asking again. + await gotoApp(page, "/?mode=documents"); + await page.goBack(); + await expect(page).toHaveURL(noQuestionInAddress); + await expect(page.getByTestId("user-question-bubble").last()).toContainText(followUp, { + timeout: uiAssertionTimeoutMs, + }); + expect(answerRequests).toHaveLength(2); + + // The same address opened somewhere without this tab's storage (a copied link) + // lands on the Answer home and sends nothing. + const otherContext = await browser.newContext({ baseURL: new URL(answeredUrl).origin }); + try { + const otherPage = await otherContext.newPage(); + const otherRequests: string[] = []; + await mockDemoApi(otherPage, { onAnswerRequest: (query) => otherRequests.push(query) }); + await otherPage.goto(answeredUrl); + await waitForDemoDashboardReady(otherPage); + await expect(otherPage.getByTestId("plain-answer-response")).toHaveCount(0); + expect(otherRequests).toEqual([]); + } finally { + await otherContext.close(); + } + }); + test("answer follow-up suggestions run the next question", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page); From aee39d0a7a8f6c9f8900b823a716be8c47e0a6d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 11:58:34 +0000 Subject: [PATCH 2/2] Forget stored answer questions at sign-out and account switches The device sweep now removes clinical.answer-question.* keys, and the in-memory copies are dropped on the account transition event, so a later user on a shared device cannot resolve an earlier user's ask id. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Lc51zEGdkbyyuYesESHTbn --- src/lib/account-device-sweep.ts | 7 ++++++- src/lib/answer-question-handoff.ts | 5 +++++ tests/answer-question-handoff.test.ts | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/account-device-sweep.ts b/src/lib/account-device-sweep.ts index 90202aeea5..53a3bd5011 100644 --- a/src/lib/account-device-sweep.ts +++ b/src/lib/account-device-sweep.ts @@ -36,7 +36,12 @@ const DEVICE_PREFERENCE_KEYS: ReadonlySet = 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-"; diff --git a/src/lib/answer-question-handoff.ts b/src/lib/answer-question-handoff.ts index 42b2de1c00..7ff12143f0 100644 --- a/src/lib/answer-question-handoff.ts +++ b/src/lib/answer-question-handoff.ts @@ -12,6 +12,8 @@ * 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."; @@ -26,6 +28,9 @@ type HandoffStorage = Pick; // 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(); +// 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 { diff --git a/tests/answer-question-handoff.test.ts b/tests/answer-question-handoff.test.ts index 10022c559d..e8788fa803 100644 --- a/tests/answer-question-handoff.test.ts +++ b/tests/answer-question-handoff.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from "vitest"; +import { clearAccountScopedBrowserStorage } from "../src/lib/account-scoped-browser-state"; import { answerHandoffParam, privateAnswerHref, @@ -95,6 +96,14 @@ describe("answer question handoff (FE-01)", () => { expect(questionKeys.length).toBe(30); }); + it("forgets every stored question at sign-out or an account switch", () => { + const ref = stashAnswerQuestion("synthetic question before sign-out"); + expect(readStashedAnswerQuestion(ref)).toBe("synthetic question before sign-out"); + clearAccountScopedBrowserStorage(); + expect(readStashedAnswerQuestion(ref)).toBeNull(); + expect(Object.keys(window.sessionStorage).filter((key) => key.startsWith("clinical.answer-question."))).toEqual([]); + }); + it("still keeps the question out of the address when storage throws", () => { const broken = { getItem: () => {