From 0d41abcda4e243cb987ba43cb4daa21579e359d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:50:28 +0100 Subject: [PATCH] fix(settings): list the Coach entry only while the Coach can answer The settings nav filtered its Coach entry by the raw module map, which follows the operator's switch and Hide Coach but not a missing provider or missing consent, so it kept an entry the sidebar and the bottom bar had already dropped. It now reads useNavModules like them. Settings, AI decides from the same map whether to list the stored Coach memory, so the memory always has one place in Settings where it can be read and deleted. --- .../coach-entry-follows-capability.test.tsx | 89 +++++++++++++++++++ src/components/settings/ai-section.tsx | 12 +-- src/components/settings/settings-shell.tsx | 7 +- 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/components/settings/__tests__/coach-entry-follows-capability.test.tsx diff --git a/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx new file mode 100644 index 000000000..c30b49e8c --- /dev/null +++ b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx @@ -0,0 +1,89 @@ +/** + * Settings lists the Coach entry only while the Coach can answer, like the + * sidebar and the bottom bar, and Settings → AI then keeps the stored Coach + * memory in reach. + * + * Both used to read the raw module map, which answers the operator's switch + * and Hide Coach but not a missing provider or missing consent: the settings + * nav kept a Coach entry the sidebar had already dropped. The two now read + * the same map (`useNavModules`), so the memory's own entry and the fallback + * under Settings → AI cannot both go. + * + * The shell's module filter runs after hydration only, so `useMounted` is + * mocked to the hydrated answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const h = vi.hoisted(() => ({ coachAvailable: true })); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/settings/ai", + useRouter: () => ({ replace: () => {}, push: () => {} }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: { coach: true } }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: h.coachAvailable, + reason: h.coachAvailable ? null : "no_provider", + onDeviceAllowed: true, + }), +})); +// Settings → AI's cards have their own suites; only the memory slot is +// under test here. +vi.mock("@/components/ai/ai-consent-request", () => ({ + AiConsentRequest: () => null, +})); +vi.mock("@/components/settings/ai/ai-insights-card", () => ({ + AiInsightsCard: () => null, +})); +vi.mock("@/components/settings/ai/operator-off-notice", () => ({ + OperatorOffNotice: () => null, +})); +vi.mock("@/components/insights/ai-setup-hint", () => ({ + AiSetupHint: () => null, +})); +vi.mock("@/components/settings/coach-memory-section", () => ({ + StoredCoachMemory: () =>
, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiSection } from "../ai-section"; +import { SettingsShell } from "../settings-shell"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("the Coach entry in Settings", () => { + beforeEach(() => { + h.coachAvailable = true; + }); + + it("is listed while the Coach can answer, and the memory lives there", () => { + expect(render(body)).toContain( + 'href="/settings/coach"', + ); + expect(render()).not.toContain( + 'data-slot="stored-coach-memory"', + ); + }); + + it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => { + h.coachAvailable = false; + expect( + render(body), + ).not.toContain('href="/settings/coach"'); + expect(render()).toContain('data-slot="stored-coach-memory"'); + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 57ae0dd16..30de702d7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -25,10 +25,12 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section"; import { AiSetupHint } from "@/components/insights/ai-setup-hint"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { isSurfaceVisible } from "@/lib/modules/surface"; export function AiSection() { - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated } = useAuth(); + const navModules = useNavModules(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child // cards would then disagree with the SSR HTML (React #418). Gate the @@ -46,11 +48,9 @@ export function AiSection() { // nothing below reads as broken), and the one calm setup hint while no // provider is configured, or the consent question while the operator's // provider waits on it. Below it: what the Coach stored, while Settings → - // Coach is not reachable, so it can always be read and deleted. - const coachSettingsReachable = isSurfaceVisible( - "settings:coach", - user?.modules, - ); + // Coach is not listed (the settings nav reads the same map), so it can + // always be read and deleted. + const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules); return (
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1ddee6b15..a2bcb5185 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -47,6 +47,7 @@ import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/ui/page-header"; import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { isSurfaceVisible } from "@/lib/modules/surface"; @@ -366,8 +367,8 @@ export function SettingsShell({ const activeSlug = deriveActiveSlug(pathname, active); // v1.18.0 — per-submodule entries are listed only when their module - // is enabled. Read from the resolved `useAuth().user.modules` map (the - // same map the Module hub, nav, and Insights pills gate off). Fail OPEN + // is enabled, from `useNavModules()` as in the sidebar (Coach follows its + // AI capability; Settings → AI then lists the stored memory). Fail OPEN // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, // reads as enabled so an entry never silently disappears. A section's // owner comes from the surface map (`settings:`); sections with no @@ -390,7 +391,7 @@ export function SettingsShell({ // effect (which the lint rule flags as a cascading render), and no risk of // the two passes disagreeing. const hydrated = useMounted(); - const modules = user?.modules; + const modules = useNavModules(); const activeRecord = user?.accountAccess?.active ?? null; const allVisibleSections = SETTINGS_SECTIONS.filter( (section) =>