merge(UI1): Coach new-chat composer redesign

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:16:45 +02:00
commit ad74d255e4
12 files changed
+137 -144

No files matched your search

+1 -1
View File
@@ -2279,7 +2279,7 @@
"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?",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Nachgedacht für {seconds}s",
+1 -1
View File
@@ -2279,7 +2279,7 @@
"dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Thought for {seconds}s",
+1 -1
View File
@@ -2279,7 +2279,7 @@
"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?",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Pensó durante {seconds}s",
+1 -1
View File
@@ -2279,7 +2279,7 @@
"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 ?",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
+1 -1
View File
@@ -2279,7 +2279,7 @@
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
+1 -1
View File
@@ -2279,7 +2279,7 @@
"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?",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Myślał przez {seconds}s",
@@ -136,24 +136,30 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
// v1.19.1 (C2) — a dedicated, always-visible Conversations button on the
// page surface (not buried in the composer `+` menu).
it("renders the dedicated Conversations button on the page surface", () => {
// v1.21.0 — the page toolbar is a single trailing affordance: the settings
// gear in the top-right corner. The "Conversations" + "New chat" buttons
// were removed from the toolbar (both still live in the composer `+` menu),
// and the settings gear moved here from the composer.
it("renders the settings gear in the top-right page toolbar", () => {
const html = render(<CoachConversation surface="page" />, makeClient());
expect(html).toContain('data-slot="coach-page-conversations"');
expect(html).toContain('data-slot="coach-page-new-chat"');
expect(html).toContain('data-slot="coach-page-settings"');
const gear = html.match(/<a[^>]*data-slot="coach-page-settings"[^>]*>/);
expect(gear?.[0]).toContain('href="/settings/ai"');
// The old toolbar Conversations + New chat buttons are gone.
expect(html).not.toContain('data-slot="coach-page-conversations"');
expect(html).not.toContain('data-slot="coach-page-new-chat"');
});
// v1.19.1 (C5) — entering via the drawer handoff (`?view=conversations`)
// keeps the new-chat hero (no thread auto-resumed) while opening the
// history drawer; the toolbar + button stay present so the pane is never
// a blank dead-end.
it("keeps the hero and toolbar when opened with openHistoryOnMount", () => {
// v1.21.0 — entering via the drawer handoff (`?view=conversations`) keeps
// the new-chat hero (no thread auto-resumed) while opening the history
// drawer; the toolbar gear stays present so the pane is never a blank
// dead-end.
it("keeps the hero and toolbar gear when opened with openHistoryOnMount", () => {
const html = render(
<CoachConversation surface="page" openHistoryOnMount />,
makeClient(),
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain('data-slot="coach-page-conversations"');
expect(html).toContain('data-slot="coach-page-settings"');
});
});
@@ -17,8 +17,10 @@ describe("<CoachHero>", () => {
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
expect(html).toContain("How can I help you?");
// Greeting copy from insights.coach.heroGreeting — one line now.
expect(html).toContain("Ask me anything about your data");
// The earlier two-line subline was dropped.
expect(html).not.toContain("Ask about your trends, medications");
// 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"');
@@ -26,7 +28,7 @@ describe("<CoachHero>", () => {
it("renders the German greeting under the de locale", () => {
const html = render(<CoachHero composer={null} />, "de");
expect(html).toContain("Wie kann ich dir helfen?");
expect(html).toContain("Frage mich etwas zu deinen Daten");
});
it("does not render starter-question suggestion chips", () => {
@@ -237,10 +237,11 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-send"');
});
it("renders the control-hub action row with showHub (page composer)", () => {
// v1.18.11 (W11) — the page composer is the control hub: a `+` actions
// menu (new chat + open conversations) and a settings deep-link sit on
// the action row alongside the mic + send.
it("renders the one-row page composer with showHub", () => {
// v1.21.0 — the page composer is ONE baseline row: a leading `+` actions
// menu (new chat + open conversations), the textarea, then mic + send.
// The settings gear moved OUT of the composer (now the page toolbar's
// top-right), so the composer's front is only the `+`.
const html = render(
<CoachInput
value=""
@@ -253,14 +254,18 @@ describe("<CoachInput>", () => {
);
expect(html).toContain('data-slot="coach-input-hub"');
expect(html).toContain('data-slot="coach-input-actions"');
// The settings gear deep-links to Settings → AI (not an in-chat sheet).
const settings = html.match(
/<a[^>]*data-slot="coach-input-settings"[^>]*>/,
);
expect(settings?.[0]).toContain('href="/settings/ai"');
// Mic + send remain present in the hub layout.
// The settings gear is gone from the composer entirely.
expect(html).not.toContain('data-slot="coach-input-settings"');
// Mic + send remain present on the same row.
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// The leading `+` precedes the textarea; the textarea precedes the send.
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
const sendIdx = html.indexOf('data-slot="coach-input-send"');
expect(plusIdx).toBeGreaterThan(-1);
expect(plusIdx).toBeLessThan(textareaIdx);
expect(textareaIdx).toBeLessThan(sendIdx);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
@@ -3,7 +3,7 @@
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MessagesSquare, Plus, Settings, Sparkles } from "lucide-react";
import { Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -539,35 +539,30 @@ export function CoachConversation({
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{/* v1.19.1 (C2) — a clear, always-visible "Conversations" button on
the page surface. The composer's `+` menu still carries the same
action, but the maintainer wanted an obvious, dedicated affordance
to reach past conversations rather than one buried in a menu. */}
{/* v1.21.0 — the page toolbar is now a single trailing affordance:
the settings gear in the top-right corner. The "Conversations"
and "New chat" controls were removed here — both still live in the
composer's `+` actions menu, keeping the new-chat surface calm and
uncluttered. The gear deep-links to Settings → AI (one place for
model + behaviour), matching the drawer header's gear. */}
<div
data-slot="coach-page-toolbar"
className="flex shrink-0 items-center justify-between gap-2 px-4 pt-2 sm:px-6"
className="flex shrink-0 items-center justify-end px-4 pt-2 sm:px-6"
>
<Button
type="button"
asChild
variant="ghost"
size="sm"
onClick={() => setHistoryDrawerOpen(true)}
data-slot="coach-page-conversations"
className="text-muted-foreground hover:text-foreground -ml-1 gap-1.5"
size="icon"
data-slot="coach-page-settings"
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNewChat}
data-slot="coach-page-new-chat"
className="text-muted-foreground hover:text-foreground -mr-1 gap-1.5"
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
</div>
{heroActive ? (
@@ -64,11 +64,13 @@ export function CoachHero({ composer }: CoachHeroProps) {
<Sparkles className="text-background size-6" />
</div>
{/* Greeting — a slightly larger, tighter display treatment of the
existing font (weight/tracking/size only, no new font). */}
{/* Greeting — one line. A slightly larger, tighter display
treatment of the existing font (weight/tracking/size only, no
new font). The earlier two-line subline was dropped: the
new-chat surface reads as a single calm invitation. */}
<div
className={cn(
"flex flex-col gap-1.5",
"flex flex-col",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "80ms", animationFillMode: "both" }}
@@ -76,9 +78,6 @@ export function CoachHero({ composer }: CoachHeroProps) {
<h1 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("insights.coach.heroGreeting")}
</h1>
<p className="text-muted-foreground text-sm">
{t("insights.coach.heroSubline")}
</p>
</div>
{/* Composer — the live <CoachInput>, centred. */}
@@ -9,16 +9,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import {
Loader2,
MessagesSquare,
Mic,
Plus,
Send,
Settings,
Square,
} from "lucide-react";
import { Loader2, MessagesSquare, Mic, Plus, Send, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -452,6 +443,46 @@ export function CoachInput({
</Button>
);
// v1.21.0 — the leading `+` actions menu (page surface only). Opens the
// attachment/scope menu: new chat + open conversations. The settings gear
// no longer lives here — it moved to the page toolbar's top-right corner,
// so the composer's front is ONLY the `+`. Const-lifted like the mic / send
// so it slots into the single-row composer without forking the layout.
const actionsButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<form
data-slot="coach-input"
@@ -459,26 +490,30 @@ export function CoachInput({
className="flex flex-col"
>
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
field. The drawer surface keeps the textarea flanked by the mic
(left) and send / stop (right) on one baseline. The page surface
(`showHub`) stacks the textarea over a control-hub action row so
the composer carries the conversation controls ChatGPT-style.
`items-end` keeps the single-row controls pinned to the input's
last line as it grows. Enter sends, Shift+Enter inserts a newline. */}
field, ONE baseline row. The drawer surface flanks the textarea with
the mic (left) and send / stop (right). The page surface (`showHub`)
leads with a `+` actions menu (new chat + open conversations), then
the textarea, then the mic + send — same row, vertically centred.
The settings gear is NOT in the composer; it lives in the page
toolbar's top-right corner. `items-end` keeps the flanking 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-2xl border",
"shadow-sm transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
showHub ? "flex flex-col gap-1 p-2" : "flex items-end gap-1.5 p-1.5",
// Both surfaces share ONE baseline row: the drawer flanks the
// textarea with mic (left) + send (right); the page leads with a
// `+` actions menu, then the textarea, then mic + send. `items-end`
// keeps the flanking controls pinned to the input's last line as it
// grows.
"flex items-end gap-1.5 p-1.5",
)}
>
{/* 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. In the hub
layout the mic moves into the action row below the textarea. */}
{!showHub && micButton}
{/* Leading control. Page surface: the `+` actions menu. Drawer
surface: the dictation mic. */}
{showHub ? actionsButton : micButton}
<textarea
id={inputId}
ref={textareaRef}
@@ -508,9 +543,9 @@ export function CoachInput({
// Desktop shrinks back to `text-sm` for the compact
// composer.
"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 send button so
// the placeholder and the icon share a baseline.
showHub ? "px-2 pt-1.5 pb-0.5" : "px-2 py-1.5",
// Centre the single-line state against the flanking controls so
// the placeholder and the icons share a baseline.
"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
@@ -520,64 +555,15 @@ export function CoachInput({
)}
/>
{showHub ? (
// v1.18.11 — the control-hub action row (page surface). Leading
// `+` actions menu (new chat + open conversations) and a settings
// deep-link on the left; the mic + send / stop on the right —
// all on one baseline, ChatGPT-style.
<div data-slot="coach-input-hub" className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* The Coach preferences live in Settings → AI (one place for
model + behaviour); the gear deep-links there. */}
<Button
asChild
size="icon"
variant="ghost"
data-slot="coach-input-settings"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
<div className="ml-auto flex items-center gap-1">
{micButton}
{sendButton}
</div>
// v1.21.0 — page surface trailing cluster: mic + send / stop on the
// same baseline as the leading `+` and the textarea. The settings
// gear moved out of the composer entirely (now in the page toolbar).
<div
data-slot="coach-input-hub"
className="flex shrink-0 items-center gap-1.5"
>
{micButton}
{sendButton}
</div>
) : (
sendButton