feat(coach): a guided dialog for follow-ups and nudges that know their owner

Open follow-up questions now run as a guided in-chat sequence — a quiet
card offers to start, the coach asks one question at a time with
visible progress, answers can be adopted into the matching self-context
field, skipping keeps a question pending and a closing summary says
what was taken over. Proactive nudges learn three new deterministic
signals — weight drifting from its target band, a run of short nights,
an active user falling silent — address the focus the user asked to
watch, and gain per-group toggles and a frequency preference.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 11:45:58 +02:00
1 parent f13aa684f9
commit a62e00cfcd
25 files changed
+3199 -377

No files matched your search

@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
GuidedQuestionBubble,
GuidedSummaryBubble,
} from "../guided-dialog-bubbles";
import { GuidedQuestionsCard } from "../guided-questions-card";
/**
* v1.16.5 — markup-level coverage for the guided clarifying-questions
* surfaces (entry card, question bubble, summary bubble). Behavioural
* transitions live in `guided-questions-machine.test.ts`; placement in
* the thread lives with the `placeInterleaved` tests.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const noop = () => {};
describe("<GuidedQuestionsCard>", () => {
it("renders the plural offer with the count and all three choices", () => {
const html = render(
<GuidedQuestionsCard
count={3}
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
expect(html).toContain('data-slot="coach-guided-offer"');
expect(html).toContain("The Coach has 3 follow-up questions for you");
expect(html).toContain('data-slot="coach-guided-offer-start"');
expect(html).toContain('data-slot="coach-guided-offer-later"');
expect(html).toContain('data-slot="coach-guided-offer-dismiss"');
expect(html).toContain("Answer now");
});
it("uses the singular title for a single pending question", () => {
const html = render(
<GuidedQuestionsCard
count={1}
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
expect(html).toContain("The Coach has a follow-up question for you");
expect(html).not.toContain("{count}");
});
it("disables every action while a request is in flight", () => {
const html = render(
<GuidedQuestionsCard
count={2}
disabled
onStart={noop}
onLater={noop}
onDismissAll={noop}
/>,
);
expect(html.match(/disabled=""/g)?.length).toBe(3);
});
});
describe("<GuidedQuestionBubble>", () => {
it("renders progress + question text for the current question with actions", () => {
const html = render(
<GuidedQuestionBubble
question="Do you have any chronic conditions?"
progress={{ current: 1, total: 3 }}
current
onSkip={noop}
onLater={noop}
onDismissRemaining={noop}
/>,
);
expect(html).toContain('data-slot="coach-guided-question"');
expect(html).toContain('data-state="current"');
expect(html).toContain("Question 1 of 3");
expect(html).toContain("Do you have any chronic conditions?");
expect(html).toContain('data-slot="coach-guided-skip"');
expect(html).toContain('data-slot="coach-guided-later"');
expect(html).toContain('data-slot="coach-guided-dismiss"');
});
it("renders answered questions static — no actions", () => {
const html = render(
<GuidedQuestionBubble
question="Do you have any chronic conditions?"
progress={{ current: 1, total: 3 }}
/>,
);
expect(html).toContain('data-state="answered"');
expect(html).not.toContain('data-slot="coach-guided-actions"');
});
it("localises the progress label", () => {
const html = render(
<GuidedQuestionBubble
question="Hast du chronische Erkrankungen?"
progress={{ current: 2, total: 3 }}
/>,
"de",
);
expect(html).toContain("Frage 2 von 3");
});
});
describe("<GuidedSummaryBubble>", () => {
it("recaps answered + adopted counts and links to the self-context", () => {
const html = render(
<GuidedSummaryBubble answered={2} adopted={1} total={3} />,
);
expect(html).toContain('data-slot="coach-guided-summary"');
expect(html).toContain("2 of 3 answered");
expect(html).toContain("1 added to your self-context");
expect(html).toContain('href="/settings/ai"');
});
it("says so when nothing was adopted", () => {
const html = render(
<GuidedSummaryBubble answered={1} adopted={0} total={3} />,
);
expect(html).toContain("nothing new added to your self-context");
});
});
@@ -0,0 +1,274 @@
import { describe, expect, it } from "vitest";
import {
countAdopted,
deriveThreadItems,
GUIDED_IDLE,
guidedReducer,
type GuidedEvent,
type GuidedState,
} from "../guided-questions-machine";
/**
* v1.16.5 — guided clarifying-questions state machine.
*
* Pure-reducer coverage for the in-chat guided flow: start / later /
* answer / skip / exit / dismiss transitions, adoption settling, and
* the thread-item derivation the conversation surface renders from.
*/
const QUESTIONS = [
"Do you have any chronic conditions?",
"What should the Coach focus on?",
"Any allergies worth noting?",
];
function run(events: GuidedEvent[], from: GuidedState = GUIDED_IDLE) {
return events.reduce(guidedReducer, from);
}
describe("guidedReducer — entry", () => {
it("START snapshots the questions and asks the first", () => {
const state = run([{ type: "START", questions: QUESTIONS }]);
expect(state).toEqual({
phase: "asking",
questions: QUESTIONS,
index: 0,
outcomes: [],
});
});
it("START with no questions is a no-op", () => {
expect(run([{ type: "START", questions: [] }])).toEqual(GUIDED_IDLE);
});
it("START mid-flow is ignored (no sequence restart)", () => {
const asking = run([{ type: "START", questions: QUESTIONS }]);
expect(guidedReducer(asking, { type: "START", questions: ["x"] })).toBe(
asking,
);
});
it("LATER from idle hides the offer for the session", () => {
expect(run([{ type: "LATER" }])).toEqual({ phase: "done" });
});
it("LATER mid-flow is ignored — the bubble actions own the exits", () => {
const asking = run([{ type: "START", questions: QUESTIONS }]);
expect(guidedReducer(asking, { type: "LATER" })).toBe(asking);
});
});
describe("guidedReducer — answering", () => {
it("ANSWER_SUBMITTED records the outcome and waits for the turn", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
]);
expect(state.phase).toBe("answered");
if (state.phase !== "answered") throw new Error("unreachable");
expect(state.outcomes).toEqual([
{
index: 0,
question: QUESTIONS[0],
answer: "Hypertension.",
adoption: "pending",
},
]);
});
it("TURN_COMPLETE advances to the next question", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
]);
expect(state).toMatchObject({ phase: "asking", index: 1 });
});
it("TURN_COMPLETE after the last question lands on the summary", () => {
const state = run([
{ type: "START", questions: ["only one"] },
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
{ type: "TURN_COMPLETE" },
]);
expect(state.phase).toBe("summary");
if (state.phase !== "summary") throw new Error("unreachable");
expect(state.outcomes).toHaveLength(1);
});
it("composer submits outside the asking phase change nothing", () => {
const summary = run([
{ type: "START", questions: ["only one"] },
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
{ type: "TURN_COMPLETE" },
]);
expect(
guidedReducer(summary, { type: "ANSWER_SUBMITTED", answer: "more" }),
).toBe(summary);
});
});
describe("guidedReducer — skip / exit / reset", () => {
it("SKIP advances without an outcome (question stays pending)", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "SKIP" },
]);
expect(state).toMatchObject({ phase: "asking", index: 1, outcomes: [] });
});
it("skipping every question ends without a summary", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "SKIP" },
{ type: "SKIP" },
{ type: "SKIP" },
]);
expect(state).toEqual({ phase: "done" });
});
it("SKIP on the last question shows the summary when answers exist", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
{ type: "SKIP" },
{ type: "SKIP" },
]);
expect(state.phase).toBe("summary");
});
it("EXIT with answers lands on the summary; without answers it just ends", () => {
const withAnswer = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
{ type: "EXIT" },
]);
expect(withAnswer.phase).toBe("summary");
const withoutAnswer = run([
{ type: "START", questions: QUESTIONS },
{ type: "EXIT" },
]);
expect(withoutAnswer).toEqual({ phase: "done" });
});
it("RESET drops any session back to idle", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "RESET" },
]);
expect(state).toEqual(GUIDED_IDLE);
});
});
describe("guidedReducer — adoption settling", () => {
const answeredOne: GuidedEvent[] = [
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
];
it("settles the matching pending outcome", () => {
const state = run([
...answeredOne,
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.outcomes[0].adoption).toBe("adopted");
});
it("never resettles an already-settled outcome", () => {
const state = run([
...answeredOne,
{ type: "ADOPTION_SETTLED", index: 0, adoption: "declined" },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "asking") throw new Error("unreachable");
expect(state.outcomes[0].adoption).toBe("declined");
});
it("still settles outcomes while the summary is showing", () => {
const state = run([
{ type: "START", questions: ["only one"] },
{ type: "ANSWER_SUBMITTED", answer: "An answer." },
{ type: "TURN_COMPLETE" },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
]);
if (state.phase !== "summary") throw new Error("unreachable");
expect(countAdopted(state.outcomes)).toBe(1);
});
});
describe("deriveThreadItems", () => {
it("idle and done contribute nothing", () => {
expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]);
expect(deriveThreadItems({ phase: "done" })).toEqual([]);
});
it("asking renders answered questions anchored + the current at the tail", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
]);
const items = deriveThreadItems(state);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
kind: "question",
question: QUESTIONS[0],
progress: { current: 1, total: 3 },
anchorAnswer: "Hypertension.",
});
expect(items[1]).toMatchObject({
kind: "question",
question: QUESTIONS[1],
progress: { current: 2, total: 3 },
anchorAnswer: null,
current: true,
});
});
it("a skipped question keeps the original numbering on the next one", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "SKIP" },
]);
const items = deriveThreadItems(state);
expect(items).toHaveLength(1);
expect(items[0].progress).toEqual({ current: 2, total: 3 });
});
it("answered phase has no current item (the turn is streaming)", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
]);
const items = deriveThreadItems(state);
expect(items).toHaveLength(1);
expect(items[0].anchorAnswer).toBe("Hypertension.");
});
it("summary carries answered / adopted / total counts", () => {
const state = run([
{ type: "START", questions: QUESTIONS },
{ type: "ANSWER_SUBMITTED", answer: "Hypertension." },
{ type: "TURN_COMPLETE" },
{ type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" },
{ type: "ANSWER_SUBMITTED", answer: "Sleep." },
{ type: "TURN_COMPLETE" },
{ type: "ADOPTION_SETTLED", index: 1, adoption: "declined" },
{ type: "SKIP" },
]);
const items = deriveThreadItems(state);
const summary = items.at(-1);
expect(summary).toMatchObject({
kind: "summary",
anchorAnswer: null,
summary: { answered: 2, adopted: 1, total: 3 },
});
});
});
@@ -52,7 +52,11 @@ vi.mock("@tanstack/react-query", () => ({
}),
}));
import { MessageThread } from "../message-thread";
import {
MessageThread,
placeInterleaved,
type InterleavedThreadItem,
} from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -599,3 +603,126 @@ describe("errorCodeToI18nKey", () => {
);
});
});
/**
* v1.16.5 — placement of guided-flow bubbles between the persisted
* messages, the optimistic user bubble, and the thread tail.
*/
describe("placeInterleaved", () => {
const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({
key,
anchorAnswer,
node: <span data-testid={key} />,
});
const user = (id: string, content: string) => ({
id,
role: "user",
content,
});
const assistant = (id: string, content: string) => ({
id,
role: "assistant",
content,
});
it("anchors an answered question before its persisted user message", () => {
const placed = placeInterleaved(
[item("q1", "My answer"), item("q2", null)],
[
user("m1", "Earlier turn"),
assistant("m2", "Earlier reply"),
user("m3", "My answer"),
assistant("m4", "Reply"),
],
null,
);
expect(placed.before.get("m3")?.key).toBe("q1");
expect(placed.beforeOptimistic).toEqual([]);
expect(placed.tail.map((i) => i.key)).toEqual(["q2"]);
});
it("anchors before the optimistic bubble while the twin is in flight", () => {
const placed = placeInterleaved(
[item("q1", "My answer")],
[user("m1", "Earlier turn")],
"My answer",
);
expect(placed.before.size).toBe(0);
expect(placed.beforeOptimistic.map((i) => i.key)).toEqual(["q1"]);
expect(placed.tail).toEqual([]);
});
it("matches assistant-content collisions never (user messages only)", () => {
const placed = placeInterleaved(
[item("q1", "My answer")],
[assistant("m1", "My answer")],
null,
);
expect(placed.before.size).toBe(0);
expect(placed.tail.map((i) => i.key)).toEqual(["q1"]);
});
it("consumes duplicate answers in order", () => {
const placed = placeInterleaved(
[item("q1", "Yes"), item("q2", "Yes")],
[user("m1", "Yes"), assistant("m2", "r"), user("m3", "Yes")],
null,
);
expect(placed.before.get("m1")?.key).toBe("q1");
expect(placed.before.get("m3")?.key).toBe("q2");
});
it("falls back to the tail when an anchor never matches", () => {
const placed = placeInterleaved(
[item("q1", "Lost answer"), item("summary", null)],
[user("m1", "Something else")],
null,
);
expect(placed.before.size).toBe(0);
expect(placed.tail.map((i) => i.key)).toEqual(["q1", "summary"]);
});
});
describe("<MessageThread> interleaved guided bubbles", () => {
it("renders an unanchored item at the tail even on an empty thread", () => {
const html = render(
<MessageThread
conversation={null}
interleaved={[
{
key: "q1",
anchorAnswer: null,
node: <div data-slot="guided-test-bubble">First question?</div>,
},
]}
/>,
);
// The guided bubble suppresses the empty-state hero…
expect(html).not.toContain(
"Ask anything about your trends, medications, or readings",
);
// …and renders inside the scroller.
expect(html).toContain('data-slot="guided-test-bubble"');
expect(html).toContain("First question?");
});
it("renders an anchored item before the user message that answered it", () => {
const html = render(
<MessageThread
conversation={baseConversation}
interleaved={[
{
key: "q1",
anchorAnswer: "Why was BP higher on Monday?",
node: <div data-slot="guided-test-bubble">Guided question?</div>,
},
]}
/>,
);
const guidedAt = html.indexOf('data-slot="guided-test-bubble"');
const userAt = html.indexOf('data-slot="coach-bubble-user"');
expect(guidedAt).toBeGreaterThan(-1);
expect(userAt).toBeGreaterThan(-1);
expect(guidedAt).toBeLessThan(userAt);
});
});
@@ -1,20 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
GuidedSummaryBubble,
} from "./guided-dialog-bubbles";
import { GuidedQuestionsCard } from "./guided-questions-card";
import {
deriveThreadItems,
GUIDED_IDLE,
guidedReducer,
} from "./guided-questions-machine";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MessageThread, type InterleavedThreadItem } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SelfContextChips } from "./self-context-chips";
import { SourcesRail } from "./sources-rail";
import { useResettableValue } from "./use-resettable-value";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
@@ -114,18 +127,46 @@ export function CoachConversation({
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow. `activeChipQuestion` remembers the
// clarifying-question chip the user tapped (the chip pre-fills the
// composer); when the next message answers it, `pendingAdopt` raises a
// quiet offer to fold the answer back into the Selbstauskunft.
const [activeChipQuestion, setActiveChipQuestion] = useState<string | null>(
null,
);
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
// Selbstauskunft. v1.16.5 — the answers now come from the guided
// flow; `guidedIndex` routes the offer's outcome back into the
// machine so the closing summary can say what was adopted.
const [pendingAdopt, setPendingAdopt] = useState<{
question: string;
answer: string;
guidedIndex: number;
} | null>(null);
// v1.16.5 — guided clarifying-questions flow (V2 of the v1.16.0
// chips). The machine is pure (`guided-questions-machine.ts`); this
// component owns the server round-trips: the pending-questions query
// feeding the entry card, the per-question dismiss on answer, and
// the dismiss-all behind "don't ask again".
const [guided, dispatchGuided] = useReducer(guidedReducer, GUIDED_IDLE);
const queryClient = useQueryClient();
const { data: questionsData } = useQuery({
queryKey: queryKeys.coachAboutMeQuestions(),
queryFn: async () =>
apiGet<{ questions: string[] }>("/api/coach/about-me/questions"),
staleTime: 60_000,
});
const pendingQuestions = questionsData?.questions ?? [];
const dismissQuestions = useMutation({
mutationKey: queryKeys.coachAboutMeQuestions(),
mutationFn: async (question?: string) =>
apiDelete<{ questions: string[] }>(
"/api/coach/about-me/questions",
question === undefined ? {} : { question },
),
onSuccess: (next) => {
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
},
onError: () => {
toast.error(t("insights.coach.guided.dismissError"));
},
});
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
@@ -143,44 +184,96 @@ export function CoachConversation({
send.cancel();
setCurrentConversationId(null);
setInputValue("");
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
});
}, [registerReset, send, setInputValue]);
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
// v1.16.4 — when this message answers a tapped clarifying-question
// chip, peel the inserted question off the front so only the user's
// own words form the adoptable answer. An empty remainder (the user
// deleted their answer or sent the bare question) raises no offer.
const chipQuestion = activeChipQuestion;
setActiveChipQuestion(null);
const answer = chipQuestion
? trimmed.startsWith(chipQuestion)
? trimmed.slice(chipQuestion.length).trim()
: trimmed
: null;
// v1.16.5 — in the guided flow the composer message IS the answer
// to the current question. Mark it answered before the send so the
// question bubble anchors above the user's message, and dismiss the
// question server-side — answered questions never return.
const guidedQuestion =
guided.phase === "asking" ? guided.questions[guided.index] : null;
const guidedIndex = guided.phase === "asking" ? guided.index : null;
if (guidedQuestion !== null) {
dispatchGuided({ type: "ANSWER_SUBMITTED", answer: trimmed });
dismissQuestions.mutate(guidedQuestion);
}
setInputValue("");
await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
});
if (chipQuestion && answer) {
setPendingAdopt({ question: chipQuestion, answer });
if (guidedQuestion !== null && guidedIndex !== null) {
setPendingAdopt({
question: guidedQuestion,
answer: trimmed,
guidedIndex,
});
// The assistant turn is done — advance to the next question (or
// the closing summary).
dispatchGuided({ type: "TURN_COMPLETE" });
}
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
setActiveChipQuestion(null);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
send.reset();
}
const title = conversation?.title ?? t("insights.coach.newChat");
const tagline = t("insights.coach.tagline");
// v1.16.5 — materialise the machine's thread items into bubbles. The
// thread only places them (`placeInterleaved`); every behaviour stays
// here with the flow.
const interleaved: InterleavedThreadItem[] = deriveThreadItems(guided).map(
(item) => ({
key: item.key,
anchorAnswer: item.anchorAnswer,
node:
item.kind === "summary" ? (
<GuidedSummaryBubble
answered={item.summary?.answered ?? 0}
adopted={item.summary?.adopted ?? 0}
total={item.summary?.total ?? 0}
/>
) : (
<GuidedQuestionBubble
question={item.question ?? ""}
progress={item.progress ?? { current: 1, total: 1 }}
current={item.current}
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
onSkip={
item.current
? () => dispatchGuided({ type: "SKIP" })
: undefined
}
onLater={
item.current
? () => dispatchGuided({ type: "EXIT" })
: undefined
}
onDismissRemaining={
item.current
? () =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "EXIT" }),
})
: undefined
}
/>
),
}),
);
return (
<div
data-slot="coach-conversation"
@@ -250,7 +343,13 @@ export function CoachConversation({
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
) : undefined
}
@@ -259,30 +358,47 @@ export function CoachConversation({
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* v1.16.0 — pending self-context questions as tappable
chips. Tapping inserts the question into the composer
(the user answers it in their own words) and dismisses
the chip. Renders nothing when no questions pend. */}
{/* v1.16.4 — quiet adopt-into-self-context offer once a chip
question has been answered. Self-removes after settle. */}
{/* v1.16.4 — quiet adopt-into-self-context offer once a
clarifying question has been answered. Self-removes
after settle; v1.16.5 reports the outcome back into the
guided machine for the closing summary. */}
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
the v1.16.0 chips). Offers the in-chat sequence while
questions pend and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({ type: "START", questions: pendingQuestions })
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
<SelfContextChips
disabled={send.isStreaming}
onPick={(question) => {
setActiveChipQuestion(question);
setInputValue(`${question}\n`);
}}
/>
<CoachInput
value={inputValue}
onChange={setInputValue}
@@ -291,6 +407,11 @@ export function CoachConversation({
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
placeholder={
guided.phase === "asking"
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
/>
</div>
}
@@ -311,6 +432,8 @@ export function CoachConversation({
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
}
@@ -59,6 +59,12 @@ export interface CoachInputProps {
* already-open drawer we do not steal focus back.
*/
autoFocusOnOpen?: boolean;
/**
* v1.16.5 — placeholder override. The guided clarifying-questions
* flow swaps the generic "ask anything" prompt for an answer cue
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
}
/**
@@ -105,6 +111,7 @@ export function CoachInput({
isStreaming = false,
inputId = "coach-composer-textarea",
autoFocusOnOpen = false,
placeholder,
}: CoachInputProps) {
const { t } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -202,7 +209,7 @@ export function CoachInput({
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("insights.coach.composerPlaceholder")}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
disabled={disabled}
// v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard
// return on iOS / Android instead of the generic newline
@@ -0,0 +1,241 @@
"use client";
/**
* v1.16.5 — in-thread bubbles for the guided clarifying-questions flow
* (see `guided-questions-machine.ts` for the state machine).
*
* Two bubbles, both deterministic (no model call) and both styled as a
* close sibling of the assistant bubble so the sequence reads as the
* Coach speaking — same avatar position and bubble geometry, but a
* purple-tinted surface and a question-mark avatar so a guided turn is
* visually distinct from AI prose:
*
* - `GuidedQuestionBubble` — one clarifying question with a
* "question 1 of 3" progress line + dot strip. The CURRENT question
* briefly shows the shared typing-dots indicator before the text
* reveals (the classic scripted-bot rhythm) and carries the quiet
* skip / later / don't-ask-again actions beneath the bubble.
* Answered questions re-render static (no actions, no reveal),
* anchored above the answer that resolved them.
* - `GuidedSummaryBubble` — the closing recap: how many questions
* were answered, how many answers were adopted into the
* self-context, and a deep-link to Settings → AI to review it.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import {
ArrowRight,
ClipboardCheck,
MessageCircleQuestion,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { TypingDots } from "./message-thread";
/** Shared quiet text-button styling for the per-question actions. */
const guidedActionClass = cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
);
function GuidedAvatar({ icon: Icon }: { icon: typeof ClipboardCheck }) {
return (
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Icon className="text-background size-3.5" />
</div>
);
}
/**
* Dot strip mirroring the textual progress label: filled = asked
* (everything before the current question, answered or skipped),
* outlined = the current one, muted = still ahead. Decorative — the
* "question 1 of 3" text is the accessible progress signal.
*/
function ProgressDots({ current, total }: { current: number; total: number }) {
return (
<span aria-hidden="true" className="inline-flex items-center gap-1">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
))}
</span>
);
}
export interface GuidedQuestionBubbleProps {
question: string;
progress: { current: number; total: number };
/** Live question: actions render and the typing reveal plays. */
current?: boolean;
onSkip?: () => void;
onLater?: () => void;
onDismissRemaining?: () => void;
/** Disables the actions (in-flight dismiss request). */
actionsDisabled?: boolean;
}
export function GuidedQuestionBubble({
question,
progress,
current = false,
onSkip,
onLater,
onDismissRemaining,
actionsDisabled = false,
}: GuidedQuestionBubbleProps) {
const { t } = useTranslations();
// Scripted-bot rhythm: the current question holds the shared
// typing-dots indicator for a beat before the text reveals. SSR and
// reduced-motion users get the text immediately; answered bubbles
// never replay the reveal.
const [revealed, setRevealed] = useState<boolean>(() => {
if (!current) return true;
if (typeof window === "undefined") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
useEffect(() => {
if (revealed) return;
const handle = window.setTimeout(() => setRevealed(true), 650);
return () => window.clearTimeout(handle);
}, [revealed]);
return (
<div
data-slot="coach-guided-question"
data-state={current ? "current" : "answered"}
className="flex items-start gap-2.5"
>
<GuidedAvatar icon={MessageCircleQuestion} />
<div className="flex max-w-[calc(80%-2.625rem)] min-w-0 flex-col gap-1.5">
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{revealed ? (
<>
<p
data-slot="coach-guided-progress"
className="text-dracula-purple mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
>
{t("insights.coach.guided.progress", {
current: progress.current,
total: progress.total,
})}
<ProgressDots
current={progress.current}
total={progress.total}
/>
</p>
<p className="whitespace-pre-wrap">{question}</p>
</>
) : (
<TypingDots label={t("insights.coach.thinking")} />
)}
</div>
{current && revealed && (
<div
data-slot="coach-guided-actions"
className="flex flex-wrap items-center gap-1"
>
<button
type="button"
data-slot="coach-guided-skip"
disabled={actionsDisabled}
onClick={onSkip}
className={guidedActionClass}
>
{t("insights.coach.guided.skip")}
</button>
<button
type="button"
data-slot="coach-guided-later"
disabled={actionsDisabled}
onClick={onLater}
className={guidedActionClass}
>
{t("insights.coach.guided.later")}
</button>
<button
type="button"
data-slot="coach-guided-dismiss"
disabled={actionsDisabled}
onClick={onDismissRemaining}
className={guidedActionClass}
>
{t("insights.coach.guided.dismissRemaining")}
</button>
</div>
)}
</div>
</div>
);
}
export interface GuidedSummaryBubbleProps {
answered: number;
adopted: number;
total: number;
}
export function GuidedSummaryBubble({
answered,
adopted,
total,
}: GuidedSummaryBubbleProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-summary"
className="flex items-start gap-2.5"
>
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"max-w-[calc(80%-2.625rem)] min-w-0 rounded-xl rounded-tl-sm border px-3.5 py-2.5",
)}
>
<p className="text-sm font-medium">
{t("insights.coach.guided.summaryTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("insights.coach.guided.summaryAnswered", { answered, total })}
{" · "}
{adopted > 0
? t("insights.coach.guided.summaryAdopted", { adopted })
: t("insights.coach.guided.summaryAdoptedNone")}
</p>
<Link
href="/settings/ai"
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"hover:underline focus-visible:ring-dracula-purple/40 rounded focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
<ArrowRight className="size-3" aria-hidden="true" />
</Link>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
"use client";
/**
* v1.16.5 — entry card for the guided clarifying-questions flow.
*
* Replaces the v1.16.0 loose composer chips: when pending self-context
* questions exist, this quiet card sits above the composer and offers
* the guided sequence instead of scattering tappable chips. Three
* choices, mirroring common scripted-onboarding chat patterns:
*
* - "Answer now" starts the in-chat sequence (machine START).
* - "Later" hides the offer for this session; the questions stay
* pending and the card returns on the next mount.
* - "Don't ask again" dismisses every pending question server-side
* (existing DELETE with empty body).
*
* Renders nothing when no questions pend — zero vertical cost.
*/
import { MessageCircleQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
export interface GuidedQuestionsCardProps {
/** Pending-question count (1–3); the parent hides the card at 0. */
count: number;
onStart: () => void;
onLater: () => void;
onDismissAll: () => void;
/** Disabled while a reply streams or the dismiss request is in flight. */
disabled?: boolean;
}
export function GuidedQuestionsCard({
count,
onStart,
onLater,
onDismissAll,
disabled = false,
}: GuidedQuestionsCardProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-guided-offer"
className={cn(
"border-dracula-purple/30 from-dracula-purple/10 to-dracula-pink/5",
"mb-2 rounded-lg border bg-gradient-to-r px-3 py-2.5",
)}
>
<div className="flex items-start gap-2.5">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<MessageCircleQuestion className="text-background size-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{count === 1
? t("insights.coach.guided.offerTitleOne")
: t("insights.coach.guided.offerTitle", { count })}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{t("insights.coach.guided.offerBody")}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<button
type="button"
data-slot="coach-guided-offer-start"
disabled={disabled}
onClick={onStart}
className={cn(
"bg-dracula-purple/15 text-dracula-purple inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-dracula-purple/25 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{t("insights.coach.guided.offerStart")}
</button>
<button
type="button"
data-slot="coach-guided-offer-later"
disabled={disabled}
onClick={onLater}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{t("insights.coach.guided.offerLater")}
</button>
<button
type="button"
data-slot="coach-guided-offer-dismiss"
disabled={disabled}
onClick={onDismissAll}
className={cn(
"text-muted-foreground/70 hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{t("insights.coach.guided.offerDismiss")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
/**
* v1.16.5 — guided clarifying-questions flow, the V2 of the v1.16.0
* composer chips.
*
* After the user saves Settings → AI → "About me", up to 3 pending
* clarifying questions live encrypted on the profile
* (`/api/coach/about-me/questions`). V1 surfaced them as loose chips
* the user had to pick apart; V2 walks them one at a time inside the
* chat: an entry card offers the sequence, the Coach asks each
* question as a deterministic bubble (no model call), the user answers
* in the normal composer, the existing `/adopt` offer folds the answer
* into the matching self-context field, and a closing summary says
* what was taken over.
*
* This module is the whole client-side state machine, kept pure so the
* transitions can be unit-tested without a DOM:
*
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
* │ │ ▲ │
* LATER SKIP└──────TURN_COMPLETE────────┘
* │ │ (next question, or…)
* ▼ ├──EXIT/SKIP-on-last──▶ summary (answers exist)
* done ◀────────────┴──────────────────────▶ done (none answered)
*
* Server contract stays additive-free: answering dismisses that one
* question (existing DELETE), "skip" / "later" leave questions pending
* for the next session, "don't ask again" dismisses all (existing
* DELETE with empty body). No new schema, no new endpoint.
*/
/** Lifecycle of one answered question's adopt offer. */
export type GuidedAdoption =
| "pending"
| "adopted"
| "duplicate"
| "declined"
| "failed";
export interface GuidedOutcome {
/** Index of the question in the session snapshot (progress label). */
index: number;
question: string;
/** The composer message that answered it (thread anchor). */
answer: string;
adoption: GuidedAdoption;
}
export type GuidedState =
| { phase: "idle" }
| { phase: "done" }
| {
/**
* `asking` — the current question bubble is live and the next
* composer submit answers it. `answered` — the answer was sent
* and the assistant turn is still streaming; no current bubble.
*/
phase: "asking" | "answered";
/** Snapshot taken at START; pending-question edits mid-flow don't reshuffle the sequence. */
questions: string[];
index: number;
outcomes: GuidedOutcome[];
}
| { phase: "summary"; questions: string[]; outcomes: GuidedOutcome[] };
export const GUIDED_IDLE: GuidedState = { phase: "idle" };
export type GuidedEvent =
| { type: "START"; questions: string[] }
/** Entry-card "later": hide the offer for this session, keep questions pending. */
| { type: "LATER" }
/** Composer submit while `asking` — the message answers the current question. */
| { type: "ANSWER_SUBMITTED"; answer: string }
/** The assistant turn for the answer finished; advance the sequence. */
| { type: "TURN_COMPLETE" }
/** Skip the current question (stays pending server-side). */
| { type: "SKIP" }
/** Leave the flow ("later" / "don't ask again" mid-sequence). */
| { type: "EXIT" }
/** The adopt offer for an answered question settled. */
| {
type: "ADOPTION_SETTLED";
index: number;
adoption: Exclude<GuidedAdoption, "pending">;
}
/** New chat / conversation switch / drawer close — drop the session. */
| { type: "RESET" };
/**
* Close the sequence: answered questions earn the summary bubble;
* a flow abandoned before any answer just ends (the entry card will
* re-offer the still-pending questions next session).
*/
function finish(state: {
questions: string[];
outcomes: GuidedOutcome[];
}): GuidedState {
return state.outcomes.length > 0
? { phase: "summary", questions: state.questions, outcomes: state.outcomes }
: { phase: "done" };
}
export function guidedReducer(
state: GuidedState,
event: GuidedEvent,
): GuidedState {
switch (event.type) {
case "RESET":
return GUIDED_IDLE;
case "START": {
if (state.phase !== "idle" && state.phase !== "done") return state;
if (event.questions.length === 0) return state;
return {
phase: "asking",
questions: [...event.questions],
index: 0,
outcomes: [],
};
}
case "LATER":
return state.phase === "idle" ? { phase: "done" } : state;
case "ANSWER_SUBMITTED": {
if (state.phase !== "asking") return state;
return {
...state,
phase: "answered",
outcomes: [
...state.outcomes,
{
index: state.index,
question: state.questions[state.index],
answer: event.answer,
adoption: "pending",
},
],
};
}
case "TURN_COMPLETE": {
if (state.phase !== "answered") return state;
const next = state.index + 1;
if (next < state.questions.length) {
return { ...state, phase: "asking", index: next };
}
return finish(state);
}
case "SKIP": {
if (state.phase !== "asking") return state;
const next = state.index + 1;
if (next < state.questions.length) {
return { ...state, index: next };
}
return finish(state);
}
case "EXIT": {
if (state.phase !== "asking") return state;
return finish(state);
}
case "ADOPTION_SETTLED": {
if (
state.phase !== "asking" &&
state.phase !== "answered" &&
state.phase !== "summary"
) {
return state;
}
const outcomes = state.outcomes.map((o) =>
o.index === event.index && o.adoption === "pending"
? { ...o, adoption: event.adoption }
: o,
);
return { ...state, outcomes };
}
}
}
/** Count of answers actually written into the self-context. */
export function countAdopted(outcomes: GuidedOutcome[]): number {
return outcomes.filter((o) => o.adoption === "adopted").length;
}
/**
* One in-thread bubble the guided flow contributes. Answered questions
* anchor immediately BEFORE the user message that answered them
* (`anchorAnswer` = that message's content) so the transcript reads
* question → answer → reply; the current question and the summary
* carry `anchorAnswer: null` and render at the thread tail.
*/
export interface GuidedThreadItem {
key: string;
kind: "question" | "summary";
/** kind=question */
question?: string;
progress?: { current: number; total: number };
/** kind=question: live question — render actions + typing reveal. */
current?: boolean;
/** kind=summary */
summary?: { answered: number; adopted: number; total: number };
anchorAnswer: string | null;
}
export function deriveThreadItems(state: GuidedState): GuidedThreadItem[] {
if (state.phase === "idle" || state.phase === "done") return [];
const total = state.questions.length;
const items: GuidedThreadItem[] = state.outcomes.map((o) => ({
key: `guided-q-${o.index}`,
kind: "question",
question: o.question,
progress: { current: o.index + 1, total },
anchorAnswer: o.answer,
}));
if (state.phase === "asking") {
items.push({
key: `guided-q-${state.index}`,
kind: "question",
question: state.questions[state.index],
progress: { current: state.index + 1, total },
current: true,
anchorAnswer: null,
});
}
if (state.phase === "summary") {
items.push({
key: "guided-summary",
kind: "summary",
summary: {
answered: state.outcomes.length,
adopted: countAdopted(state.outcomes),
total,
},
anchorAnswer: null,
});
}
return items;
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -59,6 +59,74 @@ export interface MessageThreadProps {
optimisticUser?: CoachOptimisticUserMessage | null;
/** Empty-state copy when no conversation is loaded yet. */
emptyHint?: string;
/**
* v1.16.5 — locally-rendered bubbles the guided clarifying-questions
* flow interleaves with the persisted history (deterministic Coach
* questions + the closing summary). Items anchored on an answer
* render immediately before the user message that answered them;
* unanchored items render at the thread tail. See `placeInterleaved`.
*/
interleaved?: InterleavedThreadItem[];
}
/**
* v1.16.5 — one locally-rendered thread bubble contributed by the
* guided clarifying-questions flow. The thread owns only the placement;
* the node's behaviour lives with the flow in `coach-conversation`.
*/
export interface InterleavedThreadItem {
key: string;
/**
* Content of the user message this item precedes (a guided question
* renders above its answer). `null` → render at the thread tail
* (the current question / the summary).
*/
anchorAnswer: string | null;
node: React.ReactNode;
}
/**
* Pure placement for interleaved items, exported for unit tests.
* Items are chronological by construction (the guided flow emits them
* in question order), so a single forward pointer suffices: each
* anchored item consumes the first remaining user message whose
* content equals its anchor. Anchors that never match (e.g. an errored
* turn whose message was never persisted) fall through to the tail so
* no bubble is ever silently dropped.
*/
export function placeInterleaved(
items: InterleavedThreadItem[],
messages: { id: string; role: string; content: string }[],
optimisticContent: string | null,
): {
before: Map<string, InterleavedThreadItem>;
beforeOptimistic: InterleavedThreadItem[];
tail: InterleavedThreadItem[];
} {
const anchored = items.filter((i) => i.anchorAnswer !== null);
const before = new Map<string, InterleavedThreadItem>();
let p = 0;
for (const m of messages) {
if (p >= anchored.length) break;
if (m.role === "user" && m.content === anchored[p].anchorAnswer) {
before.set(m.id, anchored[p]);
p += 1;
}
}
const beforeOptimistic: InterleavedThreadItem[] = [];
if (
p < anchored.length &&
optimisticContent !== null &&
optimisticContent === anchored[p].anchorAnswer
) {
beforeOptimistic.push(anchored[p]);
p += 1;
}
const tail = [
...anchored.slice(p),
...items.filter((i) => i.anchorAnswer === null),
];
return { before, beforeOptimistic, tail };
}
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
@@ -112,6 +180,7 @@ export function MessageThread({
streaming,
optimisticUser,
emptyHint,
interleaved,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -129,6 +198,8 @@ export function MessageThread({
() => conversation?.messages ?? [],
[conversation?.messages],
);
// v1.16.5 — locally-rendered guided bubbles (see `placeInterleaved`).
const interleavedItems = interleaved ?? [];
// v1.4.20.1 — once the SSE stream emits `done`, the route's
// invalidate-then-refetch pulls the persisted assistant message into
// `conversation.messages`. The streaming bubble was still rendering
@@ -187,6 +258,15 @@ export function MessageThread({
return true;
})();
// v1.16.5 — slot the guided bubbles around the persisted history,
// the optimistic user bubble, and the streaming tail. Cheap on every
// render: at most a handful of items over a single message walk.
const placement = placeInterleaved(
interleavedItems,
messages,
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -203,6 +283,7 @@ export function MessageThread({
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
// localId so the user sees their own bubble land at the bottom.
// v1.16.5 — guided bubbles count as new messages for the auto-scroll.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
@@ -210,7 +291,12 @@ export function MessageThread({
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}
}, [messages.length, streaming?.content, optimisticUser?.localId]);
}, [
messages.length,
streaming?.content,
optimisticUser?.localId,
interleavedItems.length,
]);
// v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the
// visual viewport shrinks (typically the soft keyboard opening on
@@ -234,7 +320,12 @@ export function MessageThread({
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (messages.length === 0 && !streamingActive && !optimisticActive) {
if (
messages.length === 0 &&
!streamingActive &&
!optimisticActive &&
interleavedItems.length === 0
) {
return (
<div
data-slot="coach-message-thread"
@@ -269,15 +360,20 @@ export function MessageThread({
// twin during the 150ms grace window so the streaming bubble
// stays alone on slow connections.
if (m.id === suppressedTwinId) return null;
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
return (
<ChatBubble
key={m.id}
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
/>
<Fragment key={m.id}>
{guidedBefore?.node}
<ChatBubble
role={m.role}
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
messageId={m.id}
/>
</Fragment>
);
})}
{/* v1.4.25 W5 — optimistic user bubble surfaces between the
@@ -285,6 +381,10 @@ export function MessageThread({
the visible order matches the user's mental model. The
send-hook drops it as soon as the SSE `done` frame fires +
the invalidate-refetch lands the persisted twin. */}
{/* v1.16.5 — guided question whose answer is still optimistic-only. */}
{placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{optimisticActive && optimisticUser && (
<ChatBubble
key={optimisticUser.localId}
@@ -308,6 +408,11 @@ export function MessageThread({
/>
</div>
)}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
</div>
);
}
@@ -593,8 +698,12 @@ function ChatBubble({
* with staggered delays so no custom keyframe is introduced;
* `motion-reduce` freezes the dots and the `label` stays as the
* screen-reader text either way.
*
* Exported since v1.16.5: the guided clarifying-question bubble replays
* the same indicator before a deterministic question reveals, so the
* scripted turns share one rhythm with the streamed ones.
*/
function TypingDots({ label }: { label: string }) {
export function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
@@ -28,12 +28,23 @@ export interface SelfContextAdoptOfferProps {
answer: string;
/** Remove the strip (declined, or confirmation elapsed). */
onDismiss: () => void;
/**
* v1.16.5 — reports the offer's outcome once, so the guided
* clarifying-questions flow can count adoptions for its closing
* summary. Fired on server settle (adopted / duplicate), on a failed
* request, and on an explicit decline — never on the auto-fold
* timeout (the outcome was already reported by then).
*/
onSettled?: (
outcome: "adopted" | "duplicate" | "declined" | "failed",
) => void;
}
export function SelfContextAdoptOffer({
question,
answer,
onDismiss,
onSettled,
}: SelfContextAdoptOfferProps) {
const { t } = useTranslations();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
@@ -44,6 +55,10 @@ export function SelfContextAdoptOffer({
},
onSuccess: (data) => {
setSettled(data.adopted ? "adopted" : "duplicate");
onSettled?.(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
onSettled?.("failed");
},
});
@@ -108,7 +123,10 @@ export function SelfContextAdoptOffer({
type="button"
aria-label={t("insights.coach.selfContextAdopt.dismiss")}
data-slot="coach-self-context-adopt-dismiss"
onClick={onDismiss}
onClick={() => {
onSettled?.("declined");
onDismiss();
}}
className={cn(
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
@@ -1,123 +0,0 @@
"use client";
/**
* v1.16.0 — pending self-context questions as composer chips.
*
* After the user saves Settings → AI → "About me", the server derives
* up to 3 clarifying questions (`/api/coach/about-me/questions`). They
* surface here, directly above the Coach composer, as quiet tappable
* chips: tapping one inserts the question into the composer (so the
* user answers it in their own words) and dismisses it; the ✕
* dismisses without inserting. No questions → nothing renders, zero
* vertical cost.
*
* Each chip is a `role="group"` of two sibling buttons (insert +
* dismiss) — buttons cannot nest, and both actions deserve their own
* focus stop.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MessageCircleQuestion, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
export interface SelfContextChipsProps {
/** Insert the tapped question into the composer. */
onPick: (question: string) => void;
/** Hidden while a reply streams so the input value cannot be yanked. */
disabled?: boolean;
}
export function SelfContextChips({
onPick,
disabled = false,
}: SelfContextChipsProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.coachAboutMeQuestions(),
queryFn: async () => {
return apiGet<{ questions: string[] }>("/api/coach/about-me/questions");
},
staleTime: 60_000,
});
const dismiss = useMutation({
mutationKey: queryKeys.coachAboutMeQuestions(),
mutationFn: async (question: string) => {
return apiDelete<{ questions: string[] }>("/api/coach/about-me/questions", { question });
},
onSuccess: (next) => {
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
},
// v1.16.4 — a failed dismiss used to fail silently, leaving the chip
// in place with no signal that the request was rejected.
onError: () => {
toast.error(t("insights.coach.selfContextChipDismissError"));
},
});
const questions = data?.questions ?? [];
if (questions.length === 0) return null;
return (
<div data-slot="coach-self-context-chips" className="mb-2">
<p className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-[11px]">
<MessageCircleQuestion
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
{t("insights.coach.selfContextChipsLabel")}
</p>
<ul className="flex flex-wrap gap-1.5">
{questions.map((question) => (
<li
key={question}
role="group"
aria-label={question}
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 inline-flex max-w-full items-stretch overflow-hidden rounded-full border",
"transition-colors",
)}
>
<button
type="button"
disabled={disabled}
data-slot="coach-self-context-chip"
onClick={() => {
onPick(question);
dismiss.mutate(question);
}}
title={question}
className={cn(
"text-foreground min-h-9 min-w-0 truncate py-1.5 pr-1 pl-3 text-left text-xs",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
{question}
</button>
<button
type="button"
disabled={disabled}
aria-label={t("insights.coach.selfContextChipDismiss")}
data-slot="coach-self-context-chip-dismiss"
onClick={() => dismiss.mutate(question)}
className={cn(
"text-muted-foreground hover:text-foreground flex items-center px-2",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
<X className="size-3" aria-hidden="true" />
</button>
</li>
))}
</ul>
</div>
);
}
+138 -17
View File
@@ -7,23 +7,67 @@
* `notificationPrefs.coach.nudgesEnabled` through the roaming
* notification-prefs blob so web + iOS share one source of truth.
*
* v1.16.5 — per-group toggles (medication / vitals / routine) plus the
* frequency pref (weekly / biweekly) underneath the master switch. All
* of it rides the same `coach` sub-object of the prefs blob; the cron
* resolves it via `resolveCoachNudgePrefs`.
*
* Mirrors `<MoodReminderCard>` (optimistic switch, auto-clearing inline
* status line).
* status line, NativeSelect for the enum pref).
*/
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MessageCircleHeart } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
interface NotificationPrefsShape {
coach: { nudgesEnabled: boolean };
interface CoachPrefsShape {
nudgesEnabled: boolean;
nudgeMedication: boolean;
nudgeVitals: boolean;
nudgeRoutine: boolean;
nudgeFrequency: "weekly" | "biweekly";
}
interface NotificationPrefsShape {
coach: CoachPrefsShape;
}
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
};
const GROUP_FIELDS = [
"nudgeMedication",
"nudgeVitals",
"nudgeRoutine",
] as const;
type GroupField = (typeof GROUP_FIELDS)[number];
const GROUP_I18N: Record<GroupField, { label: string; desc: string }> = {
nudgeMedication: {
label: "notifications.coachNudge.groupMedication",
desc: "notifications.coachNudge.groupMedicationDesc",
},
nudgeVitals: {
label: "notifications.coachNudge.groupVitals",
desc: "notifications.coachNudge.groupVitalsDesc",
},
nudgeRoutine: {
label: "notifications.coachNudge.groupRoutine",
desc: "notifications.coachNudge.groupRoutineDesc",
},
};
export function CoachNudgeCard({
isAuthenticated,
}: {
@@ -31,7 +75,7 @@ export function CoachNudgeCard({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [optimistic, setOptimistic] = useState<Partial<CoachPrefsShape>>({});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
@@ -66,10 +110,23 @@ export function CoachNudgeCard({
enabled: isAuthenticated,
});
const enabled = optimistic ?? prefs?.coach?.nudgesEnabled ?? true;
const resolved: CoachPrefsShape = {
...COACH_PREF_DEFAULTS,
...(prefs?.coach ?? {}),
...optimistic,
};
const enabled = resolved.nudgesEnabled;
async function handleToggle(next: boolean) {
setOptimistic(next);
/**
* One PATCH path for every knob on the card. `toast` carries the
* success line for the master toggle; the sub-prefs pass null and
* stay silent on success — the control itself shows the new state.
*/
async function patchCoach(
partial: Partial<CoachPrefsShape>,
toast: string | null,
) {
setOptimistic((prev) => ({ ...prev, ...partial }));
setSaving(true);
setMsg(null);
setMsgType(null);
@@ -81,23 +138,21 @@ export function CoachNudgeCard({
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coach: { nudgesEnabled: next } }),
body: JSON.stringify({ coach: partial }),
});
if (res.ok) {
setMsg(
next
? t("notifications.coachNudge.enabledToast")
: t("notifications.coachNudge.disabledToast"),
);
setMsgType("success");
if (toast) {
setMsg(toast);
setMsgType("success");
scheduleClear();
}
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
scheduleClear();
setOptimistic({});
} else {
setOptimistic(null);
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
setMsgType("error");
scheduleClear();
@@ -105,6 +160,15 @@ export function CoachNudgeCard({
setSaving(false);
}
async function handleToggle(next: boolean) {
await patchCoach(
{ nudgesEnabled: next },
next
? t("notifications.coachNudge.enabledToast")
: t("notifications.coachNudge.disabledToast"),
);
}
return (
<section
aria-labelledby="settings-coach-nudge-title"
@@ -132,6 +196,63 @@ export function CoachNudgeCard({
</label>
}
/>
{enabled && (
<div className="mt-4 space-y-3 pl-7">
<p className="text-sm font-medium">
{t("notifications.coachNudge.groupsLabel")}
</p>
{GROUP_FIELDS.map((field) => (
<div
key={field}
className="flex min-h-11 items-center justify-between gap-3"
data-testid={`coach-nudge-group-${field}`}
>
<div className="min-w-0">
<p className="text-sm">{t(GROUP_I18N[field].label)}</p>
<p className="text-muted-foreground text-xs">
{t(GROUP_I18N[field].desc)}
</p>
</div>
<Switch
checked={resolved[field]}
onCheckedChange={(next) => patchCoach({ [field]: next }, null)}
disabled={!isAuthenticated || saving}
aria-label={t(GROUP_I18N[field].label)}
/>
</div>
))}
<div className="flex min-h-11 items-center gap-3">
<label
htmlFor="coach-nudge-frequency"
className="text-sm font-medium"
>
{t("notifications.coachNudge.frequencyLabel")}
</label>
<NativeSelect
id="coach-nudge-frequency"
className="w-auto"
value={resolved.nudgeFrequency}
disabled={!isAuthenticated || saving}
onChange={(e) =>
patchCoach(
{
nudgeFrequency: e.target.value as "weekly" | "biweekly",
},
null,
)
}
aria-label={t("notifications.coachNudge.frequencyAria")}
>
<option value="weekly">
{t("notifications.coachNudge.frequencyWeekly")}
</option>
<option value="biweekly">
{t("notifications.coachNudge.frequencyBiweekly")}
</option>
</NativeSelect>
</div>
</div>
)}
{msg && (
<p
role="status"