mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
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'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'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>
|
||||
|
||||
Reference in new issue
Block a user