From 01b73f6379be15bc1d90edb73e3513385f9f928d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:41:58 +0200 Subject: [PATCH 1/2] feat(coach): make the full Coach page a single full-bleed surface Drop the inner bordered card on the dedicated /coach page so the chat reads as one continuous conversation rather than a window inside a window, and remove the top "back to the drawer" minimize control (it is pointless on the full page; the bottom-right FAB drawer remains the separate compact surface). Also remove the two starter-question suggestion chips below the new-chat composer, leaving a greeting + composer hero that matches the reference new-chat layout. History stays reachable through the existing conversation rail. --- src/app/coach/page.tsx | 99 ++++++------------- .../coach-panel/__tests__/coach-hero.test.tsx | 27 ++--- .../coach-panel/coach-conversation.tsx | 14 +-- .../insights/coach-panel/coach-hero.tsx | 68 +++---------- 4 files changed, 56 insertions(+), 152 deletions(-) diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 9fdbe3462..1fe255a99 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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 `` 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 `` (which provides the `` - * 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 ``. + 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 (
`'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: + // `` 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" > -
- ( -

{title}

- )} - leadingHeaderActions={ - <> - - - } - /> -
+ ( +

{title}

+ )} + />
); } diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx index 98b2dd6af..7c36b6892 100644 --- a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -12,13 +12,9 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe("", () => { - it("renders the centred greeting, the composer slot, and the chips", () => { + it("renders the centred greeting and the composer slot", () => { const html = render( - composer} - prompts={["What should I tell my doctor?", "Is my medication working?"]} - onPickPrompt={() => {}} - />, + composer} />, ); expect(html).toContain('data-slot="coach-hero"'); // Greeting copy from insights.coach.heroGreeting. @@ -26,26 +22,19 @@ describe("", () => { // 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( - {}} />, - "de", - ); + const html = render(, "de"); expect(html).toContain("Wie kann ich dir helfen?"); }); - it("omits the chip row when no prompts are supplied", () => { - const html = render( - {}} />, - ); + 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(); expect(html).not.toContain('data-slot="coach-hero-chips"'); + expect(html).not.toContain('data-slot="coach-hero-chip"'); }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index f5107c9f0..cbe6afae1 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 (
{heroActive ? ( - handleSubmit(prompt)} - /> + ) : ( `). + * 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 + * ``). * * The hero does NOT fork composer logic: it RECEIVES the live * `` 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 `` 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}
- - {/* Starter-question chips, seeded with the Coach's own prompts. */} - {prompts.length > 0 && ( -
- {prompts.map((prompt) => ( - - ))} -
- )} ); From 398f5c3b6bfc269ed7205c3f288305eb75ea7669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:42:10 +0200 Subject: [PATCH 2/2] fix(coach): make the composer mic reliable and never a dead tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always render the dictation mic so the affordance stays discoverable. On a browser without the Web Speech API (or during SSR) render it disabled with an explanatory tooltip instead of hiding it or leaving a control that silently does nothing on tap. When a dictation start fails — denied microphone permission, an insecure-context self-host, or no network for the recogniser — surface a quiet toast rather than resetting in silence. Add voiceUnsupported + dictateError strings across all locales. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../__tests__/coach-input.test.tsx | 19 ++-- .../insights/coach-panel/coach-input.tsx | 106 +++++++++++------- 8 files changed, 87 insertions(+), 50 deletions(-) diff --git a/messages/de.json b/messages/de.json index 40d7ddbd8..17022908c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index 54241ad76..2f03a8198 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 4b8561ed3..5612ce688 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index 71ec8f395..fd4b6835b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index 712b5c259..1b26acae6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index 832a4d5d2..4b7dc46f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", 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 ea73deed7..eb15e9b62 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -24,16 +24,21 @@ describe("", () => { 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( {}} 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(/]*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", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 8a759259c..647f06a01 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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 & { 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 && ( - - )} + aria-hidden="true" + /> +