feat(coach): New conversation in the chat, a foldable list, and the day beside it

New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 13:44:58 +02:00
1 parent 9d5a529d53
commit 08f532b428
29 files changed
+659 -1338

No files matched your search

@@ -137,11 +137,6 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
capabilities: ["coach"],
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
},
"src/app/api/insights/coach/seeded-question/route.ts": {
kind: "mixed",
capabilities: ["coach"],
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
},
"src/app/api/coach/about-me/route.ts": {
kind: "mixed",
capabilities: ["aboutMeQuestions"],
@@ -104,7 +104,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -141,7 +140,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -180,7 +178,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -230,7 +227,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -259,7 +255,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -289,7 +284,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -311,7 +305,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -395,7 +388,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -435,7 +427,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -464,7 +455,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -510,7 +500,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -1,169 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.21.2 (A3) — the seeded-question route resolves today's single most
* notable derived signal into a tappable Coach opener, server-side. When the
* confidence + notability gate yields nothing the route returns
* `{ signal: null }` and the hero keeps its neutral greeting — never a
* fabricated opener.
*/
const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
},
}));
// The `coach` capability decides whether an opener is served at all; pinned
// here so the route test does not depend on the capability loader's reads.
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
const getAiCapability = vi.fn();
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAnalyticsReadRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the profile loader + the shared briefing detector so the route test
// stays isolated from the compute tier.
vi.mock("@/lib/insights/derived", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/insights/derived")>()),
loadBaselineProfile: vi
.fn()
.mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }),
}));
vi.mock("@/lib/insights/derived-briefing", () => ({
detectDerivedBriefingSignals: vi.fn(),
}));
import { getSession } from "@/lib/auth/session";
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
import { GET } from "../route";
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
new URL("http://localhost/api/insights/coach/seeded-question"),
);
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
getAiCapability.mockResolvedValue(AVAILABLE);
});
describe("GET /api/insights/coach/seeded-question", () => {
it("returns the most notable signal (the head) when one crosses the gate", async () => {
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({
signals: [
{
sourceMetric: "readiness",
label: "readiness",
score: 58,
band: "yellow",
confidence: 72,
},
{
sourceMetric: "recovery",
label: "recovery",
score: 49,
band: "red",
confidence: 65,
},
],
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
signal: { sourceMetric: string; score: number; band: string } | null;
} | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.signal).toEqual({
sourceMetric: "readiness",
score: 58,
band: "yellow",
});
expect(getAiCapability).toHaveBeenCalledWith("coach");
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
});
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
"answers { signal: null, ai } for %s without running the detector",
async (reason) => {
const ai = { available: false, reason, onDeviceAllowed: false };
getAiCapability.mockResolvedValue(ai);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { signal: unknown; ai: unknown };
};
expect(body.data).toEqual({ signal: null, ai });
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
},
);
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { signal: unknown } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.signal).toBeNull();
});
it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => {
findUniqueMock.mockResolvedValue({
notificationPrefs: { coach: { ambientSuggestions: false } },
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { signal: unknown } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.signal).toBeNull();
// The opt-out short-circuits before the compute tier.
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
});
});
@@ -1,113 +0,0 @@
/**
* v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach.
*
* `GET /api/insights/coach/seeded-question` resolves today's single most
* notable derived wellness signal (readiness / recovery shift, the same
* confidence-gated detector the daily briefing uses) into a tappable
* suggested opener for the Coach's blank new-chat hero. When no signal
* crosses the gate the route returns `{ signal: null }` and the hero
* keeps its neutral greeting — never a fabricated opener.
*
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
* and never recomputes it (server-authoritative parity). It IS registered in
* the OpenAPI contract. This comment said the opposite until the route was
* published — it shipped as a same-origin web surface and the sentence was
* never revisited. A comment that contradicts the registry is worse than no
* comment, because the next reader believes it instead of checking.
*
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
* (userId narrowed from the session/Bearer, never a body/query field),
* analytics-read rate limit.
*
* A mixed read: the opener only exists to open the Coach, so it is served
* only while the `coach` capability is available. Otherwise the route answers
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
* with the reason beside it.
*/
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
/** Resolved opener the hero renders, or null when nothing notable. */
export interface CoachSeededQuestionDTO {
/**
* The notable signal driving the opener, or null when nothing crossed
* the briefing detector's confidence + notability gate.
*/
signal: {
/** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */
sourceMetric: string;
/** Latest 0–100 score. */
score: number;
/** Band — `yellow` / `red` (green never surfaces as notable). */
band: string;
} | null;
/** Whether the Coach this opener feeds is available, and why not. */
ai: AiCapabilityState;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// The opener feeds the Coach hero: with the Coach unavailable there is no
// hero to seed, so the neutral shape answers and says why.
const ai = await getAiCapability("coach");
if (!ai.available) {
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
}
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
// Server-authoritative ambient-suggestions opt-out: when the user turns
// proactive suggestions off the hero must not seed an opener, so the route
// skips the detector work and returns the neutral `{ signal: null }` shape.
const prefsRow = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
if (
!resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null)
) {
annotate({
action: { name: "coach.seeded-question.resolve" },
meta: { has_signal: false, source_metric: "none", band: "none" },
});
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
}
// Profile read once via the shared loader; passed into the detector,
// never re-fetched per candidate (the detector probes readiness +
// recovery off the one profile).
const profile = await loadBaselineProfile(prisma, user.id);
const detected = await detectDerivedBriefingSignals(user.id, profile);
// The detector returns 1–2 signals in priority order; the opener anchors
// on the single most notable one (the head).
const top = detected?.signals[0] ?? null;
const signal: CoachSeededQuestionDTO["signal"] = top
? { sourceMetric: top.sourceMetric, score: top.score, band: top.band }
: null;
annotate({
action: { name: "coach.seeded-question.resolve" },
meta: {
has_signal: signal !== null,
source_metric: signal?.sourceMetric ?? "none",
band: signal?.band ?? "none",
},
});
return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO);
});
@@ -31,12 +31,17 @@ describe("<CoachHero>", () => {
expect(html).toContain("Frage mich etwas zu deinen Daten");
});
it("does not render starter-question suggestion chips", () => {
// v1.18.10 (W4) — the two starter chips below the composer were
// removed; the hero is greeting + composer only.
const html = render(<CoachHero composer={null} />);
it("leaves nothing under the composer", () => {
// No starter chips, no seeded opener, no scope pill: the empty
// conversation is the greeting and the field.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).not.toContain('data-slot="coach-hero-chips"');
expect(html).not.toContain('data-slot="coach-hero-chip"');
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
expect(html).not.toContain("coach-scope-hint");
const afterComposer = html.split('data-slot="test-composer"')[1] ?? "";
expect(afterComposer.replace(/<\/div>|composer|>/g, "")).toBe("");
});
});
@@ -0,0 +1,114 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { BOTH_PANELS_QUERY, listYields } from "../conversations-panel";
import { NewChatFab } from "../new-chat-fab";
/**
* The Coach page holds two docked panels beside the conversation: the
* conversations and, right of them, the day. Below 1600 px only one is open.
* The list folds when a day opens or the window narrows with both open; it
* never folds again right after the person opened it beside a day (that
* folds the day instead). The click paths and focus run in
* `e2e/coach-layout.spec.ts`.
*/
const base = {
docked: true,
fitsBoth: false,
listOpen: true,
dayOpen: true,
dayOpened: false,
narrowed: false,
};
describe("listYields", () => {
it("folds the list when a day opens below 1600 px", () => {
expect(listYields({ ...base, dayOpened: true })).toBe(true);
});
it("folds the list when the window narrows with both open", () => {
expect(listYields({ ...base, narrowed: true })).toBe(true);
});
it("keeps both open from 1600 px", () => {
expect(listYields({ ...base, fitsBoth: true, dayOpened: true })).toBe(
false,
);
});
it("leaves a list the person opened beside a day alone", () => {
// No opening and no narrowing: the day is the one that gives way.
expect(listYields(base)).toBe(false);
});
it("does nothing for a folded list, a closed day, or the sheet", () => {
expect(listYields({ ...base, dayOpened: true, listOpen: false })).toBe(
false,
);
expect(listYields({ ...base, narrowed: true, dayOpen: false })).toBe(false);
expect(listYields({ ...base, dayOpened: true, docked: false })).toBe(false);
});
it("splits at 1600 px", () => {
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
});
});
describe("the folded list", () => {
const source = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/conversations-panel.tsx",
),
"utf8",
);
it("keeps an edge with its button, and sits left of the day", () => {
expect(source).toContain('data-slot="coach-panel-rail"');
expect(source).toContain('data-slot="coach-panel-expand"');
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
expect(source).toMatch(/order-1/);
});
it("no longer starts a conversation from the list's header", () => {
expect(source).not.toContain("coach-panel-new-chat");
});
});
describe("<NewChatFab>", () => {
it("is the Coach button's round face with a plus, named New conversation", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NewChatFab onNewChat={() => undefined} />
</I18nProvider>,
);
const button = html.match(/<button[^>]*>/)?.[0] ?? "";
expect(button).toContain('data-slot="coach-new-chat-fab"');
expect(button).toContain('aria-label="New conversation"');
expect(button).toContain("size-14");
expect(button).toContain("rounded-full");
expect(button).toContain("from-primary");
// Above the composer by default, beside it where the column is wide.
expect(button).toContain("bottom-full");
expect(button).toContain("@min-[54rem]/composer:bottom-8");
expect(html).toContain("lucide-plus");
});
});
describe("coachPageHref", () => {
it("keeps an open day when the conversation changes", async () => {
const { coachPageHref } = await import("../coach-conversation");
expect(coachPageHref("c1")).toBe("/coach?c=c1");
expect(coachPageHref(null)).toBe("/coach");
expect(coachPageHref("c1", "?c=c0&day=2026-09-24")).toBe(
"/coach?c=c1&day=2026-09-24",
);
expect(coachPageHref(null, "?day=2026-09-24&c=c0")).toBe(
"/coach?day=2026-09-24",
);
});
});
@@ -1,177 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ScopeHintBadge } from "../scope-hint-badge";
import { CoachHero } from "../coach-hero";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* v1.21.2 (A2 + A3) — the visible scope/opener affordance.
*
* The component renders in the `node` test environment via static markup, so
* the tests assert the structural slots + the caller-resolved label / question
* (those are passed in already-localised, not looked up in the bundle). The
* prefix/tap copy resolves through the live i18n bundle.
*/
describe("<ScopeHintBadge>", () => {
it("renders the scope pill with the active metric and the tappable seed", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint"');
expect(html).toContain('data-variant="scope"');
// The metric label is surfaced in the visible pill.
expect(html).toContain('data-slot="coach-scope-hint-pill"');
expect(html).toContain("blood pressure");
// The seed question is a real button so it is keyboard reachable.
expect(html).toContain('data-slot="coach-scope-hint-seed"');
expect(html).toContain("Walk me through my blood pressure trend.");
});
it("renders the seeded variant for a notable derived signal", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).toContain('data-variant="seeded"');
expect(html).toContain("readiness");
expect(html).toContain("Why is my readiness lower today?");
});
it("invokes onSeed with the question when the seed button is activated", () => {
// The button's onClick is wired to `onSeed(question)`; assert the binding
// directly (the node test env has no DOM event loop to click through).
const onSeed = vi.fn();
const question = "Walk me through my pulse readings.";
// Render once to prove no throw, then exercise the handler contract.
render(
<ScopeHintBadge
variant="scope"
label="pulse"
question={question}
onSeed={onSeed}
/>,
);
onSeed(question);
expect(onSeed).toHaveBeenCalledWith(question);
});
// v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can
// wave off today's suggestion. The control is gated to the seeded variant.
it("renders the dismiss control for the seeded variant when onDismiss is given", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint-dismiss"');
});
it("omits the dismiss control for the seeded variant without onDismiss", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
});
it("never renders the dismiss control for the non-dismissable scope pill", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
});
it("sizes the seeded opener to its text and keeps it single-line", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
const seedTag = html.match(
/<button[^>]*data-slot="coach-scope-hint-seed"[^>]*>/,
);
expect(seedTag).not.toBeNull();
// v1.25.0 — intrinsic width, not stretched to the composer column: the
// opener grows to its text and caps at the full width. The old narrow cap
// and the full-width stretch are both gone.
expect(html).not.toContain("max-w-md");
// Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not
// the old `flex-1` / `w-full` full-width stretch.
expect(seedTag?.[0]).toContain("inline-flex");
expect(seedTag?.[0]).toContain("max-w-full");
expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/);
// The question span truncates with an ellipsis rather than wrapping.
expect(html).toContain("truncate");
});
});
describe("<CoachHero> scope hint slot", () => {
it("renders the scope hint above-composer slot when provided", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
scopeHint={<div data-slot="injected-hint">hint</div>}
/>,
);
expect(html).toContain('data-slot="coach-hero-scope-hint"');
expect(html).toContain('data-slot="injected-hint"');
});
it("falls back to the neutral hero when no scope hint is given", () => {
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
// is the calm greeting + composer with NO opener affordance.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain("Ask me anything about your data");
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
});
});
describe("metricScopeLabelFallback", () => {
it("maps a scope source to its brand-free domain label", () => {
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
});
it("returns null for an absent metric (a generic open)", () => {
expect(metricScopeLabelFallback(null)).toBeNull();
expect(metricScopeLabelFallback(undefined)).toBeNull();
});
});
@@ -7,18 +7,17 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Paperclip, Plus, Sparkles } from "lucide-react";
import { Paperclip, 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 { DAY_QUERY_PARAM } from "@/lib/day/contract";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
@@ -30,7 +29,6 @@ import {
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { ScopeHintBadge } from "./scope-hint-badge";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -53,12 +51,12 @@ import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
import { MobileRailTray } from "./mobile-rail-tray";
import { NewChatFab } from "./new-chat-fab";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
import { AttachmentPicker } from "./attachment-picker";
import { useResettableValue } from "./use-resettable-value";
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
import {
useAttachCoachDocument,
useCoachConversation,
@@ -127,19 +125,6 @@ export function dropInvalidStagedAttachments(
return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true);
}
/**
* v1.21.4 (C2) — the localStorage key that records the seeded "worth a look"
* opener as dismissed for a given LOCAL calendar day. Date-stamped so the
* dismissal resets at midnight: a new day mints a new key the flag has not
* been written under yet, and the opener returns.
*/
function seededDismissStorageKey(now: Date = new Date()): string {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `coach-seeded-dismissed:${year}-${month}-${day}`;
}
export interface CoachConversationProps {
/**
* Pre-fill for the composer (suggested-prompt chip click). Resets the
@@ -269,10 +254,18 @@ export interface CoachConversationProps {
* `useSearchParams` in step with it) so a reload lands on the same thread
* without a server round trip or a new history entry per switch.
*/
function coachPageHref(conversationId: string | null): string {
return conversationId
export function coachPageHref(
conversationId: string | null,
search = "",
): string {
const base = conversationId
? `/coach?c=${encodeURIComponent(conversationId)}`
: "/coach";
// An open day stays open: the day docked beside the conversation is the
// URL's `?day=`, and switching conversations must not close it.
const day = new URLSearchParams(search).get(DAY_QUERY_PARAM);
if (!day) return base;
return `${base}${conversationId ? "&" : "?"}${DAY_QUERY_PARAM}=${encodeURIComponent(day)}`;
}
export function CoachConversation({
@@ -372,7 +365,7 @@ export function CoachConversation({
// a shared tab) lands on it. The drawer has no URL of its own.
function syncPageUrl(conversationId: string | null) {
if (surface !== "page" || typeof window === "undefined") return;
const href = coachPageHref(conversationId);
const href = coachPageHref(conversationId, window.location.search);
if (window.location.pathname + window.location.search === href) return;
window.history.replaceState(window.history.state, "", href);
}
@@ -973,62 +966,16 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero.
//
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
// visible — a "the Coach is already on <metric>" pill plus the data-aware
// seed question (the launch prefill) the user can tap into the composer —
// instead of the old hidden prefill.
//
// A3 (unscoped launch): no launch scope, so resolve today's single most
// notable derived signal server-side and offer it as a tappable opener. The
// query only fires when the hero is on screen AND there is no launch scope
// (an A2 launch already has its opener), so a scoped open pays nothing for
// it. When the server returns no signal the hint is null and the neutral
// greeting stands — never a fabricated opener.
// Something to leave: a stored conversation or a turn on its way.
const threadStarted =
currentConversationId !== null ||
send.isStreaming ||
!!send.optimisticUser ||
!!send.streaming.content;
// The launch scope, for the drawer's sources rail: a conversation opened
// from a metric surface names the metric there until its first turn.
const a2Metric = launchScope?.metric ?? null;
// v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone
// for the rest of the local calendar day; the fetch is skipped too, so a
// dismissed day costs nothing. SSR-safe lazy init guards `window`.
const [seededDismissedToday, setSeededDismissedToday] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(seededDismissStorageKey()) === "1";
} catch {
return false;
}
});
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
// seeded opener (and skips its fetch when off). The A2 launch scope below is
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
const seededEnabled =
heroActive &&
a2Metric === null &&
!seededDismissedToday &&
ambientSuggestionsEnabled;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
enabled: seededEnabled,
staleTime: 5 * 60 * 1000,
});
function seedComposer(question: string) {
setInputValue(question);
}
function dismissSeeded() {
setSeededDismissedToday(true);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(seededDismissStorageKey(), "1");
} catch {
// Storage can throw (private mode / quota); the in-memory flag still
// hides the opener for this session.
}
}
// `t()` returns the key string itself when a key is missing, so the only
// reliable "is this key defined" signal is `t(key) !== key`.
@@ -1037,10 +984,9 @@ export function CoachConversation({
return resolved === key ? null : resolved;
}
// The resolved A2 metric label: prefers a per-source i18n key, falls back
// to the brand-free English domain phrase so every source has a label.
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
// the same string. Null when the launch carried no metric.
// The resolved metric label: prefers a per-source i18n key, falls back to
// the brand-free English domain phrase so every source has a label. Null
// when the launch carried no metric.
const a2LabelKey = scopeSourceMetricLabelKey(a2Metric);
const a2MetricLabel = a2Metric
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
@@ -1049,51 +995,6 @@ export function CoachConversation({
a2Metric)
: null;
let scopeHint: React.ReactNode = null;
if (fenced) {
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
// honest fencing banner (below) is the only chrome the fenced hero shows.
scopeHint = null;
} else if (a2Metric && a2MetricLabel) {
// A2 — the launch prefill IS the data-aware opener; fall back to the
// generic per-metric question when the launch carried no prefill.
const seedQuestion =
(prefill ?? "").trim() || t("insights.coach.scope.question");
scopeHint = (
<ScopeHintBadge
variant="scope"
label={a2MetricLabel}
question={seedQuestion}
onSeed={seedComposer}
/>
);
} else if (
seeded?.signal &&
!seededDismissedToday &&
ambientSuggestionsEnabled
) {
// A3 — the notable derived signal. The label + opener are keyed on the
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
// (future detector additions) skips the opener rather than guessing.
const sentinel = seeded.signal.sourceMetric;
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
const signalQuestion = tOrNull(
`insights.coach.seeded.question.${sentinel}`,
);
if (signalLabel && signalQuestion) {
scopeHint = (
<ScopeHintBadge
variant="seeded"
label={signalLabel}
question={signalQuestion}
onSeed={seedComposer}
onDismiss={dismissSeeded}
/>
);
}
}
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
// the guided-questions entry card stack above the live composer. Shared by
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
@@ -1218,7 +1119,7 @@ export function CoachConversation({
/>
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
<CoachHero composer={composerNode} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
@@ -1234,9 +1135,10 @@ export function CoachConversation({
</div>
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
className="@container/composer relative shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
<NewChatFab onNewChat={handleNewChat} />
</div>
</>
)}
@@ -1300,18 +1202,6 @@ export function CoachConversation({
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleNewChat}
data-slot="coach-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* The same quick settings the page's panel opens: model and what
the Coach may read. Everything else stays in Settings → AI. */}
<CoachSettingsOverlay
@@ -1342,6 +1232,11 @@ export function CoachConversation({
/>
}
composer={composerStack}
// New conversation floats in the conversation itself, once there is
// one to leave.
newChat={
threadStarted ? <NewChatFab onNewChat={handleNewChat} /> : null
}
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
@@ -42,6 +42,8 @@ import { useTranslations } from "@/lib/i18n/context";
export interface CoachDrawerBodyProps {
thread: React.ReactNode;
composer: React.ReactNode;
/** The New conversation button, floating above the composer. */
newChat?: React.ReactNode;
/**
* Inline conversation list (page surface only). When set, it renders
* as a collapsible left column on lg+ and the history button collapses
@@ -67,6 +69,7 @@ export interface CoachDrawerBodyProps {
export function CoachDrawerBody({
thread,
composer,
newChat,
historyRail,
historyOpen = false,
onToggleHistory,
@@ -234,9 +237,10 @@ export function CoachDrawerBody({
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
className="border-border/70 @container/composer relative flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
>
{composer}
{newChat}
</div>
</main>
</div>
@@ -28,25 +28,15 @@ import { useTranslations } from "@/lib/i18n/context";
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*
* v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
* pill + seed question when launched scoped (A2), or the pre-seeded
* notable-signal opener when launched unscoped (A3). When absent the hero
* is the calm greeting + composer exactly as before, so the neutral
* blank-state fallback is structural.
* The space under the composer stays empty: no suggested prompts and no
* openers, so an empty conversation is the greeting and the field.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/**
* v1.21.2 — optional scope/opener affordance rendered above the
* composer. The parent owns the data (launch scope / seeded signal) and
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
*/
scopeHint?: React.ReactNode;
}
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
export function CoachHero({ composer }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -104,23 +94,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
>
{composer}
</div>
{/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath
the composer so the greeting → composer rhythm stays intact and
the opener reads as a follow-on suggestion. Renders only when the
parent resolved a scope or a notable signal. */}
{scopeHint ? (
<div
data-slot="coach-hero-scope-hint"
className={cn(
"w-full",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{scopeHint}
</div>
) : null}
</div>
</div>
);
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
import { Button } from "@/components/ui/button";
import {
Sheet,
@@ -42,18 +43,26 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Docked, the panel is a column of the shell beside the content column
* (top bar and page): it runs the full height of the window, and the top bar
* ends at its left edge. The panel's header row is the top bar's band, so
* the two bottom borders draw one line. Shut, it is gone entirely (no rail)
* and the top bar runs to the window's edge. The toggle is the last item of
* the top bar, so it sits against the open panel and at the window's edge
* when the panel is shut. The panel header carries the title, a link to
* Plans, the settings gear and, in the sheet, a close button. New chat
* opens the header row, left of the title: a fixed control that never sits
* over the list, so the last conversation needs no reserve below it.
* the two bottom borders draw one line. It folds like the day beside it: the
* header's first control folds it to a narrow edge, and the edge's button
* (in the same place) opens it again. Folded, the edge names the list.
* The header carries the title, a link to Plans, the settings gear and, in
* the sheet, a close button. New conversation is not here: it floats in the
* conversation itself (`NewChatFab`).
*
* Keyboard: Escape closes the docked panel when focus is inside it and
* The day docks right of the panel on this page too (page, conversations,
* day). Below 1600 px only one of the two is open: a day opening folds the
* list to its edge without touching the remembered choice, and opening the
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
*
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
* the top bar.
*
* Keyboard: Escape folds the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
* already claimed the key, then focus returns to the toggle. In the sheet
* Radix owns Escape, the focus trap and the return.
* already claimed the key; focus moves to the edge's button, and opening
* from there lands on the fold control. In the sheet Radix owns Escape, the
* focus trap and the return.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
@@ -68,6 +77,50 @@ const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
const noSubscription = () => () => {};
/** Wide enough for the conversations and a day beside the conversation. */
export const BOTH_PANELS_QUERY = "(min-width: 1600px)";
function subscribeBothPanels(callback: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(BOTH_PANELS_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function useBothPanelsFit(): boolean {
return useSyncExternalStore(
subscribeBothPanels,
() =>
typeof window !== "undefined" && !!window.matchMedia
? window.matchMedia(BOTH_PANELS_QUERY).matches
: false,
() => false,
);
}
/**
* Whether the docked list gives way to an open day: it does when the day
* opens, or the window narrows, while both would be open below 1600 px.
* Only those two moments fold it, so opening the list again beside an open
* day (which folds the day) is never undone.
*/
export function listYields(state: {
docked: boolean;
fitsBoth: boolean;
listOpen: boolean;
dayOpened: boolean;
narrowed: boolean;
dayOpen: boolean;
}): boolean {
return (
state.docked &&
!state.fitsBoth &&
state.listOpen &&
state.dayOpen &&
(state.dayOpened || state.narrowed)
);
}
/**
* Whether the phone sheet is open. A settings deep link opens it only once
* the viewport is known: the server and hydration renders take the narrow
@@ -181,22 +234,62 @@ export function ConversationsPanel({
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
const dockedOpen = rememberedOpen || revealed;
const expanded = docked ? dockedOpen : sheetOpen;
function toggle() {
if (docked) {
setRevealed(false);
setOpen(!dockedOpen);
} else {
setSheetOpen(!sheetOpen);
// The day beside the list (docked on this page from 1280 px too).
const openDay = useOpenDay();
const fitsBoth = useBothPanelsFit();
// Folded for the day, without rewriting the remembered choice.
const [yielded, setYielded] = useState(false);
const dockedOpen = (rememberedOpen || revealed) && !yielded;
const [seen, setSeen] = useState<{ day: string | null; fitsBoth: boolean }>({
day: null,
fitsBoth,
});
if (seen.day !== openDay || seen.fitsBoth !== fitsBoth) {
setSeen({ day: openDay, fitsBoth });
if (
listYields({
docked,
fitsBoth,
listOpen: dockedOpen,
dayOpen: openDay !== null,
dayOpened: seen.day === null && openDay !== null,
narrowed: seen.fitsBoth && !fitsBoth,
})
) {
setYielded(true);
}
}
function closeDocked() {
const expanded = docked ? dockedOpen : sheetOpen;
const collapseRef = useRef<HTMLButtonElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// Where focus goes once the docked panel has folded or opened.
const focusAfter = useRef<"collapse" | "expand" | null>(null);
useEffect(() => {
const target = focusAfter.current;
focusAfter.current = null;
if (target === "collapse") collapseRef.current?.focus();
if (target === "expand") expandRef.current?.focus();
}, [dockedOpen]);
function toggleSheet() {
setSheetOpen(!sheetOpen);
}
function expandDocked() {
focusAfter.current = "collapse";
setYielded(false);
setRevealed(false);
setOpen(true);
// Below 1600 px the day makes room: it folds to its edge.
if (!fitsBoth && openDay !== null) yieldDay();
}
function collapseDocked() {
focusAfter.current = "expand";
setYielded(false);
setRevealed(false);
setOpen(false);
toggleRef.current?.focus();
}
function afterPick() {
@@ -224,32 +317,34 @@ export function ConversationsPanel({
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
// The panel's one action, so the primary wash marks it apart
// from the muted utility buttons on the right.
className={cn(
PANEL_HEADER_BUTTON,
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("insights.coach.newChat")}
</TooltipContent>
</Tooltip>
{inSheet ? null : (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={collapseRef}
type="button"
variant="ghost"
size="icon"
onClick={collapseDocked}
data-slot="coach-panel-collapse"
aria-controls={COACH_PANEL_ID}
aria-expanded={true}
aria-label={t("insights.coach.frame.hidePanel")}
className={PANEL_HEADER_BUTTON}
>
{/* The day's own fold control and glyph, mirrored so it
points the way the panel goes. */}
<PanelRightClose
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("insights.coach.frame.hidePanel")}
</TooltipContent>
</Tooltip>
)}
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
@@ -321,14 +416,16 @@ export function ConversationsPanel({
return (
<TooltipProvider delayDuration={300}>
<TopBarActions>
<PanelToggle
ref={toggleRef}
expanded={expanded}
label={toggleLabel}
onToggle={toggle}
/>
</TopBarActions>
{docked ? null : (
<TopBarActions>
<PanelToggle
ref={toggleRef}
expanded={expanded}
label={toggleLabel}
onToggle={toggleSheet}
/>
</TopBarActions>
)}
{docked ? (
<ShellSidePanel>
@@ -337,28 +434,87 @@ export function ConversationsPanel({
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
inert={!dockedOpen}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (
!dockedOpen ||
event.key !== "Escape" ||
event.defaultPrevented
) {
return;
}
event.preventDefault();
closeDocked();
collapseDocked();
}}
className={cn(
// A column of the shell's content row (`ShellSidePanel`), so it
// runs from the top of the window to the bottom beside the top
// bar. The width animates (offcanvas: shut, nothing of it
// shows); the inner column
// keeps 18rem and is pinned to the panel's left edge, so it
// slides out to the right instead of squeezing. The thread column
// keeps its width; only its centring moves.
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
// bar, left of a docked day (`order-1`, the day is `order-2`).
// The width animates between the open column and the folded
// edge; the open column keeps 18rem and is pinned to the
// panel's left edge, so it slides instead of squeezing.
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
dockedOpen ? "w-72" : "w-12",
)}
>
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
{dockedOpen ? (
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
) : (
<div
data-slot="coach-panel-rail"
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
>
{/* The top bar's band, so the bottom borders draw one line;
the button sits where the open panel keeps its fold
control. */}
<div
className={cn(
SHELL_HEADER_BAND,
"border-sidebar-border flex shrink-0 items-center justify-center",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
onClick={expandDocked}
data-slot="coach-panel-expand"
aria-controls={COACH_PANEL_ID}
aria-expanded={false}
aria-label={t("insights.coach.frame.showPanel")}
className={PANEL_HEADER_BUTTON}
>
<PanelRightOpen
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("insights.coach.frame.showPanel")}
</TooltipContent>
</Tooltip>
</div>
{/* The rest of the edge takes a click too and names the
list; the keyboard has the button above. */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={expandDocked}
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
>
<span className="[writing-mode:vertical-rl]">
{t("insights.coach.historyTitle")}
</span>
</button>
</div>
)}
</aside>
</ShellSidePanel>
) : (
@@ -0,0 +1,57 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* New conversation, as the round button in the conversation's own window:
* the floating Coach button's size and look, with a plus.
*
* It sits in the composer's row, so it belongs to the conversation and never
* to the list beside it, and it moves wherever that column goes (a docked
* list or day narrows the column, the button stays inside it). Where the
* column is wide enough, it takes the bottom right corner beside the
* composer, where the Coach button sits on other pages; where the composer
* fills the width (a phone, the drawer, a narrow column), it rises just
* above the composer's top edge so it never covers the field.
*
* The parent is the composer's wrapper and must be `relative` and an
* `@container/composer`.
*/
export function NewChatFab({
onNewChat,
className,
}: {
onNewChat: () => void;
className?: string;
}) {
const { t } = useTranslations();
const label = t("insights.coach.newConversation");
return (
<Button
type="button"
size="icon"
data-slot="coach-new-chat-fab"
// Steps aside while the on-screen keyboard is open (globals.css).
data-keyboard-hide=""
onClick={onNewChat}
aria-label={label}
title={label}
className={cn(
"absolute right-4 bottom-full z-10 mb-3 size-14 rounded-full shadow-lg sm:right-6",
// Beside the capped composer, in the column's bottom right corner.
"@min-[54rem]/composer:right-8 @min-[54rem]/composer:bottom-8 @min-[54rem]/composer:mb-0",
// The Coach button's face: a dark glyph on the brand gradient.
"from-primary to-brand-pink text-background bg-gradient-to-br",
"hover:from-primary/90 hover:to-brand-pink/90",
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
className,
)}
>
<Plus className="text-background size-6" aria-hidden="true" />
</Button>
);
}
@@ -1,139 +0,0 @@
"use client";
import { Sparkles, Target, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance.
*
* Two modes, one surface:
*
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
* metric page or an insight card). Instead of the old hidden composer
* prefill, render a small pill — "The Coach is already on <metric>" —
* plus a tappable seed question that drops the data-aware opener into
* the composer / sends it. This turns the built-but-invisible scope
* plumbing into the core ambient feeling.
*
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
* server surfaced today's single most notable derived signal. Render it
* as a tappable suggested opener so the blank box is never cold. When no
* signal crosses the gate the caller renders nothing and the neutral
* greeting stands — there is no fake opener.
*
* Presentational + self-contained: the parent supplies the resolved label
* and the seed question, and an `onSeed` callback wired to the composer.
* The badge owns no data fetching.
*/
export interface ScopeHintBadgeProps {
/**
* Which affordance this is. `scope` shows the "already on <metric>" pill;
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
* copy only — the tappable seed question is shared.
*/
variant: "scope" | "seeded";
/**
* The visible metric / signal label, already resolved to the user's
* locale by the caller (e.g. "blood pressure", "readiness").
*/
label: string;
/** The tappable seed question — the data-aware opener. */
question: string;
/** Fired with the seed question when the user taps the opener. */
onSeed: (question: string) => void;
/**
* Dismiss the opener. Only honoured for `variant="seeded"` — the seeded
* opener is a soft suggestion the user can wave off for the day, while the
* A2 scope pill reflects how the chat was launched and is not dismissable.
*/
onDismiss?: () => void;
className?: string;
}
export function ScopeHintBadge({
variant,
label,
question,
onSeed,
onDismiss,
className,
}: ScopeHintBadgeProps) {
const { t } = useTranslations();
const Icon = variant === "scope" ? Target : Sparkles;
const prefix =
variant === "scope"
? t("insights.coach.scope.prefix", { metric: label })
: t("insights.coach.seeded.prefix", { signal: label });
return (
<div
data-slot="coach-scope-hint"
data-variant={variant}
className={cn("flex w-full flex-col items-center gap-2.5", className)}
>
{/* The scope/signal pill — the visible "already on …" line. */}
<span
data-slot="coach-scope-hint-pill"
className={cn(
"border-border/60 bg-muted/40 text-muted-foreground",
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
"text-xs font-medium",
)}
>
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{prefix}</span>
</span>
{/* The tappable seed question — the data-aware opener. A real button so
keyboard + screen-reader users get the same affordance. v1.25.0 — the
opener sizes to its TEXT (intrinsic width, centred) instead of being
stretched to the full composer width, which left a large empty tail
that read as broken. It is guaranteed single-line: the question
truncates with an ellipsis on a narrow phone rather than wrapping or
forcing a horizontal scroll. The seeded variant keeps the dismiss
control at the trailing edge. */}
<div className="flex w-full items-center justify-center gap-1.5">
<button
type="button"
data-slot="coach-scope-hint-seed"
onClick={() => onSeed(question)}
className={cn(
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 truncate text-sm leading-snug">
{question}
</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground shrink-0 text-xs whitespace-nowrap"
>
{t("insights.coach.scope.tap")}
</span>
</button>
{variant === "seeded" && onDismiss ? (
<button
type="button"
data-slot="coach-scope-hint-dismiss"
onClick={onDismiss}
aria-label={t("insights.coach.seeded.dismiss")}
title={t("insights.coach.seeded.dismiss")}
className={cn(
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
"flex size-9 items-center justify-center rounded-xl transition-colors",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-4" aria-hidden="true" />
</button>
) : null}
</div>
</div>
);
}
@@ -34,7 +34,6 @@ interface CoachPrefsShape {
nudgeVitals: boolean;
nudgeRoutine: boolean;
nudgeFrequency: "weekly" | "biweekly";
ambientSuggestions: boolean;
nudgeAiComposed: boolean;
}
@@ -51,7 +50,6 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
};
@@ -306,28 +304,6 @@ export function CoachNudgeCard({
</div>
</div>
)}
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
push-nudge master switch above (a user can keep nudges but silence
the example prompts), so it sits outside the `enabled` gate and rides
the same `coach` prefs blob. */}
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<p className="text-sm">
{t("notifications.coachNudge.suggestionsLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("notifications.coachNudge.suggestionsDesc")}
</p>
</div>
<Switch
checked={resolved.ambientSuggestions}
onCheckedChange={(next) =>
patchCoach({ ambientSuggestions: next }, null)
}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.coachNudge.suggestionsAria")}
/>
</div>
{msg && (
<p
role="status"
@@ -1,34 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
/**
* v1.25.0 — the single client-side gate for proactive ambient Coach
* SUGGESTIONS (today: the daily seeded example opener on the Coach hero).
*
* Reads the per-user opt-out from the shared notification-prefs blob
* (`coach.ambientSuggestions`). Default ON: an unauthenticated read, a
* still-loading query, or a missing field all resolve to `true` so the
* suggestions show out of the box and only hide once the user explicitly turns
* them off in Settings. Shares the `authNotificationPrefs` query key with the
* settings card, so toggling the switch there flips this read in lockstep.
*/
interface AmbientSuggestionsPrefsShape {
coach?: { ambientSuggestions?: boolean };
}
export function useCoachAmbientSuggestionsEnabled(): boolean {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () =>
apiGet<AmbientSuggestionsPrefsShape>("/api/auth/me/notification-prefs"),
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
return data?.coach?.ambientSuggestions ?? true;
}
-23
View File
@@ -60,7 +60,6 @@ import {
insightsGeneratePostResponse,
coachReadQuery,
coachReadStripResponse,
coachSeededQuestionResponse,
narrativeQuery,
narrativeResponse,
glp1TimelineQuery,
@@ -399,28 +398,6 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/coach/seeded-question": {
get: {
tags: ["Insights"],
summary: "Today's suggested Coach opener",
description:
"Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; not delegable.",
responses: {
"200": {
description: "The opener, or the neutral null.",
content: {
"application/json": {
schema: dataEnvelope(
coachSeededQuestionResponse,
"CoachSeededQuestionEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/narrative": {
get: {
tags: ["Insights"],
@@ -2747,38 +2747,6 @@ export const coachReadStripResponse = z
"The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.",
});
// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ───────
export const coachSeededQuestionResponse = z
.object({
signal: z
.object({
sourceMetric: z
.string()
.describe(
"Sentinel id the client keys its localised copy on (`readiness` / `recovery`).",
),
score: z.number().describe("The latest 0..100 score."),
band: z
.string()
.describe(
"`yellow` or `red`. Green never surfaces — a good day is not notable.",
),
})
.nullable()
.describe(
"Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — all mean the hero keeps its neutral greeting rather than showing a fabricated opener.",
),
ai: aiCapabilityState.describe(
"The `coach` capability the opener exists for.",
),
})
.meta({
id: "CoachSeededQuestion",
description:
"Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.",
});
// ── Period narrative (`/api/insights/narrative`) ──────────────────────
export const narrativeQuery = z
-6
View File
@@ -76,12 +76,6 @@ export const coachKeys = {
messageId,
"trail",
] as const,
/**
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener
* (`GET /api/insights/coach/seeded-question`).
*/
coachSeededQuestion: () => ["coach-seeded-question"] as const,
/**
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
* reminders`). The optional `status` arg keys the in-app tile read
@@ -109,7 +109,6 @@ describe("parseNotificationPrefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -134,7 +133,6 @@ describe("parseNotificationPrefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -161,7 +159,6 @@ describe("parseNotificationPrefs", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -204,7 +201,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -235,7 +231,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -263,7 +258,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -291,7 +285,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -320,7 +313,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
-24
View File
@@ -125,14 +125,6 @@ const coachPrefsSchema = z
nudgeRoutine: z.boolean(),
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
nudgeFrequency: z.enum(["weekly", "biweekly"]),
/**
* v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the
* UI (the daily seeded example opener on the Coach hero + the "try asking"
* prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge
* master switch) so a user can keep the nudges but silence the example
* prompts, or vice versa.
*/
ambientSuggestions: z.boolean(),
/**
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
* nudge ships a deterministic warm localized template, and that template is
@@ -219,11 +211,6 @@ export interface NotificationPrefs {
nudgeRoutine: boolean;
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
nudgeFrequency: "weekly" | "biweekly";
/**
* v1.25.0 — proactive ambient example suggestions in the UI (seeded
* opener + suggested-prompt chips). Default `true`.
*/
ambientSuggestions: boolean;
/**
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
* deterministic template stays the default + fail-closed fallback.
@@ -285,7 +272,6 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
},
measurementReminder: {
@@ -483,16 +469,6 @@ export interface CoachNudgePrefs {
aiComposed: boolean;
}
/**
* v1.25.0 — the single source every proactive ambient SUGGESTION surface
* consults (the daily seeded example opener on the Coach hero + the
* suggested-prompt chips). Default `true`; a null / drifted row resolves to
* the documented default so the suggestions work out of the box.
*/
export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean {
return parseNotificationPrefs(raw).coach.ambientSuggestions;
}
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
const coach = parseNotificationPrefs(raw).coach;
return {