refactor(coach): drawer polish — Gravatar avatar, disclaimer move, cog removal

Three contained UX fixes that line up with the new prose-first
conversation feel:

- User bubble avatar pulls the user's Gravatar at the same 32-px
  dimensions as the Coach avatar; falls back to initials when no
  Gravatar is configured.
- Disclaimer ("Coach replies are generated. Clinical decisions belong
  with your doctor.") moves out of the composer and into the sources
  rail footer, replacing the now-redundant "Coach reads only your
  connected data" caption.
- Settings cog removed from the drawer header. The placeholder
  tooltip read as a dead button and a real settings surface lands in
  a later patch — better to drop the icon cleanly than wire it to
  nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 21:12:26 +02:00
1 parent bc87fa3904
commit b845cf5dae
7 files changed
+115 -66

No files matched your search

@@ -11,7 +11,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachInput>", () => {
it("mounts the textarea, mic, send button, and disclaimer slots", () => {
it("mounts the textarea, mic, and send button slots", () => {
// v1.4.22 B4 — the disclaimer moved to the sources rail; the
// composer no longer renders its own paragraph below the input.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
@@ -19,8 +21,8 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
expect(html).toContain('data-slot="coach-input-disclaimer"');
expect(html).toContain("Coach replies are generated");
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + hint", () => {
@@ -31,13 +33,12 @@ describe("<CoachInput>", () => {
expect(html).toContain("Press Enter to send");
});
it("renders the German placeholder + disclaimer when locale is 'de'", () => {
it("renders the German placeholder under the 'de' locale", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
"de",
);
expect(html).toContain("Frag mich etwas zu deinen Daten");
expect(html).toContain("Klinische Entscheidungen gehören in die Hand");
});
it("disables the mic button with the v1.5 tooltip text", () => {
@@ -1,7 +1,34 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The user bubble pulls `useAuth().user.gravatarUrl` to mirror the
// Coach avatar (v1.4.22 B3). SSR rendering through `renderToStaticMarkup`
// has no TanStack-Query provider, so we mock the hook the same way
// `recommendation-feedback.test.tsx` does — returning a tester user
// with no Gravatar so we exercise the initials fallback path by
// default, and override per-test where needed.
const useAuthMock = vi.fn<
() => {
user: {
id: string;
username: string;
role: string;
gravatarUrl: string | null;
};
isAuthenticated: boolean;
isLoading: boolean;
}
>(() => ({
user: { id: "test-user", username: "tester", role: "USER", gravatarUrl: null },
isAuthenticated: true,
isLoading: false,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => useAuthMock(),
}));
import { MessageThread } from "../message-thread";
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
@@ -319,4 +346,32 @@ describe("<MessageThread>", () => {
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
});
it("renders the user bubble with a Gravatar when one is set (B3 parity)", () => {
// v1.4.22 B3 — the user-bubble avatar uses the same dimensions
// as the Coach avatar and pulls the Gravatar URL from useAuth.
useAuthMock.mockReturnValueOnce({
user: {
id: "u-1",
username: "marc",
role: "USER",
gravatarUrl: "https://www.gravatar.com/avatar/abc123?s=64&d=mp",
},
isAuthenticated: true,
isLoading: false,
});
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(
/<img[^>]+src="https:\/\/www\.gravatar\.com\/avatar\/abc123/,
);
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*class="[^"]*size-8/);
});
it("falls back to initials when no Gravatar URL is present", () => {
// Default mock returns gravatarUrl: null → initials path.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
});
});
@@ -44,11 +44,10 @@ describe("<SourcesRail>", () => {
expect(html).toContain("Einnahmetreue");
});
it("renders the footer disclaimer", () => {
it("renders the medical disclaimer in the footer (relocated from the composer in v1.4.22)", () => {
const html = render(<SourcesRail />);
expect(html).toContain(
"The Coach reads only the data you&#x27;ve connected.",
);
expect(html).toContain('data-slot="coach-sources-disclaimer"');
expect(html).toContain("Clinical decisions belong with your doctor");
});
it("renders a checkbox per row + a window selector trigger", () => {
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { Plus, Settings2, Sparkles } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -11,12 +11,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -178,15 +172,13 @@ export function CoachDrawer({
"flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Title + new-chat button. Settings cog
sits next to the avatar on the LEFT — the previous layout
painted it next to the new-chat button on the right, where
Radix Sheet's default close-X (absolute top-4 right-4)
overlapped the cog and made it visually un-clickable. The
new layout keeps the cog inside the visible header column
and reserves the right edge for the close-X alone.
v1.4.20.1: pr-12 ensures the new-chat button never slides
under the close-X on narrower viewports either. */}
{/* Header (full width). Avatar + title + new-chat button.
v1.4.22 B5 — the settings cog was removed: it had no real
wiring and the matching v1.4.21 placeholder tooltip
("Coach settings arrive in v1.4.21") read as a dead button.
A real settings surface for per-user prompt-tuning lands
with v1.4.23. pr-12 keeps the new-chat button clear of the
Sheet's close-X on narrower viewports. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-3 border-b p-3 pr-12 sm:p-4 sm:pr-14"
@@ -197,29 +189,6 @@ export function CoachDrawer({
>
<Sparkles className="text-background size-4" />
</div>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
// Match the visible disabled state — sighted users
// see a coming-soon tooltip; SR users hear the same
// copy instead of a working "Coach settings" label.
aria-label={t("insights.coach.settingsTooltip")}
data-slot="coach-drawer-settings"
className="size-8 shrink-0"
>
<Settings2 className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("insights.coach.settingsTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-sm font-semibold">
{drawerTitle}
@@ -30,8 +30,9 @@ import { useTranslations } from "@/lib/i18n/context";
* with the iOS app in v1.5") because the natural shipper of voice is
* the native client, not the PWA.
*
* The disclaimer below the input is a hard requirement — Coach
* replies are not medical advice.
* v1.4.22 B4: the disclaimer ("Coach replies are generated …") moved
* out of the composer and into the sources rail footer, so the
* composer stays focused on the input affordance.
*/
export interface CoachInputProps {
value: string;
@@ -85,7 +86,7 @@ export function CoachInput({
<form
data-slot="coach-input"
onSubmit={handleFormSubmit}
className="flex flex-col gap-2"
className="flex flex-col"
>
<div
className={cn(
@@ -151,12 +152,6 @@ export function CoachInput({
</Button>
</div>
</div>
<p
data-slot="coach-input-disclaimer"
className="text-muted-foreground text-[11px]"
>
{t("insights.coach.composerDisclaimer")}
</p>
</form>
);
}
@@ -5,6 +5,7 @@ import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { SourceChips } from "./source-chips";
import type {
@@ -166,7 +167,16 @@ function ChatBubble({
errorCode,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
if (role === "user") {
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
// avatar matches the Coach avatar in size and visual weight.
// Falls back to the generic person glyph when the user hasn't
// configured an email (or the Gravatar lookup returned null).
const gravatarUrl = user?.gravatarUrl ?? null;
const initials = user?.username
? user.username.slice(0, 2).toUpperCase()
: null;
return (
<div
data-slot="coach-bubble-user"
@@ -181,12 +191,24 @@ function ChatBubble({
>
{content}
</div>
<div
aria-hidden="true"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full"
>
<User className="size-3.5" />
</div>
{gravatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={gravatarUrl}
alt=""
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="border-border/50 mt-0.5 size-8 shrink-0 rounded-full border object-cover"
/>
) : (
<div
aria-hidden="true"
data-slot="coach-bubble-user-avatar"
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"
>
{initials ?? <User className="size-3.5" />}
</div>
)}
</div>
);
}
@@ -275,13 +275,21 @@ export function SourcesRail({
);
})}
</ul>
<div className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3">
{/* v1.4.22 B4: the rail's footer now carries the medical
disclaimer (relocated from below the composer). The
source-picker section above stands on its own as the rail's
primary content — users see the disclaimer once, in a calm
place, instead of every time they look at the input. */}
<div
data-slot="coach-sources-disclaimer"
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
>
<Activity
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.sourcesFooter")}
{t("insights.coach.composerDisclaimer")}
</p>
</div>
</div>