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
17 changes: 14 additions & 3 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
16 changes: 11 additions & 5 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof normalizeSourceMetadata> | null | undefined) {
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
const copySourceQuoteTimerRef = useRef<number | null>(null);
const usePreviewSheet = useMobilePreviewSheet();
Expand All @@ -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;
Expand Down Expand Up @@ -612,7 +618,7 @@ export function NaturalLanguageAnswer({
>
<span className={sourceCapsule}>
<Layers className="h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0 truncate">{capsuleDisplay.label}</span>
{capsuleDisplay.showLabelText ? <span className="min-w-0 truncate">{capsuleDisplay.label}</span> : null}
{capsuleDisplay.showCountBadge ? <span className={sourceCapsuleCountBadge}>{sourceCount}</span> : null}
{canOpenSourcePreview ? (
<ChevronDown
Expand Down
9 changes: 8 additions & 1 deletion src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template";
import { cn, floatingControl, sourceCard } from "@/components/ui-primitives";
import { answerEmptyState, answerLoading, copyButton } from "@/lib/ui-copy";
Expand Down Expand Up @@ -72,7 +73,13 @@ export function AnswerEmptyState({
}) {
// Returning users get their prior questions back as one-tap chips so they can
// re-run without retyping. Capped for a calm surface; storage already dedupes.
const recents = onSelectRecent ? recentQueries.filter((entry) => 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 (
<ModeHomeTemplate
Expand Down
18 changes: 18 additions & 0 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
deriveSidebarIdentity,
} from "@/components/clinical-dashboard/ClinicalSidebar";
import { GuideDialog } from "@/components/clinical-dashboard/dashboard-shell";
import { landingModeForPreference, readAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll";
import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton";
Expand Down Expand Up @@ -102,7 +103,24 @@ export function GlobalSearchShell(props: GlobalSearchShellProps) {

function GlobalSearchShellClient(props: GlobalSearchShellProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const landingPreferenceAppliedRef = useRef(false);
// Default-landing preference: on a bare "/" load (no explicit mode, query, or
// run flag) replace the URL with the saved landing mode exactly once; the
// dashboard's existing URL-sync effect then switches into it. Deep links and
// explicit mode choices always win over the preference.
useEffect(() => {
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),
Expand Down
4 changes: 0 additions & 4 deletions src/components/clinical-dashboard/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
<SegmentedControl
ariaLabelledBy="settings-landing-label"
ariaDescribedBy={notYetActiveId("settings-landing-label")}
value={preferences.landing}
onChange={(value) => setPreference("landing", value)}
options={LANDING_OPTIONS}
Expand All @@ -626,7 +624,6 @@ export function SettingsDialog({
<SettingsGroup>
<SettingsToggleField
icon={PanelTop}
notYetActive
label="Recent searches on home"
description="Surface your latest questions when you land."
checked={preferences.showRecentOnHome}
Expand All @@ -642,7 +639,6 @@ export function SettingsDialog({
/>
<SettingsToggleField
icon={BookOpen}
notYetActive
label="Compact citations"
description="Show tighter inline source references."
checked={preferences.compactCitations}
Expand Down
20 changes: 20 additions & 0 deletions src/components/clinical-dashboard/use-app-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,26 @@ export function applyPreferenceSideEffects(preferences: AppPreferences) {
}
}

/**
* Maps the saved default-landing preference onto the dashboard mode a bare "/"
* load should open in. "ask" is the built-in default (no override needed), so
* it — and any unset/invalid value — returns null.
*/
export function landingModeForPreference(landing: LandingPreference): "documents" | "tools" | null {
if (landing === "search") return "documents";
if (landing === "browse") return "tools";
return null;
}

/**
* One-shot, non-hook read of the stored preferences for callers that apply a
* preference outside React state (e.g. the landing-mode redirect on mount).
* Live consumers should use `useAppPreferences` so they re-render on change.
*/
export function readAppPreferences(): AppPreferences {
return getSnapshot();
}

export function useAppPreferences() {
const preferences = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

Expand Down
Loading
Loading