Merge wave-coach: Claude/ChatGPT-style Coach frontend redesign

This commit is contained in:
Marc-André Bombeck committed 2026-06-19 19:48:33 +02:00
commit 43388eabbf
13 files changed
+470 -127

No files matched your search

+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Daten, die der Coach sieht",
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
"sourcesMemberCount": "{count} Werte"
"sourcesMemberCount": "{count} Werte",
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Data the Coach sees",
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
"sourcesMemberCount": "{count} metrics"
"sourcesMemberCount": "{count} metrics",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Datos que ve el Coach",
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
"sourcesMemberCount": "{count} métricas"
"sourcesMemberCount": "{count} métricas",
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Données vues par le Coach",
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
"sourcesMemberCount": "{count} mesures"
"sourcesMemberCount": "{count} mesures",
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Dati che il Coach vede",
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
"sourcesMemberCount": "{count} metriche"
"sourcesMemberCount": "{count} metriche",
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
+5 -1
View File
@@ -2264,7 +2264,11 @@
"settingsClustersLabel": "Dane widziane przez Coacha",
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
"sourcesMemberCount": "{count} wskaźników"
"sourcesMemberCount": "{count} wskaźników",
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -58,19 +58,34 @@ describe("<CoachDrawerBody> — rail-tray strip", () => {
expect(html).not.toContain('data-slot="coach-drawer-sources"');
});
it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => {
it("mounts a collapsible inline history column on lg+ when historyRail is passed (page surface)", () => {
// v1.18.7 W-coach C-UI — the inline rail is now collapsible and
// collapsed by default; the page surface passes `onToggleHistory`.
// The sub-lg tray trigger keeps `lg:hidden` (the bottom tray covers
// small viewports); a separate lg+ toggle (`coach-history-toggle`)
// shows the rail on desktop.
const html = render(
<CoachDrawerBody {...baseProps} historyRail={<span>history</span>} />,
<CoachDrawerBody
{...baseProps}
historyRail={<span>history</span>}
onToggleHistory={() => undefined}
/>,
);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toContain("history");
// The strip button collapses on lg+ where the inline column shows.
// Sub-lg tray trigger collapses on lg+ (the inline rail covers it).
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger?.[0]).toContain("lg:hidden");
// lg+ inline-rail toggle is present and labelled as a show control.
const railToggle = html.match(
/<button[^>]*data-slot="coach-history-toggle"[^>]*>/,
);
expect(railToggle).not.toBeNull();
expect(railToggle?.[0]).toContain('aria-expanded="false"');
});
it("renders the message thread + composer slots without a disclaimer line", () => {
@@ -11,21 +11,31 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<CoachInput>", () => {
it("mounts the textarea + send button slots (mic dropped in W5)", () => {
it("mounts the textarea + 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.
// v1.4.25 W5 — the non-functional mic icon was removed.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain('data-slot="coach-input"');
expect(html).toContain('data-slot="coach-input-textarea"');
expect(html).toContain('data-slot="coach-input-send"');
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).not.toContain('data-slot="coach-input-disclaimer"');
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.
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");
});
it("renders the localised placeholder without the retired shortcut hint", () => {
// v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an
// Info-popover) is gone entirely; the behaviour itself is
@@ -48,18 +58,6 @@ describe("<CoachInput>", () => {
expect(html).toContain("Frag mich etwas zu deinen Daten");
});
it("no longer renders a mic button (W5 removed the placeholder)", () => {
// v1.4.25 W5 — the mic icon used to be rendered + disabled with a
// "voice arrives with iOS" tooltip. The maintainer flagged it as a click-
// trap: nothing happened on tap. The composer now drops the icon
// entirely; voice input remains a v1.5 iOS feature.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).not.toMatch(/data-slot="coach-input-mic"/);
expect(html).not.toContain("Voice input arrives with the iOS app");
});
it("disables the send button when the value is empty", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
@@ -126,6 +126,10 @@ export function CoachConversation({
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
// v1.18.7 (W-coach C-UI) — page surface only: the inline conversation
// rail is collapsed by default for a calm, prompt-first surface. The
// rail-tray-strip toggle (lg+) opens it; the rail heading closes it.
const [historyRailOpen, setHistoryRailOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet
// offer to fold a clarifying-question answer back into the
@@ -359,6 +363,12 @@ export function CoachConversation({
</header>
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
@@ -1,8 +1,9 @@
"use client";
import { ChevronLeft, MessagesSquare } from "lucide-react";
import { ChevronLeft, MessagesSquare, PanelLeftClose } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -28,16 +29,37 @@ import { useTranslations } from "@/lib/i18n/context";
* (the in-panel left tray kept breaking inside the sheet), while
* the page renders the conversation list inline as a left column
* on lg+ via the `historyRail` slot (tray below lg).
*
* v1.18.7 (W-coach C-UI) — the inline history rail on the page surface
* is now COLLAPSIBLE and collapsed by default. The maintainer wanted a
* calm, prompt-first surface (Claude/ChatGPT-like) rather than a
* permanently-open list, and explicitly did not want the old top-left
* "open side panel" affordance lingering in fullscreen. The rail folds
* away to a zero-width column; a single clean toggle in the rail-tray
* strip (lg+) reveals it, and a matching close control sits in the rail
* heading. Below lg the conversation list stays a bottom tray.
*/
export interface CoachDrawerBodyProps {
thread: React.ReactNode;
composer: React.ReactNode;
/**
* Inline conversation list (page surface only). When set, it renders
* as a left column on lg+ and the history button collapses to the
* sub-lg tray trigger. The drawer omits it.
* as a collapsible left column on lg+ and the history button collapses
* to the sub-lg tray trigger. The drawer omits it.
*/
historyRail?: React.ReactNode;
/**
* v1.18.7 — page surface only: whether the inline history rail is
* expanded (lg+). Defaults to collapsed via the parent. Ignored when
* `historyRail` is omitted (the drawer surface).
*/
historyOpen?: boolean;
/**
* v1.18.7 — page surface only: toggles the inline rail open/closed.
* The rail-tray-strip control opens it; the rail-heading control
* closes it. Below lg the strip button still opens the bottom tray.
*/
onToggleHistory?: () => void;
onHistoryClick: () => void;
onOpenSourcesTray: () => void;
}
@@ -46,49 +68,75 @@ export function CoachDrawerBody({
thread,
composer,
historyRail,
historyOpen = false,
onToggleHistory,
onHistoryClick,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
// The page surface (inline `historyRail`) carries the collapsible
// grid; without a rail the body is a single column (drawer surface).
const railExpanded = !!historyRail && historyOpen;
return (
<div
data-slot="coach-drawer-body"
// v1.18.1 (W-COACH-UI C1/C3) — the inline history rail was a
// tight 260 px gutter; widen it to 300 px (lg) / 340 px (xl) so
// the conversation list breathes and the centre thread keeps the
// remaining width. Without a rail the body is a single column.
className={
historyRail
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[300px_1fr] xl:grid-cols-[340px_1fr]"
: "grid min-h-0 flex-1 grid-cols-1"
}
data-history-open={historyRail ? (historyOpen ? "true" : "false") : undefined}
// v1.18.7 — the inline rail column animates between 0 and a fixed
// width so collapsing it hands the full width back to the thread
// without a layout jump. Without a rail the body is a single
// column. `transition-[grid-template-columns]` keeps the fold
// smooth on the page surface; the thread column is always `1fr`.
className={cn(
"grid min-h-0 flex-1 grid-cols-1",
historyRail &&
"lg:grid-cols-[0px_1fr] lg:transition-[grid-template-columns] lg:duration-300 motion-reduce:lg:transition-none",
railExpanded && "lg:grid-cols-[320px_1fr]",
)}
>
{/* Inline history column — page surface, lg+ only. */}
{/* Inline history column — page surface, lg+ only, collapsible. */}
{historyRail && (
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
data-open={historyOpen ? "true" : "false"}
// Collapsed: zero-width + clipped so the list is fully hidden
// and non-interactive; expanded: a bordered left column. The
// `overflow-hidden` keeps the content from spilling during the
// width transition.
className={cn(
"hidden h-full min-h-0 overflow-hidden lg:flex lg:flex-col",
railExpanded
? "border-border/70 border-r opacity-100"
: "pointer-events-none opacity-0",
)}
aria-hidden={!historyOpen}
>
{/* v1.18.1 (W-COACH-UI C4) — the rail heading and the centre
column's rail-tray strip share one fixed `h-14` header band
with identical `border-b`, so the two dividers land on the
same horizontal line across the columns (the asymmetry the
maintainer flagged: a `p-3` text heading vs a `py-2` button
strip put the lines at different heights). */}
<h2 className="border-border/70 text-muted-foreground flex h-14 shrink-0 items-center border-b px-4 text-xs font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
{/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
was the broken left scrollbar: `<HistoryRail>` is itself a
`flex h-full flex-col` with its OWN `overflow-y-auto` list,
so this nested it inside a second scroll container. The
outer container scrolled the whole rail (search field
included) against an `h-full` child whose height resolved to
its content, leaving the inner list's `flex-1 min-h-0`
without a bounded track — the scrollbar rendered against an
indeterminate height and read as half-broken. Drop the outer
scroll: the rail keeps a fixed search header and its single
internal list-scroll is the only scroll region. */}
{/* v1.18.7 — the rail heading shares the `h-14` band + `border-b`
with the centre column's rail-tray strip so the two dividers
land on one horizontal line, and carries the collapse
control on its trailing edge. */}
<div className="border-border/70 flex h-14 shrink-0 items-center justify-between border-b px-4">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
{onToggleHistory && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleHistory}
data-slot="coach-history-collapse"
aria-label={t("insights.coach.hideConversations")}
title={t("insights.coach.hideConversations")}
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
>
<PanelLeftClose className="size-4" aria-hidden="true" />
</Button>
)}
</div>
{/* v1.18.6 (CCH-01) — `<HistoryRail>` is itself a `flex h-full
flex-col` with its OWN `overflow-y-auto` list, so a single
bounded flex track here is all it needs; do not nest a
second scroll container. */}
<div className="flex min-h-0 flex-1 flex-col">{historyRail}</div>
</aside>
)}
@@ -96,41 +144,72 @@ export function CoachDrawerBody({
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 min-w-0 flex-col"
>
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble.
v1.18.1 (W-COACH-UI C4) — fixed `h-14` so the strip's
bottom border aligns with the history rail heading divider
in the adjacent column (one continuous line, no restless
two-height split). */}
v1.18.7 — fixed `h-14` so the strip's bottom border aligns
with the history rail heading divider in the adjacent column.
On the page surface the left control is the rail TOGGLE
(clean show/hide) on lg+, falling back to the tray trigger
below lg; the drawer keeps the plain conversations button. */}
<div
data-slot="coach-drawer-rail-tray-strip"
// v1.18.1 (W-COACH-UI C4) — match the header band's `px-3 sm:px-4`
// inset so the rail-tray strip, the header, and the adjacent
// history-rail `<h2>` (px-4) all share the same horizontal divider
// line at sm+ rather than stepping in/out by a pixel.
className="border-border/70 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-3 sm:px-4"
>
<Button
type="button"
variant="ghost"
onClick={onHistoryClick}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
// With an inline history column (page surface, lg+) the
// button is redundant above lg and hides there.
className={
historyRail
? "min-h-11 gap-1.5 text-xs lg:hidden"
: "min-h-11 gap-1.5 text-xs"
}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Left control(s). On the page surface (inline rail) the
affordance forks by breakpoint: below lg a tray trigger
opens the bottom sheet; on lg+ a clean toggle shows/hides
the inline rail (and the lg+ toggle hides once the rail is
expanded, since the rail heading then owns the close
control). Without an inline rail (drawer surface) the
single button hands off via `onHistoryClick`. */}
{historyRail && onToggleHistory ? (
<>
{/* Sub-lg: open the bottom tray. */}
<Button
type="button"
variant="ghost"
onClick={onHistoryClick}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="text-muted-foreground hover:text-foreground min-h-11 gap-1.5 text-xs lg:hidden"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* lg+: toggle the inline rail (hidden while expanded). */}
<Button
type="button"
variant="ghost"
onClick={onToggleHistory}
data-slot="coach-history-toggle"
aria-label={t("insights.coach.showConversations")}
aria-expanded={historyOpen}
className={cn(
"text-muted-foreground hover:text-foreground hidden min-h-11 gap-1.5 text-xs lg:flex",
railExpanded && "lg:hidden",
)}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
</>
) : (
<Button
type="button"
variant="ghost"
onClick={onHistoryClick}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="text-muted-foreground hover:text-foreground min-h-11 gap-1.5 text-xs"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
)}
{/* Sources toggle — the rail is hidden by default on every
viewport and opens as the right-edge tray. */}
<Button
@@ -147,12 +226,7 @@ export function CoachDrawerBody({
</div>
{/* v1.18.6.1 — the thread wrapper must be a flex column itself so the
`<MessageThread>` scroll region resolves a bounded height. The
wrapper used to be a plain block, so the thread's `h-full` resolved
against an auto-height parent and the scroll container grew with its
content instead of scrolling — pushing the composer off-screen and
leaving a stray scrollbar on the page. `flex min-h-0 flex-1 flex-col`
gives the thread a definite track to scroll within. */}
`<MessageThread>` scroll region resolves a bounded height. */}
<div className="flex min-h-0 flex-1 flex-col">{thread}</div>
{/* Composer pinned to the bottom. */}
<div
@@ -6,8 +6,10 @@ import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { Loader2, Send, Square } from "lucide-react";
import { Loader2, Mic, Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -30,9 +32,14 @@ import { useTranslations } from "@/lib/i18n/context";
* out of the composer and into the sources rail footer, so the
* composer stays focused on the input affordance.
*
* v1.4.25 W5: dropped the non-functional mic icon. The voice-input
* affordance ships with the iOS client; surfacing a placeholder in the
* web composer drew clicks for an action that did nothing.
* 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.
*/
export interface CoachInputProps {
value: string;
@@ -102,6 +109,56 @@ export function computeAutoGrowHeight(args: {
const AUTO_GROW_MAX_LINES = 6;
/**
* v1.18.7 — resolve the browser's `SpeechRecognition` constructor
* (prefixed `webkit` on Chromium / Safari, unprefixed on the spec
* track). Returns `null` server-side and on browsers without the API
* (Firefox) so the mic control hides rather than rendering dead. Kept
* tiny + typed locally — the project ships no DOM lib entry for the
* Web Speech API.
*/
type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
onerror: (() => void) | null;
onend: (() => void) | null;
};
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: ArrayLike<
ArrayLike<{ transcript: string }> & { isFinal: boolean }
>;
};
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
if (typeof window === "undefined") return null;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support
// without a setState-in-effect: the server snapshot is `false` (no
// mic at SSR) and the client snapshot reflects whether the constructor
// exists. The store never changes after hydration, so the subscribe
// callback is a no-op.
const noopSubscribe = () => () => {};
function useVoiceSupported(): boolean {
return useSyncExternalStore(
noopSubscribe,
() => getSpeechRecognitionCtor() !== null,
() => false,
);
}
export function CoachInput({
value,
onChange,
@@ -113,7 +170,7 @@ export function CoachInput({
autoFocusOnOpen = false,
placeholder,
}: CoachInputProps) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
@@ -162,6 +219,80 @@ export function CoachInput({
el.style.height = `${height}px`;
}, [value]);
// v1.18.7 — voice dictation. The mic control only mounts once the
// client confirms the Web Speech API exists (resolved through
// `useSyncExternalStore`, server snapshot `false`), so SSR +
// unsupported browsers render no button at all — never a dead
// affordance. `onChange` / `value` are mirrored into refs (in an
// effect, never during render) so the long-lived recognition handlers
// always read the freshest composer state without re-subscribing.
const voiceSupported = useVoiceSupported();
const [listening, setListening] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
useEffect(() => {
onChangeRef.current = onChange;
valueRef.current = value;
});
// Tear the recogniser down on unmount so a live mic never outlives
// the composer (e.g. the drawer closing mid-dictation).
useEffect(() => {
return () => {
recognitionRef.current?.abort();
recognitionRef.current = null;
};
}, []);
const stopDictation = useCallback(() => {
recognitionRef.current?.stop();
}, []);
const startDictation = useCallback(() => {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = locale === "en" ? "en-US" : locale;
recognition.continuous = true;
recognition.interimResults = true;
// Anchor every transcript onto the text present when dictation
// began so interim results replace cleanly rather than stacking.
const base = valueRef.current;
recognition.onresult = (event) => {
let finalText = "";
let interimText = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const result = event.results[i];
const transcript = result[0]?.transcript ?? "";
if (result.isFinal) finalText += transcript;
else interimText += transcript;
}
const spoken = (finalText + interimText).trim();
if (!spoken) return;
const joiner = base && !base.endsWith(" ") ? " " : "";
onChangeRef.current(`${base}${joiner}${spoken}`);
};
recognition.onerror = () => setListening(false);
recognition.onend = () => {
setListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
try {
recognition.start();
setListening(true);
} catch {
// start() throws if already running; treat as a no-op.
setListening(false);
}
}, [locale]);
const toggleDictation = useCallback(() => {
if (listening) stopDictation();
else startDictation();
}, [listening, startDictation, stopDictation]);
const canSubmit = !disabled && value.trim().length > 0;
const handleKeyDown = useCallback(
@@ -188,20 +319,54 @@ export function CoachInput({
onSubmit={handleFormSubmit}
className="flex flex-col"
>
{/* v1.16.1 — modern chat-app composer: a single row with the
textarea and the send control on the same baseline. The
textarea starts at one line and auto-grows; `items-end` keeps
the button pinned to the input's last line as it grows. The
old footer row (Info-popover hint + right-aligned button) is
gone — Enter still sends and Shift+Enter still inserts a
newline, the affordance just no longer needs explaining. */}
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
field with the textarea flanked by a dictation mic (left) and
the send / stop control (right) on the same baseline.
`items-end` keeps the controls pinned to the input's last line
as it grows. Enter sends, Shift+Enter inserts a newline. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-md border",
"flex items-end gap-2 p-2 transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
"border-border/60 bg-muted/40 group rounded-2xl border",
"flex items-end gap-1.5 p-1.5 shadow-sm transition-colors",
"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")
}
className={cn(
"size-9 shrink-0 rounded-xl transition-colors",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
)}
<textarea
id={inputId}
ref={textareaRef}
@@ -233,8 +398,12 @@ export function CoachInput({
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the 36 px send
// button so the placeholder and the icon share a baseline.
"py-1.5",
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent] [scrollbar-width:thin]",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
@@ -251,7 +420,7 @@ export function CoachInput({
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-9 shrink-0"
className="size-9 shrink-0 rounded-xl"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
@@ -263,7 +432,7 @@ export function CoachInput({
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-9 shrink-0"
className="size-9 shrink-0 rounded-xl"
>
{isStreaming ? (
<Loader2
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { COACH_SCROLLBAR } from "./message-thread";
import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
/**
@@ -115,7 +116,11 @@ export function HistoryRail({
</div>
<div
data-slot="coach-history-list"
className="-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1",
// v1.18.7 — same thin/rounded/subtle scrollbar as the thread.
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
<p
@@ -135,6 +135,28 @@ export function placeInterleaved(
return { before, beforeOptimistic, tail };
}
/**
* v1.18.7 — shared thin/rounded/subtle scrollbar styling for the Coach
* scroll regions (the message thread + the history list). Kept as a
* Tailwind-arbitrary class string so the styling is component-scoped —
* the parallel agent owns `globals.css` and we must not touch it.
*
* Firefox: `scrollbar-width: thin` + a tinted thumb on a transparent
* track. WebKit: an 8 px overlay-style thumb with a fully rounded
* radius and no arrow buttons, brightening on hover. The Dracula purple
* is mixed down so the bar reads as a hairline accent, not a hard edge.
*/
export const COACH_SCROLLBAR = cn(
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_30%,transparent)_transparent] [scrollbar-width:thin]",
"[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:border-[3px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-clip-content",
"[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_30%,transparent)]",
"hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_45%,transparent)]",
);
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
@@ -365,15 +387,22 @@ export function MessageThread({
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
// v1.18.6 (CCH-01) — on the now full-width page surface a thread
// stretched edge-to-edge sprawled the prose to an unreadable
// measure. Centre the content on a comfortable max width (Claude-
// like) via an `mx-auto` inner gutter; the scrollbar still rides
// the surface edge. The drawer surface is already narrow, so the
// cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
// component-scoped via Tailwind arbitrary variants so globals.css
// stays untouched. Replaces the default boxy/angular track.
COACH_SCROLLBAR,
)}
>
{messages.map((m) => {
@@ -418,7 +447,15 @@ export function MessageThread({
// assistant prose announce as tokens land. aria-relevant=text
// limits announcements to the streamed content; additions
// covers the bubble-mount edge case.
<div role="log" aria-live="polite" aria-relevant="additions text">
<div
role="log"
aria-live="polite"
aria-relevant="additions text"
// v1.18.7 — a soft fade/slide-in on the assistant turn so the
// hand-off from the "Thinking…" beat to the first streamed
// tokens reads as one continuous motion, not a hard swap.
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300"
>
<ChatBubble
role="assistant"
content={streaming.content}
@@ -871,17 +908,28 @@ export function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
className="inline-flex items-center gap-1 py-1"
// v1.18.7 — show the word alongside the dots (Claude/ChatGPT-like
// "Thinking…") with a gentle pulse, so the brief pre-stream beat
// reads as deliberate rather than a frozen empty bubble. The dots
// bounce in sequence; the label keeps the screen-reader text.
className="text-muted-foreground inline-flex items-center gap-2 py-0.5"
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((i) => (
<span
key={i}
aria-hidden="true"
className="bg-muted-foreground/70 size-1.5 animate-pulse rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 250}ms` }}
/>
))}
<span aria-hidden="true" className="inline-flex items-center gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="bg-dracula-purple/70 size-1.5 animate-bounce rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
/>
))}
</span>
<span
aria-hidden="true"
className="animate-pulse text-xs motion-reduce:animate-none"
>
{label}
</span>
</span>
);
}