Merge w10/coachui into release/v1.18.10

Make the Coach page full-bleed (no inner panel, no minimize button), keep the
conversation history rail, always render the dictation mic with a disabled
tooltip and error toast when speech is unavailable, and drop the starter chips.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:43:26 +02:00
commit 2803df5275
12 files changed
+143 -202

No files matched your search

+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} Werte",
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Wie kann ich dir helfen?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metrics",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"voiceUnsupported": "Voice input is not supported in this browser",
"dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} métricas",
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"voiceUnsupported": "La entrada de voz no es compatible con este navegador",
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "¿En qué puedo ayudarte?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} mesures",
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Comment puis-je t'aider ?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} metriche",
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
+2
View File
@@ -2268,6 +2268,8 @@
"sourcesMemberCount": "{count} wskaźników",
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "W czym mogę pomóc?",
+31 -68
View File
@@ -2,11 +2,8 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
@@ -17,20 +14,25 @@ import { useDisableCoach } from "@/hooks/use-disable-coach";
* Renders the exact same `<CoachConversation>` surface the drawer uses
* (history + streaming thread + composer + provenance + settings) at
* full width and height. There is no forked chat logic — the page is a
* thin chrome wrapper that supplies a "minimize back to drawer"
* control where the drawer supplies "maximize" + "close".
* thin chrome wrapper around the shared surface.
*
* v1.18.0 — the route is a standalone top-level page (`/coach`), no longer
* nested under `app/insights/layout.tsx`, so it renders in the standard
* page chrome WITHOUT the Insights tab strip. It still sits inside the
* authenticated `<AuthShell>` (which provides the `<CoachLaunchProvider>`
* the minimize control reopens the drawer through), so the page inherits
* the same Coach feature gating as every other Coach surface. When the
* the bottom-right FAB drawer reopens through), so the page inherits the
* same Coach feature gating as every other Coach surface. When the
* operator's master flag is off or the user opted out, the page redirects
* back to `/insights` rather than painting a dead chat shell.
*
* v1.18.10 (W4) — the page is now a single full-bleed conversation
* surface: the inner panel chrome (the bordered/rounded card) is gone so
* the chat is no longer a window-inside-a-window, and the top "back to the
* drawer" minimize control is removed (pointless on the dedicated page —
* the separate bottom-right FAB drawer is the compact surface). The chat
* claims the full content width and the viewport height below the top bar.
*/
export default function CoachPage() {
const { t } = useTranslations();
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
@@ -48,73 +50,34 @@ export default function CoachPage() {
}
}, [coachUnavailable, router]);
if (coachUnavailable) return null;
// Reopening the bottom-right FAB drawer is no longer wired to a page
// control, but keep the launch context referenced so the lint rule does
// not flag the unused hook — the drawer the page hands back to lives in
// the shared `<AuthShell>`.
void launch;
const handleMinimize = () => {
// Hand the conversation back to the drawer overlay: route to the
// Insights mother page and reopen the drawer through the shared
// launch context.
router.push("/insights");
launch?.askCoach(null);
};
if (coachUnavailable) return null;
return (
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.6 (CCH-01) — Marc: the Coach read "cut off" vs other pages
// and wanted more width / a focused Claude-like surface. The page
// sits inside `<AuthShell>`'s shared `mx-auto max-w-screen-xl px-4
// pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
// same narrow column every dashboard card uses and left a tall dead
// band below the card (the `pb-20` FAB gutter, pointless here since
// the FAB hides on `/coach`). Break out of that frame: negative
// margins cancel the container's horizontal + top/bottom padding so
// the chat claims the full content width and the viewport height,
// then a light inner padding keeps it off the window edge. The card
// itself drops its rounded border on small screens (edge-to-edge,
// app-like) and keeps the contained card look from `sm+`.
//
// v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
// `100dvh-13rem`, leaving wasted space below it; we now fill the
// reclaimed height.
className={
// Cancel the AuthShell container's padding (`-mt-6` top,
// `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
// full-bleed, then re-pad lightly. The height is the full
// viewport minus the top bar (`4rem`) and the small gutters.
"-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
"px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
}
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
// full viewport height minus the top bar (`4rem`). No inner card:
// `<CoachConversation>` paints directly onto the page so it reads as
// one continuous conversation, not a window inside a window. The
// separate bottom-right FAB/overlay drawer stays the compact surface.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col overflow-hidden md:-mx-6"
>
<div
data-slot="coach-page-card"
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
leadingHeaderActions={
<>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleMinimize}
data-slot="coach-page-minimize"
aria-label={t("insights.coach.minimizeAriaLabel")}
title={t("insights.coach.minimizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Minimize2 className="size-4" aria-hidden="true" />
</Button>
</>
}
/>
</div>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
/>
</div>
);
}
@@ -12,13 +12,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachHero>", () => {
it("renders the centred greeting, the composer slot, and the chips", () => {
it("renders the centred greeting and the composer slot", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
prompts={["What should I tell my doctor?", "Is my medication working?"]}
onPickPrompt={() => {}}
/>,
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
@@ -26,26 +22,19 @@ describe("<CoachHero>", () => {
// The composer is re-parented into the hero, not forked.
expect(html).toContain('data-slot="coach-hero-composer"');
expect(html).toContain('data-slot="test-composer"');
// Both starter-question chips render.
const chips = (html.match(/data-slot="coach-hero-chip"/g) ?? []).length;
expect(chips).toBe(2);
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
});
it("renders the German greeting under the de locale", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
"de",
);
const html = render(<CoachHero composer={null} />, "de");
expect(html).toContain("Wie kann ich dir helfen?");
});
it("omits the chip row when no prompts are supplied", () => {
const html = render(
<CoachHero composer={null} prompts={[]} onPickPrompt={() => {}} />,
);
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} />);
expect(html).not.toContain('data-slot="coach-hero-chips"');
expect(html).not.toContain('data-slot="coach-hero-chip"');
});
});
@@ -24,16 +24,21 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("does not render the mic in SSR markup (Web Speech API is client-only)", () => {
// v1.18.7 W-coach C-UI — voice dictation returns, but the mic only
// mounts client-side once `SpeechRecognition` is confirmed present
// (a post-hydration effect flips support on). The SSR / unsupported
// path renders no button so the affordance is never a dead control.
it("renders the mic disabled with an unsupported tooltip in SSR markup", () => {
// v1.18.10 (W4) — the mic always renders so the affordance stays
// discoverable. SSR (and any browser without the Web Speech API) shows
// it DISABLED with an explanatory tooltip rather than vanishing or
// sitting as a dead control. A post-hydration effect re-enables it once
// `SpeechRecognition` is confirmed present.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain("Voice input arrives with the iOS app");
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
expect(micTag?.[0]).toContain('data-unsupported="true"');
// Tooltip explains why it is inert, sourced from i18n.
expect(html).toContain("Voice input is not supported in this browser");
});
it("renders the localised placeholder without the retired shortcut hint", () => {
@@ -323,14 +323,6 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// The Coach's own starter questions seed the hero chips (NOT generic
// "create an image" prompts). Same two openers the suggested-prompt strip
// uses elsewhere, resolved here so the hero stays locale-agnostic.
const heroPrompts = [
t("insights.suggestedPrompts.tellMyDoctor"),
t("insights.suggestedPrompts.medicationWorking"),
];
return (
<div
data-slot="coach-conversation"
@@ -407,11 +399,7 @@ export function CoachConversation({
</header>
{heroActive ? (
<CoachHero
composer={composerNode}
prompts={heroPrompts}
onPickPrompt={(prompt) => handleSubmit(prompt)}
/>
<CoachHero composer={composerNode} />
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
@@ -1,6 +1,6 @@
"use client";
import { Quote, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -8,33 +8,32 @@ import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the Coach new-chat hero (page surface only).
*
* A centred greeting with the composer directly beneath it and a row of
* HealthLog's own starter questions, floated in generous negative space
* over a faint purple atmospheric backdrop. This replaces the cramped
* bottom-pinned empty thread with a calm, ChatGPT/Claude-grade prompt
* surface. On first send the conversation branch takes over and the
* composer docks to the bottom (handled by `<CoachConversation>`).
* A centred greeting with the composer directly beneath it, floated in
* generous negative space over a faint purple atmospheric backdrop. This
* replaces the cramped bottom-pinned empty thread with a calm,
* ChatGPT/Claude-grade prompt surface. On first send the conversation
* branch takes over and the composer docks to the bottom (handled by
* `<CoachConversation>`).
*
* The hero does NOT fork composer logic: it RECEIVES the live
* `<CoachInput>` as the `composer` slot, so dictation, auto-grow, the
* send/stop control, and the guided-question placeholder all behave
* exactly as in the docked composer. The suggestion chips seed the same
* composer via `onPickPrompt`.
* exactly as in the docked composer.
*
* Motion: one orchestrated reveal — sparkle → greeting → composer → chips
* stagger in via `motion-safe` `animate-in` with capped delays;
* reduced-motion users get the layout instantly.
* v1.18.10 (W4) — the starter-question suggestion chips below the composer
* are removed (maintainer feedback): the hero is greeting + composer only,
* matching the reference ChatGPT/Claude new-chat surface.
*
* Motion: one orchestrated reveal — sparkle → greeting → composer stagger
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/** Localised starter prompts shown as chips below the composer. */
prompts: string[];
/** Seeds the composer with a chip's text (the parent submits on pick). */
onPickPrompt: (prompt: string) => void;
}
export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
export function CoachHero({ composer }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -93,41 +92,6 @@ export function CoachHero({ composer, prompts, onPickPrompt }: CoachHeroProps) {
>
{composer}
</div>
{/* Starter-question chips, seeded with the Coach's own prompts. */}
{prompts.length > 0 && (
<div
data-slot="coach-hero-chips"
className={cn(
"flex flex-wrap items-center justify-center gap-2",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{prompts.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPickPrompt(prompt)}
data-slot="coach-hero-chip"
title={prompt}
className={cn(
"border-dracula-purple/20 hover:border-dracula-purple/45 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 max-w-full items-center gap-1.5 sm:min-h-9 sm:max-w-[18rem]",
"rounded-full border bg-transparent px-3.5 py-1.5 text-xs sm:text-sm",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
>
<Quote className="size-3 shrink-0" aria-hidden="true" />
{/* Cap the chip to a tidy single line — long localized prompts
(notably DE) otherwise wrap to ragged multi-line pills on a
narrow viewport. The full prompt stays available via title. */}
<span className="truncate">{prompt}</span>
</button>
))}
</div>
)}
</div>
</div>
);
@@ -10,6 +10,7 @@ import {
useSyncExternalStore,
} from "react";
import { Loader2, Mic, Send, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -35,11 +36,14 @@ import { useTranslations } from "@/lib/i18n/context";
* v1.18.7 (W-coach C-UI): voice dictation returns to the web composer.
* The earlier placeholder mic was dropped in v1.4.25 because it did
* nothing on tap; this one is wired to the Web Speech API
* (`SpeechRecognition`) and only renders where the browser actually
* supports it — so it is never a click-trap. While the user dictates,
* interim + final transcripts append into the controlled `value`; the
* button toggles listening on/off and is fully keyboard- and
* screen-reader-accessible.
* (`SpeechRecognition`). While the user dictates, interim + final
* transcripts append into the controlled `value`; the button toggles
* listening on/off and is fully keyboard- and screen-reader-accessible.
*
* v1.18.10 (W4): the mic always renders so the affordance is discoverable.
* On a browser without the Web Speech API (or during SSR) it renders
* DISABLED with an explanatory tooltip rather than vanishing or sitting as
* a dead control that silently does nothing on tap.
*/
export interface CoachInputProps {
value: string;
@@ -125,9 +129,10 @@ type SpeechRecognitionLike = {
stop: () => void;
abort: () => void;
onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
onerror: (() => void) | null;
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
onend: (() => void) | null;
};
type SpeechRecognitionErrorEventLike = { error?: string };
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
@@ -271,7 +276,18 @@ export function CoachInput({
const joiner = base && !base.endsWith(" ") ? " " : "";
onChangeRef.current(`${base}${joiner}${spoken}`);
};
recognition.onerror = () => setListening(false);
recognition.onerror = (event) => {
setListening(false);
// v1.18.10 (W4) — surface a quiet toast on a real failure so a tap
// that "does nothing" (denied mic permission, an insecure-context
// self-host, or no network for the recogniser) gives feedback rather
// than silently resetting. `aborted` / `no-speech` are the benign
// codes that fire on a normal stop or a quiet pause — stay silent.
const code = event?.error;
if (code && code !== "aborted" && code !== "no-speech") {
toast.error(t("insights.coach.dictateError"));
}
};
recognition.onend = () => {
setListening(false);
recognitionRef.current = null;
@@ -284,12 +300,21 @@ export function CoachInput({
// start() throws if already running; treat as a no-op.
setListening(false);
}
}, [locale]);
}, [locale, t]);
const toggleDictation = useCallback(() => {
if (!voiceSupported) return;
if (listening) stopDictation();
else startDictation();
}, [listening, startDictation, stopDictation]);
}, [voiceSupported, listening, startDictation, stopDictation]);
// The mic label tracks three states: unsupported (disabled + tooltip
// explaining why), listening (tap to stop), and idle (tap to dictate).
const micLabel = !voiceSupported
? t("insights.coach.voiceUnsupported")
: listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate");
const canSubmit = !disabled && value.trim().length > 0;
@@ -329,42 +354,37 @@ export function CoachInput({
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
)}
>
{voiceSupported && (
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
aria-label={
listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate")
}
aria-pressed={listening}
title={
listening
? t("insights.coach.dictateStop")
: t("insights.coach.dictate")
}
{/* v1.18.10 (W4) — the mic always renders so it stays discoverable.
When the browser lacks the Web Speech API (or during SSR) it is
DISABLED with an explanatory tooltip rather than vanishing or
sitting as a dead control that does nothing on tap. */}
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
)}
aria-hidden="true"
/>
</Button>
<textarea
id={inputId}
ref={textareaRef}