diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 863c7f745..da4583ee9 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -31,11 +31,22 @@ echo "[session-start] Using node $(node -v) / npm $(npm -v)" cd "$CLAUDE_PROJECT_DIR" # npm ci keeps the lockfile untouched (npm install rewrites peer/optional -# metadata and dirties the worktree); skip entirely when the cached container -# already has node_modules. +# metadata and dirties the worktree). A bare "node_modules exists" check is not +# enough: a cached container keeps stale node_modules after dependency-bumping +# merges, which surfaces as fake typecheck/test regressions (2026-07-19 audit). +# Stamp the lockfile hash after a successful install and reinstall whenever the +# lockfile no longer matches the stamp. +LOCK_STAMP="node_modules/.session-start-lock-hash" +lock_hash="$(sha256sum package-lock.json | cut -d' ' -f1)" if [ ! -d node_modules ]; then npm ci --no-audit --no-fund + echo "$lock_hash" > "$LOCK_STAMP" echo "[session-start] Dependencies installed" +elif [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$lock_hash" ]; then + echo "[session-start] node_modules is stale for the current lockfile, reinstalling" + npm ci --no-audit --no-fund + echo "$lock_hash" > "$LOCK_STAMP" + echo "[session-start] Dependencies reinstalled" else - echo "[session-start] node_modules already present, skipping install" + echo "[session-start] node_modules matches the lockfile, skipping install" fi diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b45573806..d0db96107 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -626,3 +626,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-19 | `origin/main` through PR #903 plus fixed 48-hour PR snapshot (`#689`–`#902`) | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 | whole-repository, all-lens regression and PR-activity review | Changes requested. No P0. Confirmed five P1 defects: stale publication approvals are not bound to reviewed document state; invalid supplied credentials can become anonymous uploads; pooled duplicate uploads expose another uploader's metadata; readiness is fail-open for database usability errors; and settings promise clinical tailoring/alerts with no consumers. Eighteen P2 findings cover semantic rerank effectiveness/privacy, summary prompt trust, PDF resource limits, cancellation, auth-state loss, public storage-path exposure, query validation, provider-boundary/CI/test gaps, factsheet/Therapy behavior, Therapy startup cost, and the PR #903 ledger's incorrect claim that PR #901 has zero unresolved threads. GitHub GraphQL still reports two current unresolved P2 threads on merged PR #901. | Exact tree `68a58f6f..4034d2e60`: 217 commits, 566 files, +59,742/-8,242. Fixed snapshot inventory: 213 PRs created and 25 older PRs updated. On `a871dd765`, `verify:cheap` passed (317 files/2,879 tests), offline RAG passed (21 suites/294 tests), and production build plus required Chromium passed (1,682 pages; 239/239). PR #899 exact head `8242fa63d` has the same full local proof and green hosted checks; PR #903 is docs-only and passed `git diff --check`, docs links, and docs script references. Production-readiness CI, design-system, env parity, workflow guard, and offline audit passed. `docs:check-index` remains advisory-red and full-range `git diff --check` reports four intentional Markdown hard breaks plus three SQL whitespace lines. No OpenAI, Supabase, deployment, live clinical, or provider-backed release command ran. | | 2026-07-19 | local branches and worktrees after PRs #905 and #907 | e377ab1aed44d56c303eede80572c1df82ddcd6e | final local branch/worktree cleanup and useful-history preservation | Reduced 153 local branches to `main` plus the two actively edited task branches. Deleted 150 redundant refs using cherry-pick containment, exact merged-PR heads, synthetic no-op merge trees, exact duplicate-head retention, merged-PR commit association, and explicit supersession review. Removed two clean obsolete worktrees and retained only the active auth/Supabase and P2-remediation worktrees for their owners. Archived the final 66 superseded historical heads in a verified 76,395,256-byte Git bundle before ref deletion. PRs #905 and #907 merged through protected `main`; PR #906 subsequently merged through protected `main` and its remote branch was removed. | Fresh fetch/prune and PR inventory; exact-old-value `git update-ref`; `git merge-tree --write-tree`; GraphQL commit-to-PR association for 178 commits; reverse-patch checks against a detached fresh-main worktree; verified bundle `Database-local-refs-before-final-cleanup-20260719-0525.bundle`; final branch/worktree/remote checks. No OpenAI, Supabase, deployment, live clinical, or production-data workflow ran. | | 2026-07-19 | claude/audit-recent-changes-kde66i (audit-remediation follow-up to the ef042cacd audit row) | see PR head | remediation of the three 2026-07-19 audit findings | Fixed all three findings from the 24-hour merged-window audit. (1) Inert settings honesty (P2): every preference the app does not yet consume — jurisdiction, population, answer style, landing, both home-content toggles, compact citations, and all three notification toggles — now renders an explicit "Saved for later — not active yet" marker in `settings-dialog.tsx`; the functional appearance/density/motion rows stay unmarked. Wiring the preferences into answer generation was deliberately NOT done here (clinical-behavior change requiring governance review); the new dom test documents the contract for flipping a control live. (2) Auth offline resolution (P2/P3): `resolveInitialAuthState` gains `verificationUnavailable`, set from `isAuthRetryableFetchError(getUser().error)`, so an unreachable auth server keeps the stored session signed in while a reachable server that rejects the token still resolves signed_out; server-side bearer validation is unchanged. (3) `/medications` redirect (P3): now preserves the sanitized q/focus/run search context with the same allowlist as the root legacy-mode redirect. | Focused Vitest 30/30 across `settings-inert-preferences.dom` (new), `private-client-auth` (3 new cases), `audit-navigation-auth-regressions` (query-preservation case), `app-preferences`, and `site-map`. Full `verify:cheap` run recorded on the PR. No OpenAI, Supabase, deployment, or provider-backed check ran. | +| 2026-07-19 | claude/audit-recent-changes-kde66i (preference wiring + session-start hook follow-up to the #906 remediation) | see PR head | wiring the wireable inert preferences live and fixing the stale-node_modules session-start gap | Wired three of the seven remaining inert preferences into real behavior and removed their "Saved for later" markers: (1) Default landing view — `GlobalSearchShellClient` applies a one-shot `router.replace` to the saved landing mode (`search`→documents, `browse`→tools via `landingModeForPreference`) on a bare "/" load only; explicit mode/query/run params always win, and the dashboard's existing URL-sync effect performs the switch. (2) Recent searches on home — `AnswerEmptyState` now gates its recent-query chips on `showRecentOnHome`. (3) Compact citations — the answer source capsule drops its text label to icon+count when `compactCitations` is on, with the "No direct source found" warning explicitly exempted so compact mode can never hide a missing-source signal. Still marked inactive with reasons documented in the test contract: jurisdiction/population/answer-style (wiring them into answer generation is provider-eval-gated per the confirmation boundary), saved-protocols-on-home (no protocols module exists), and the three notification toggles (no delivery infrastructure). Separately, `.claude/hooks/session-start.sh` now stamps the `package-lock.json` sha256 into `node_modules/.session-start-lock-hash` after `npm ci` and reinstalls when the lockfile no longer matches, closing the stale-container gap that faked a typecheck regression during the 24h audit. | New `tests/answer-preferences.dom.test.tsx` (recents gate on/off, compact capsule display incl. the missing-source exemption, landing mapping) and the updated `settings-inert-preferences.dom` contract (3 rows moved inert→functional) pass with `app-preferences` and `private-client-auth`: 26/26 focused. Full typecheck, scoped zero-warning ESLint, and the full Vitest suite recorded on the PR; `bash -n` on the hook. `check:production-readiness` not run: no secrets in this container (documented demo-mode expectation) and no answer-generation, retrieval, or source-governance logic changed — the capsule change is presentational with the missing-source warning locked by test. No OpenAI, Supabase, deployment, or provider-backed check ran. | diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index d785524de..3f340df18 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -26,6 +26,7 @@ import { comparableAnswerText, sanitizeAnswerDisplayText, } from "@/components/clinical-dashboard/display-text"; +import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; @@ -258,12 +259,16 @@ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTe // One compact "Sources" pill in every state: the amber Source-only pill and the // "Review source match" banner already carry the verify-first caveat, so the // capsule label no longer restates grounding strength. -function sourceCapsuleDisplay({ sourceCount }: { sourceCount: number }): { +// With the compact-citations preference on, the pill drops its text label to +// icon + count; the "No direct source found" warning always stays worded — +// compact mode must never hide a missing-source signal. +export function sourceCapsuleDisplay({ sourceCount, compact = false }: { sourceCount: number; compact?: boolean }): { label: string; + showLabelText: boolean; showCountBadge: boolean; } { - if (sourceCount <= 0) return { label: "No direct source found", showCountBadge: false }; - return { label: "Sources", showCountBadge: true }; + if (sourceCount <= 0) return { label: "No direct source found", showLabelText: true, showCountBadge: false }; + return { label: "Sources", showLabelText: !compact, showCountBadge: true }; } export function sourceStatusDotClass(metadata: ReturnType | null | undefined) { @@ -574,6 +579,7 @@ export function NaturalLanguageAnswer({ const [sourcePreviewOpen, setSourcePreviewOpen] = useState(false); const [sourceOnlyNoticeOpen, setSourceOnlyNoticeOpen] = useState(false); const [copiedSourceQuote, setCopiedSourceQuote] = useState(false); + const { preferences } = useAppPreferences(); const sourceCapsuleRef = useRef(null); const copySourceQuoteTimerRef = useRef(null); const usePreviewSheet = useMobilePreviewSheet(); @@ -584,7 +590,7 @@ export function NaturalLanguageAnswer({ }, []); const cleaned = primaryAnswerDisplayText(text, { preformatted, preserveBold: true }); if (!cleaned) return null; - const capsuleDisplay = sourceCapsuleDisplay({ sourceCount }); + const capsuleDisplay = sourceCapsuleDisplay({ sourceCount, compact: preferences.compactCitations }); const previewSources = capsulePreviewSources(bestSource, sources, sourceLinks); const quoteText = sourceLinks.find((source) => source.snippet)?.snippet || bestSource?.quote || bestSource?.snippet; const canOpenSourcePreview = previewSources.length > 0; @@ -612,7 +618,7 @@ export function NaturalLanguageAnswer({ > - {capsuleDisplay.label} + {capsuleDisplay.showLabelText ? {capsuleDisplay.label} : null} {capsuleDisplay.showCountBadge ? {sourceCount} : null} {canOpenSourcePreview ? ( entry.trim().length > 0).slice(0, 5) : []; + // Gated on the "Recent searches on home" preference so the settings toggle + // actually controls this surface (2026-07-19 audit wiring). + const { preferences } = useAppPreferences(); + const recents = + onSelectRecent && preferences.showRecentOnHome + ? recentQueries.filter((entry) => entry.trim().length > 0).slice(0, 5) + : []; return ( { + if (landingPreferenceAppliedRef.current) return; + landingPreferenceAppliedRef.current = true; + if (pathname !== "/") return; + const params = new URLSearchParams(window.location.search); + if (params.get("mode") || params.get("q")?.trim() || params.get("query")?.trim() || params.get("run") === "1") { + return; + } + const landingMode = landingModeForPreference(readAppPreferences().landing); + if (landingMode) router.replace(`/?mode=${landingMode}`, { scroll: false }); + }, [pathname, router]); const initialMode = props.initialMode ?? "answer"; const visibleShellModes = visibleAppModeDefinitions().filter( (mode) => !props.availableModeIds?.length || props.availableModeIds.includes(mode.id), diff --git a/src/components/clinical-dashboard/settings-dialog.tsx b/src/components/clinical-dashboard/settings-dialog.tsx index 744211e87..7f188f14d 100644 --- a/src/components/clinical-dashboard/settings-dialog.tsx +++ b/src/components/clinical-dashboard/settings-dialog.tsx @@ -599,13 +599,11 @@ export function SettingsDialog({ icon={PanelTop} label="Default landing view" description="The mode shown when you open the app." - notYetActive labelId="settings-landing-label" stacked > setPreference("landing", value)} options={LANDING_OPTIONS} @@ -626,7 +624,6 @@ export function SettingsDialog({ ) { + window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify({ ...DEFAULT_PREFERENCES, ...overrides })); +} + +function renderEmptyState() { + return render( + {}} + onUploadDocument={() => {}} + recentQueries={["clozapine monitoring", "lithium levels"]} + onSelectRecent={vi.fn()} + />, + ); +} + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("recent searches on home preference", () => { + it("shows recent-query chips when the preference is on (default)", () => { + storePreferences({ showRecentOnHome: true }); + renderEmptyState(); + expect(screen.getByTestId("answer-recent-queries")).toBeInTheDocument(); + expect(screen.getByText("clozapine monitoring")).toBeInTheDocument(); + }); + + it("hides recent-query chips when the preference is off", () => { + storePreferences({ showRecentOnHome: false }); + renderEmptyState(); + expect(screen.queryByTestId("answer-recent-queries")).not.toBeInTheDocument(); + expect(screen.queryByText("clozapine monitoring")).not.toBeInTheDocument(); + }); +}); + +describe("compact citations capsule display", () => { + it("keeps the Sources label when compact is off", () => { + expect(sourceCapsuleDisplay({ sourceCount: 3, compact: false })).toEqual({ + label: "Sources", + showLabelText: true, + showCountBadge: true, + }); + }); + + it("drops the label text but keeps the count when compact is on", () => { + expect(sourceCapsuleDisplay({ sourceCount: 3, compact: true })).toEqual({ + label: "Sources", + showLabelText: false, + showCountBadge: true, + }); + }); + + it("never hides the missing-source warning, even in compact mode", () => { + expect(sourceCapsuleDisplay({ sourceCount: 0, compact: true })).toEqual({ + label: "No direct source found", + showLabelText: true, + showCountBadge: false, + }); + }); +}); + +describe("default landing view mapping", () => { + it("maps search to the documents mode and browse to the tools mode", () => { + expect(landingModeForPreference("search")).toBe("documents"); + expect(landingModeForPreference("browse")).toBe("tools"); + }); + + it("returns null for ask (the built-in default needs no override)", () => { + expect(landingModeForPreference("ask")).toBeNull(); + }); +}); diff --git a/tests/settings-inert-preferences.dom.test.tsx b/tests/settings-inert-preferences.dom.test.tsx index a7da66056..6a9a103ca 100644 --- a/tests/settings-inert-preferences.dom.test.tsx +++ b/tests/settings-inert-preferences.dom.test.tsx @@ -28,19 +28,29 @@ import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog" const NOT_YET_ACTIVE_TEXT = "Saved for later — not active yet"; const INERT_ROWS = [ + // Clinical trio: wiring these into answer generation is provider-eval-gated + // (see AGENTS.md confirmation boundary) — they stay marked until that lands. "settings-row-jurisdiction", "settings-row-default-population", "settings-row-answer-style", - "settings-row-default-landing-view", - "settings-row-recent-searches-on-home", + // No saved-protocols module exists on the home surface yet. "settings-row-saved-protocols-on-home", - "settings-row-compact-citations", + // No notification delivery infrastructure exists yet. "settings-row-guideline-updates", "settings-row-product-news", "settings-row-saved-item-changes", ]; -const FUNCTIONAL_ROWS = ["settings-row-appearance", "settings-row-interface-density", "settings-row-reduce-motion"]; +const FUNCTIONAL_ROWS = [ + "settings-row-appearance", + "settings-row-interface-density", + "settings-row-reduce-motion", + // Wired 2026-07-19: landing → shell mode redirect, recents → AnswerEmptyState + // gate, compact citations → source-capsule label. + "settings-row-default-landing-view", + "settings-row-recent-searches-on-home", + "settings-row-compact-citations", +]; function renderDialog() { return render(