mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
commit
2803df5275
12 files changed
+143
-202
No files matched your search
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user