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) =>