feat(coach): a chat that behaves like one

The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 21:19:05 +02:00
1 parent b55fab5e94
commit 9f2c403f84
19 files changed
+1092 -879

No files matched your search

+15
View File
@@ -57,6 +57,7 @@ import {
listConversations,
} from "@/lib/ai/coach/persistence";
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
enforceBudget,
@@ -331,6 +332,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// rolling summary + extract durable facts off the request path. Fire-and-
// forget: this turn uses whatever summary is already on disk; the refresh
// makes the next long turn fresh. No-ops without an embedded worker.
// v1.16.1 — always-remember categories (allergies, intolerances, explicit
// self-reported diagnoses) must not wait for the >TURN_CAP memory refresh:
// a health-critical statement in the second message of a short chat used
// to never reach the fact store. The deterministic pattern pass is
// provider-free and deduped, so it fires on every user turn.
void storeDeterministicFacts({
conversationId: workingConversationId,
userId,
message,
locale,
}).catch(() => {
// Fact capture must never break the chat turn; the >TURN_CAP LLM
// extraction remains as the catch-all on long conversations.
});
if (allTurns.length > TURN_CAP) {
void enqueueCoachMemoryRefresh({
conversationId: workingConversationId,
@@ -0,0 +1,51 @@
/**
* GET /api/insights/coach/nudge-status — has the proactive Coach nudge
* something the caller has not engaged with yet?
*
* v1.16.1 — feeds the floating Coach bubble: the bubble renders only
* while an unseen Coach-initiated nudge exists, instead of being a
* permanent FAB. "Unseen" is derived, not stored: the latest successful
* `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
* already anchors its frequency cap on) counts as read once the user
* has sent a Coach message after it. The client additionally remembers
* a local "seen" stamp so opening the chat without sending also clears
* the bubble on that device.
*
* No new table, no migration: both timestamps come from existing rows,
* scoped to the caller.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const [lastNudge, lastUserMessage] = await Promise.all([
prisma.pushAttempt.findFirst({
where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.coachMessage.findFirst({
where: { role: "user", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
const nudgedAt = lastNudge?.createdAt ?? null;
const unread =
nudgedAt !== null &&
(lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
return apiSuccess({
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
unread,
});
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";