feat(settings): explain operator switches and keep the Coach memory reachable

Settings, AI says which AI features the server's operator switched off and
shows the one setup hint while no provider is configured. The consent
withdrawal says what it deletes and what stays, and a document-reading
consent on file gets its own withdrawal. Regenerate follows the briefing
capability and the connection test names an operator refusal.

The Coach memory (facts and stored conversations) is never gated on the
Coach: Settings, Coach always shows it, and Settings, AI shows it while
the Coach page is out of reach, whenever rows exist.

Onboarding offers AI only where it can be set up here: the done-screen
panel and the getting-started row go away while the operator has AI off
or inside somebody else's record.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:05:54 +02:00
1 parent 4449b34ac7
commit 3d85445e50
24 files changed
+913 -232

No files matched your search

+14 -3
View File
@@ -56,6 +56,7 @@ function inputs(
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
aiConfigurable: true,
dismissedIds: new Set<ChecklistItemId>(),
upcomingVisitCount: 0,
managedProfileCount: 0,
@@ -94,13 +95,22 @@ describe("buildChecklist", () => {
it("flags insights done once any provider can serve the user", () => {
const off = buildChecklist(inputs({ insightsConfigured: false }));
expect(off.find((i) => i.id === "insights")?.done).toBe(false);
// `insightsConfigured` is derived from aiAvailable, which is true for a
// personal key, a local model, an OAuth sign-in, OR the operator's
// shared key — any one flips the row done.
// `insightsConfigured` is `ai.provider.configured` from `/api/auth/me`,
// true for a personal key, a local model, an OAuth sign-in, OR the
// operator's shared key; any one flips the row done.
const on = buildChecklist(inputs({ insightsConfigured: true }));
expect(on.find((i) => i.id === "insights")?.done).toBe(true);
});
it("leaves the AI row out when AI cannot be set up here", () => {
// `provider.canConfigure` is false while the operator has AI switched
// off and inside a record somebody else owns. HealthLog is complete
// without AI, so the list never carries a to-do nobody here can finish.
const items = buildChecklist(inputs({ aiConfigurable: false }));
expect(items.map((i) => i.id)).not.toContain("insights");
expect(items).toHaveLength(5);
});
it("marks profile done when all three fields set", () => {
const items = buildChecklist(inputs());
expect(items.find((i) => i.id === "profile")?.done).toBe(true);
@@ -293,6 +303,7 @@ describe("visibleChecklist + checklistProgress", () => {
dataSourceConnected: false,
notificationsConfigured: false,
insightsConfigured: false,
aiConfigurable: true,
dismissedIds: new Set(),
upcomingVisitCount: 0,
managedProfileCount: 0,
+20 -8
View File
@@ -74,12 +74,20 @@ export interface ChecklistInputs {
/**
* True iff any AI provider can serve this user — a personal key,
* a local model, an OAuth sign-in, OR the operator's shared key.
* Derived from `/api/user/ai-provider`'s `aiAvailable`, so the row
* self-satisfies the moment insights become reachable (including on a
* `ai.provider.configured` from `GET /api/auth/me`, so the row
* self-satisfies the moment a provider is set up (including on a
* deployment that ships a shared operator key). Presence-only — it
* never decrypts a credential or probes liveness.
*/
insightsConfigured: boolean;
/**
* Whether AI can be set up from here at all (`ai.provider.canConfigure`):
* false while the operator has AI switched off and inside a record
* somebody else owns. The AI row exists only when true. HealthLog is
* complete without AI, so the list never carries a to-do nobody on this
* screen can finish.
*/
aiConfigurable: boolean;
/** Dismissed item ids (per-item localStorage state). */
dismissedIds: ReadonlySet<ChecklistItemId>;
/**
@@ -197,12 +205,16 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
href: "/settings/notifications",
dismissed: inputs.dismissedIds.has("notifications"),
},
{
id: "insights",
done: inputs.insightsConfigured,
href: "/settings/ai",
dismissed: inputs.dismissedIds.has("insights"),
},
...(inputs.aiConfigurable
? [
{
id: "insights" as const,
done: inputs.insightsConfigured,
href: "/settings/ai",
dismissed: inputs.dismissedIds.has("insights"),
},
]
: []),
];
if (!inputs.onboarding) return items;
// The visit row exists only for the answer that asked for it; every other