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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:50:28 +01:00
1 parent ce377ef56d
commit 0d41abcda4
3 files changed
+99 -9

No files matched your search

@@ -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: () => <div data-slot="stored-coach-memory" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiSection } from "../ai-section";
import { SettingsShell } from "../settings-shell";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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(<SettingsShell active="ai">body</SettingsShell>)).toContain(
'href="/settings/coach"',
);
expect(render(<AiSection />)).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(<SettingsShell active="ai">body</SettingsShell>),
).not.toContain('href="/settings/coach"');
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
});
});
+6 -6
View File
@@ -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 (
<div className="space-y-6">
<OperatorOffNotice />
+4 -3
View File
@@ -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:<slug>`); 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) =>