From b845cf5daec456440bc751b96c386441a0f021e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 21:12:26 +0200 Subject: [PATCH] =?UTF-8?q?refactor(coach):=20drawer=20polish=20=E2=80=94?= =?UTF-8?q?=20Gravatar=20avatar,=20disclaimer=20move,=20cog=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/coach-input.test.tsx | 11 ++-- .../__tests__/message-thread.test.tsx | 57 ++++++++++++++++++- .../__tests__/sources-rail.test.tsx | 7 +-- .../insights/coach-panel/coach-drawer.tsx | 47 +++------------ .../insights/coach-panel/coach-input.tsx | 13 ++--- .../insights/coach-panel/message-thread.tsx | 34 +++++++++-- .../insights/coach-panel/sources-rail.tsx | 12 +++- 7 files changed, 115 insertions(+), 66 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 753e79c75..660d8f3d5 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -11,7 +11,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe("", () => { - 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( {}} onSubmit={() => {}} />, ); @@ -19,8 +21,8 @@ describe("", () => { 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("", () => { 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( {}} 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", () => { diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index b18e6747e..9bad6d1b3 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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("", () => { expect(html).not.toContain('data-slot="coach-evidence"'); expect(html).not.toContain("What I'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(); + expect(html).toContain('data-slot="coach-bubble-user-avatar"'); + expect(html).toMatch( + /]+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(); + expect(html).toContain('data-slot="coach-bubble-user-avatar"'); + expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE", () => { 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(); - expect(html).toContain( - "The Coach reads only the data you'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", () => { diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index beac983fb..f54c8a44f 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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. */} - - - - - - - {t("insights.coach.settingsTooltip")} - - -
{drawerTitle} diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 205c116af..e7b5d21c9 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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({
-

- {t("insights.coach.composerDisclaimer")} -

); } diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index e89685f69..e9486d86d 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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 (
{content}
- + {gravatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} ); } diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index bbfdd7d17..bb84eccfc 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -275,13 +275,21 @@ export function SourcesRail({ ); })} -
+ {/* 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. */} +