feat(coach): settings in composer menu, dismissable seeded opener

Add a Settings entry to the composer's "+" actions menu and remove the
page-toolbar gear; the drawer header keeps its own gear. Let the seeded
"worth a look" opener fill the composer column and give it a per-day
dismiss control. Route the Conversations action to /coach/conversations
and drop the in-page history drawer.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 11:23:47 +02:00
1 parent 7d2852a312
commit 5117bc603c
5 files changed
+184 -136

No files matched your search

@@ -1,7 +1,15 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// v1.21.4 (B) — the page surface routes "Conversations" to the dedicated
// `/coach/conversations` page via the app router, so the shared surface now
// reads `useRouter`. Stub it for the static-markup render.
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { CoachConversation } from "../coach-conversation";
import type {
@@ -136,30 +144,16 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
// v1.21.0 — the page toolbar is a single trailing affordance: the settings
// gear in the top-right corner. The "Conversations" + "New chat" buttons
// were removed from the toolbar (both still live in the composer `+` menu),
// and the settings gear moved here from the composer.
it("renders the settings gear in the top-right page toolbar", () => {
// v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the
// composer's `+` actions menu. The page chrome is the composer alone, so the
// surface exposes the `+` actions trigger and no standalone toolbar gear.
it("drops the page-toolbar gear and exposes the composer + actions menu", () => {
const html = render(<CoachConversation surface="page" />, makeClient());
expect(html).toContain('data-slot="coach-page-settings"');
const gear = html.match(/<a[^>]*data-slot="coach-page-settings"[^>]*>/);
expect(gear?.[0]).toContain('href="/settings/ai"');
// The old toolbar Conversations + New chat buttons are gone.
expect(html).not.toContain('data-slot="coach-page-conversations"');
expect(html).not.toContain('data-slot="coach-page-new-chat"');
});
// v1.21.0 — entering via the drawer handoff (`?view=conversations`) keeps
// the new-chat hero (no thread auto-resumed) while opening the history
// drawer; the toolbar gear stays present so the pane is never a blank
// dead-end.
it("keeps the hero and toolbar gear when opened with openHistoryOnMount", () => {
const html = render(
<CoachConversation surface="page" openHistoryOnMount />,
makeClient(),
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain('data-slot="coach-page-settings"');
// The old page toolbar + its gear are gone.
expect(html).not.toContain('data-slot="coach-page-settings"');
expect(html).not.toContain('data-slot="coach-page-toolbar"');
// Settings now lives behind the composer's `+` actions menu (the menu
// content is portalled open-on-demand, so SSR shows only the trigger).
expect(html).toContain('data-slot="coach-input-actions"');
});
});
@@ -71,6 +71,60 @@ describe("<ScopeHintBadge>", () => {
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("lets the seeded opener fill its column (no max-w-md cap)", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
// The opener grows to the composer column width; the old narrow cap is gone.
expect(html).not.toContain("max-w-md");
});
});
describe("<CoachHero> scope hint slot", () => {
@@ -2,17 +2,12 @@
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -93,6 +88,19 @@ export function launchScopeToCoachScope(
};
}
/**
* 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
@@ -175,13 +183,6 @@ export interface CoachConversationProps {
* "new chat" or thread switch is never overridden.
*/
autoOpenMostRecent?: boolean;
/**
* v1.19.1 (C5) — page surface only: open the conversation-history drawer
* once on mount. The drawer's "Conversations" handoff routes to
* `/coach?view=conversations`, which sets this so the user always sees the
* conversation list on arrival instead of a blank new-chat pane.
*/
openHistoryOnMount?: boolean;
}
export function CoachConversation({
@@ -198,26 +199,15 @@ export function CoachConversation({
surface,
initialConversationId,
autoOpenMostRecent = false,
openHistoryOnMount = false,
}: CoachConversationProps) {
const { t } = useTranslations();
const router = useRouter();
const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(initialConversationId ?? null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
// v1.18.11 (W11) — page surface only: conversation history is a LEFT
// slide-in drawer (overlay on every viewport, ChatGPT-style), opened from
// the composer's `+` actions menu. Replaces the old inline collapsible
// rail + top rail-tray strip; the top header bar is gone entirely on the
// page so the composer is the single control hub.
// v1.19.1 (C5) — seed the history drawer open when the page is entered via
// the drawer's "Conversations" handoff (`?view=conversations`), so the
// list is on screen immediately rather than a blank hero.
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(
() => openHistoryOnMount,
);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
@@ -425,7 +415,7 @@ export function CoachConversation({
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenHistory={() => router.push("/coach/conversations")}
/>
);
@@ -461,7 +451,19 @@ export function CoachConversation({
// it. When the server returns no signal the hint is null and the neutral
// greeting stands — never a fabricated opener.
const a2Metric = launchScope?.metric ?? null;
const seededEnabled = heroActive && a2Metric === 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;
}
});
const seededEnabled =
heroActive && a2Metric === null && !seededDismissedToday;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
@@ -474,6 +476,17 @@ export function CoachConversation({
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`.
function tOrNull(key: string): string | null {
@@ -507,7 +520,7 @@ export function CoachConversation({
onSeed={seedComposer}
/>
);
} else if (seeded?.signal) {
} else if (seeded?.signal && !seededDismissedToday) {
// 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.
@@ -523,6 +536,7 @@ export function CoachConversation({
label={signalLabel}
question={signalQuestion}
onSeed={seedComposer}
onDismiss={dismissSeeded}
/>
);
}
@@ -577,41 +591,6 @@ export function CoachConversation({
</div>
);
// v1.18.11 (W11) — page surface: the conversation history is a LEFT
// slide-in drawer (overlay on every viewport), opened from the composer's
// `+` actions menu. Reuses the shared `<HistoryRail>` + conversation-list
// query; respects `prefers-reduced-motion` (the Sheet primitive disables
// its slide there). Selecting a conversation closes the drawer.
const historyDrawer = (
<Sheet open={historyDrawerOpen} onOpenChange={setHistoryDrawerOpen}>
<SheetContent
side="left"
data-slot="coach-history-drawer"
className="w-[88vw] max-w-[340px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">
<HistoryRail
activeId={currentConversationId}
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided session;
// unanswered questions stay pending.
setCurrentConversationId(id);
setHistoryDrawerOpen(false);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
</div>
</SheetContent>
</Sheet>
);
// v1.18.11 (W11) — the PAGE surface drops the top header bar and the
// rail-tray strip entirely. The composer is the single control hub; the
// thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
@@ -626,32 +605,9 @@ export function CoachConversation({
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{/* v1.21.0 — the page toolbar is now a single trailing affordance:
the settings gear in the top-right corner. The "Conversations"
and "New chat" controls were removed here — both still live in the
composer's `+` actions menu, keeping the new-chat surface calm and
uncluttered. The gear deep-links to Settings → AI (one place for
model + behaviour), matching the drawer header's gear. */}
<div
data-slot="coach-page-toolbar"
className="flex shrink-0 items-center justify-end px-4 pt-2 sm:px-6"
>
<Button
asChild
variant="ghost"
size="icon"
data-slot="coach-page-settings"
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
</div>
{/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
in the composer's `+` actions menu alongside New chat and
Conversations, keeping the page chrome to the composer alone. */}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
@@ -675,7 +631,6 @@ export function CoachConversation({
</div>
</>
)}
{historyDrawer}
</div>
);
}
@@ -9,7 +9,16 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { Loader2, MessagesSquare, Mic, Plus, Send, Square } from "lucide-react";
import Link from "next/link";
import {
Loader2,
MessagesSquare,
Mic,
Plus,
Send,
Settings,
Square,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -479,6 +488,12 @@ export function CoachInput({
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
<Link href="/settings/ai">
<Settings className="size-4" aria-hidden="true" />
{t("insights.coach.settings")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1,6 +1,6 @@
"use client";
import { Sparkles, Target } from "lucide-react";
import { Sparkles, Target, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -43,6 +43,12 @@ export interface ScopeHintBadgeProps {
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;
}
@@ -51,6 +57,7 @@ export function ScopeHintBadge({
label,
question,
onSeed,
onDismiss,
className,
}: ScopeHintBadgeProps) {
const { t } = useTranslations();
@@ -84,26 +91,49 @@ export function ScopeHintBadge({
</span>
{/* The tappable seed question — the data-aware opener. A real button so
keyboard + screen-reader users get the same affordance. */}
<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",
"flex w-full max-w-md items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 flex-1 text-sm leading-snug">{question}</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
keyboard + screen-reader users get the same affordance. The opener
fills the column (= the composer's max-width) so it reads as one line
with the field below; the seeded variant adds a dismiss control at the
trailing edge, mirroring the composer's send affordance. */}
<div className="flex w-full items-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",
"flex min-w-0 flex-1 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.scope.tap")}
</span>
</button>
<span className="min-w-0 flex-1 text-sm leading-snug">
{question}
</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground 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-ring focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-4" aria-hidden="true" />
</button>
) : null}
</div>
</div>
);
}