fix(coach): make remembering reliable and survivable

A remember action under every persisted chat message adopts its content
into the matching self-context field — allergy statements land in
allergies, diagnosis wording in conditions, everything else in the
coach focus — reusing the adopt endpoint's dedupe and encryption, with
the result visible and editable where the self-context lives. The
deterministic fact patterns accept closed filler-word and possessive
variants that previously defeated them, and stored facts survive the
snapshot budget squeeze instead of being the first block shed on
data-rich accounts.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:52:51 +02:00
1 parent 242d2514ee
commit a664395021
9 files changed
+387 -40

No files matched your search

@@ -481,6 +481,49 @@ describe("<MessageThread>", () => {
expect(userIdx).toBeLessThan(assistantIdx);
});
// v1.16.8 — the remember control under persisted user bubbles.
it("renders the remember control under a persisted user message", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Remember");
});
it("localises the remember control under 'de'", () => {
const html = render(<MessageThread conversation={baseConversation} />, "de");
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Merken");
});
it("omits the remember control on an optimistic user bubble (no id yet)", () => {
const html = render(
<MessageThread
conversation={null}
optimisticUser={{
localId: "local-1",
content: "Ich habe eine Erdnussallergie",
conversationId: null,
}}
/>,
);
expect(html).toContain('data-slot="coach-bubble-user"');
expect(html).not.toContain('data-slot="coach-remember-message"');
});
it("omits the remember control when the message exceeds the field cap", () => {
const long = {
...baseConversation,
messages: [
{
...baseConversation.messages[0],
content: "x".repeat(501),
},
],
};
const html = render(<MessageThread conversation={long} />);
expect(html).toContain('data-slot="coach-bubble-user"');
expect(html).not.toContain('data-slot="coach-remember-message"');
});
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
it("surfaces the daily-limit copy for coach.budget.exceeded", () => {
const html = render(
@@ -4,8 +4,11 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkPlus,
Bot,
Check,
ChevronRight,
Loader2,
Sparkles,
ThumbsDown,
ThumbsUp,
@@ -18,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import type {
@@ -469,15 +473,32 @@ function ChatBubble({
>
<div
className={cn(
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
"max-w-[calc(80%-2.625rem)] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
"flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
)}
>
{content}
<div
className={cn(
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
"rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{content}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
the message into the editable self-context (Settings → AI)
on one tap, so it rides every future system prompt. Only
persisted messages get the control (an optimistic bubble
has no id yet), and only when the text fits the
self-context field cap. */}
{messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && (
<RememberUserMessage content={content} />
)}
</div>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -691,6 +712,79 @@ function ChatBubble({
);
}
/**
* v1.16.8 — per-message remember control under the user bubble.
*
* One tap stores the message text into the structured self-context via
* `POST /api/coach/about-me/adopt` (no `question` — the server matches
* the target field from the text itself: an allergy statement lands on
* the allergies field, a stated condition on conditions, everything
* else on the coach-focus slot). The stored text is visible and
* editable under Settings → AI and rides every future Coach system
* prompt. Settled states mirror `SelfContextAdoptOffer`: a short
* confirmation replaces the button after an adoption or a server-side
* dedupe; a failure surfaces a toast and the button stays tappable.
*/
function RememberUserMessage({ content }: { content: string }) {
const { t } = useTranslations();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
const remember = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
});
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
if (settled) {
return (
<p
role="status"
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs"
>
<Check className="text-dracula-green size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
)}
</p>
);
}
return (
<button
type="button"
data-slot="coach-remember-message"
onClick={() => remember.mutate()}
disabled={remember.isPending}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
"outline-none focus-visible:ring-2 disabled:opacity-50",
)}
>
{remember.isPending ? (
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-3" aria-hidden="true" />
)}
{t("insights.coach.rememberMessage.action")}
</button>
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and