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

@@ -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"