"use client"; /** * v1.11.2 — Settings → AI "What the Coach remembers" panel. * * Surfaces the durable `CoachFact` rows the assistant has stored about * the user (the v1.11.1 routes already ship the data), grouped by * category, each with a relative "learned " stamp and a "forget" * control. A bulk "forget everything" action clears the lot. * * GET /api/insights/coach/facts → { data: { facts: [...] } } * DELETE /api/insights/coach/facts/{id} → { data: { deleted } } * DELETE /api/insights/coach/facts → { data: { cleared } } * * Reads unwrap `(await res.json()).data` per the envelope convention; * every read/write routes its key through `queryKeys.coachFacts()` so * a forget invalidates the list. * * v1.39 — never gated on the Coach. What the Coach stored is the person's * own record: it stays readable and deletable while the Coach is off for any * reason (the operator's switch, Hide Coach, no provider, no consent). The * facts routes are data routes and ask no AI gate. Settings → Coach always * shows this card; while that page is not reachable, Settings → AI shows it * through `` below, whenever rows exist. */ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Brain, Loader2, Trash2 } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateOrRelative } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "sonner"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; /** Closed enum mirrored from the server `CoachFact.category` column. */ const FACT_CATEGORIES = [ "preference", "condition", "goal", "constraint", "context", ] as const; type FactCategory = (typeof FACT_CATEGORIES)[number]; interface CoachFact { id: string; category: FactCategory; text: string; confidence: number; createdAt: string; } const CATEGORY_LABEL_KEY: Record = { preference: "settings.ai.coachMemory.categoryPreference", condition: "settings.ai.coachMemory.categoryCondition", goal: "settings.ai.coachMemory.categoryGoal", constraint: "settings.ai.coachMemory.categoryConstraint", context: "settings.ai.coachMemory.categoryContext", }; async function fetchFacts(): Promise { const data = await apiGet<{ facts?: CoachFact[] } | undefined>( "/api/insights/coach/facts", ); return data?.facts ?? []; } export function CoachMemorySection({ isAuthenticated, hideWhenEmpty = false, }: { isAuthenticated: boolean; /** Render nothing while there are no stored facts (the Coach-off mount). */ hideWhenEmpty?: boolean; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const query = useQuery({ queryKey: queryKeys.coachFacts(), queryFn: fetchFacts, enabled: isAuthenticated, }); const forgetOne = useMutation({ mutationKey: queryKeys.coachFacts(), mutationFn: async (id: string) => { await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`); return id; }, onSuccess: () => { toast.success(t("settings.ai.coachMemory.forgotToast")); queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); }, onError: () => { toast.error(t("settings.ai.coachMemory.forgotError")); }, }); const forgetAll = useMutation({ mutationKey: queryKeys.coachFacts(), mutationFn: async () => { const data = await apiDelete<{ cleared?: number } | undefined>( "/api/insights/coach/facts", ); return data?.cleared ?? 0; }, onSuccess: (cleared) => { toast.success( t("settings.ai.coachMemory.clearedToast", { count: cleared }), ); queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); }, onError: () => { toast.error(t("settings.ai.coachMemory.clearError")); }, }); const facts = useMemo(() => query.data ?? [], [query.data]); // Group by category in the canonical category order so the panel // reads the same way every render regardless of insertion order. const grouped = useMemo(() => { return FACT_CATEGORIES.map((category) => ({ category, items: facts.filter((f) => f.category === category), })).filter((g) => g.items.length > 0); }, [facts]); if (hideWhenEmpty && (query.isPending || query.isError)) return null; if (hideWhenEmpty && facts.length === 0) return null; return (

{t("settings.ai.coachMemory.detail")}

{query.isError && ( query.refetch()} /> )} {!query.isError && facts.length === 0 ? ( ) : (
{grouped.map((group) => (

{t(CATEGORY_LABEL_KEY[group.category])}

    {group.items.map((fact) => (
  • {fact.text}

    {t("settings.ai.coachMemory.learnedPrefix", { when: formatDateOrRelative(fact.createdAt, t), })}

    } label={t("settings.ai.coachMemory.forget")} title={t("settings.ai.coachMemory.forgetTitle")} body={t("settings.ai.coachMemory.forgetBody")} confirmLabel={t("settings.ai.coachMemory.forgetConfirm")} disabled={!isAuthenticated} // v1.11.2 — per-id pending: only the row being // deleted disables/spins. `forgetOne.variables` holds // the id passed to the in-flight `mutate()`, so a // single shared mutation no longer greys out every // other row's forget button during one delete. pending={ forgetOne.isPending && forgetOne.variables === fact.id } onConfirm={() => forgetOne.mutate(fact.id)} />
  • ))}
))}
)}

{t("settings.ai.coachMemory.summaryNote")}

{facts.length > 0 ? ( {t("settings.ai.coachMemory.forgetAllConfirmTitle")} {t("settings.ai.coachMemory.forgetAllConfirmBody")} {t("settings.ai.coachMemory.cancel")} forgetAll.mutate()} > {forgetAll.isPending && ( )} {t("settings.ai.coachMemory.forgetAllConfirmAction")} ) : null}
); } /** * Everything the Coach stored, for Settings → AI while Settings → Coach is not * reachable (the Coach switched off by the operator or hidden by the person). * Each card renders only when it has rows, so a person who never used the * Coach sees nothing here. */ export function StoredCoachMemory({ isAuthenticated, }: { isAuthenticated: boolean; }) { return ( <> ); }