mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge wave-coach: Claude/ChatGPT-style Coach frontend redesign
This commit is contained in:
commit
43388eabbf
13 files changed
+470
-127
No files matched your search
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user