Merge w11/coachux into release/v1.18.11

Rework the Coach page to a ChatGPT-style IA: drop the top header, make the
composer the control hub (+ actions, settings, mic), open history as a left
slide-in drawer, and keep the composer one constant width across new-chat and
active conversation. The FAB drawer surface is untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 18:58:03 +02:00
commit d91cb35569
10 files changed
+429 -169

No files matched your search

+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"actionsMenu": "Aktionen",
"maximizeAriaLabel": "Im Vollbild öffnen",
"minimizeAriaLabel": "Zurück zum Seitenfenster",
"send": "Senden",
+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"actionsMenu": "Actions",
"maximizeAriaLabel": "Open in full page",
"minimizeAriaLabel": "Back to the drawer",
"send": "Send",
+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
"actionsMenu": "Acciones",
"maximizeAriaLabel": "Abrir en pantalla completa",
"minimizeAriaLabel": "Volver al panel",
"send": "Enviar",
+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
"actionsMenu": "Actions",
"maximizeAriaLabel": "Ouvrir en pleine page",
"minimizeAriaLabel": "Revenir au panneau",
"send": "Envoyer",
+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
"actionsMenu": "Azioni",
"maximizeAriaLabel": "Apri a pagina intera",
"minimizeAriaLabel": "Torna al pannello",
"send": "Invia",
+1
View File
@@ -2082,6 +2082,7 @@
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
"actionsMenu": "Działania",
"maximizeAriaLabel": "Otwórz na pełnej stronie",
"minimizeAriaLabel": "Powrót do panelu",
"send": "Wyślij",
+10 -7
View File
@@ -31,6 +31,15 @@ import { useDisableCoach } from "@/hooks/use-disable-coach";
* drawer" minimize control is removed (pointless on the dedicated page —
* the separate bottom-right FAB drawer is the compact surface). The chat
* claims the full content width and the viewport height below the top bar.
*
* v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar
* AND the top conversation-history strip entirely. The composer is the
* single control hub (leading `+` actions menu = new chat + open
* conversations, settings deep-link, mic, send); conversation history is a
* left slide-in drawer opened from that menu. The composer keeps one
* constant, centred, max-width-capped column across the new-chat hero and
* the active conversation. All of that lives in `<CoachConversation>`'s
* page branch, so the page stays a thin gating + sizing wrapper.
*/
export default function CoachPage() {
const router = useRouter();
@@ -79,13 +88,7 @@ export default function CoachPage() {
// is hidden, so the page reclaims the full height below the TopBar.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
/>
<CoachConversation surface="page" autoFocusComposer />
</div>
);
}
@@ -222,6 +222,65 @@ describe("<CoachInput>", () => {
expect(handler).toHaveBeenCalledWith("typed");
});
it("omits the control-hub action row by default (drawer composer)", () => {
// v1.18.11 (W11) — the hub is page-only; without `showHub` the composer
// stays the single-row drawer layout (mic + send on one baseline) and
// grows no actions menu or settings deep-link.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-input-hub"');
expect(html).not.toContain('data-slot="coach-input-actions"');
expect(html).not.toContain('data-slot="coach-input-settings"');
// Mic + send still render in the single-row layout.
expect(html).toContain('data-slot="coach-input-mic"');
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.
const html = render(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
/>,
);
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.
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
const html = render(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
/>,
);
const actions = html.match(
/<button[^>]*data-slot="coach-input-actions"[^>]*>/,
);
expect(actions?.[0]).toMatch(/\bsize-11\b/);
expect(actions?.[0]).toMatch(/\bsm:size-9\b/);
});
it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => {
// v1.4.25 W5 — Claude-web-style auto-grow. SSR baseline is a
// single-line textarea; the client-side `useEffect` measures
@@ -7,6 +7,12 @@ import { Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -127,10 +133,12 @@ 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);
// v1.18.11 (W11) — page surface only: conversation history is a LEFT
// slide-in drawer (overlay on every viewport, ChatGPT-style), opened from
// the composer's `+` actions menu. Replaces the old inline collapsible
// rail + top rail-tray strip; the top header bar is gone entirely on the
// page so the composer is the single control hub.
const [historyDrawerOpen, setHistoryDrawerOpen] = 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
@@ -288,6 +296,11 @@ export function CoachConversation({
// hero (centred) OR docked at the bottom of the conversation without
// forking any composer logic (dictation, auto-grow, send/stop, the
// guided-question placeholder all behave identically in both spots).
//
// v1.18.11 (W11) — on the page surface the composer is the control hub:
// it grows a leading `+` actions menu (new chat + open conversations) and
// a settings deep-link. The drawer keeps its own header for those, so the
// hub is page-only.
const composerNode = (
<CoachInput
value={inputValue}
@@ -302,6 +315,9 @@ export function CoachConversation({
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => setHistoryDrawerOpen(true)}
/>
);
@@ -323,6 +339,132 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
// the guided-questions entry card stack above the live composer. Shared by
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
// directly), so the two surfaces stay byte-identical above the composer.
const composerStack = (
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a clarifying
question has been answered. Self-removes after settle; v1.16.5
reports the outcome back into the guided machine for the closing
summary. */}
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of the
v1.16.0 chips). Offers the in-chat sequence while questions pend
and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
);
// v1.18.11 (W11) — page surface: the conversation history is a LEFT
// slide-in drawer (overlay on every viewport), opened from the composer's
// `+` actions menu. Reuses the shared `<HistoryRail>` + conversation-list
// query; respects `prefers-reduced-motion` (the Sheet primitive disables
// its slide there). Selecting a conversation closes the drawer.
const historyDrawer = (
<Sheet open={historyDrawerOpen} onOpenChange={setHistoryDrawerOpen}>
<SheetContent
side="left"
data-slot="coach-history-drawer"
className="w-[88vw] max-w-[340px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">
<HistoryRail
activeId={currentConversationId}
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided session;
// unanswered questions stay pending.
setCurrentConversationId(id);
setHistoryDrawerOpen(false);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
</div>
</SheetContent>
</Sheet>
);
// v1.18.11 (W11) — the PAGE surface drops the top header bar and the
// rail-tray strip entirely. The composer is the single control hub; the
// thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
// (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the
// composer never changes width between the new-chat hero and an active
// conversation. The drawer surface keeps its own header + body chrome
// (handled below) untouched.
if (surface === "page") {
return (
<div
data-slot="coach-conversation"
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
</div>
{/* Docked composer — the SAME centred, capped column as the
thread (and the hero composer), so the width is constant
across the new-chat → conversation transition. */}
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
</div>
</>
)}
{historyDrawer}
</div>
);
}
return (
<div
data-slot="coach-conversation"
@@ -398,94 +540,24 @@ export function CoachConversation({
{trailingHeaderActions}
</header>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> so the
// heading is not stacked twice on the page surface.
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided
// session; unanswered questions stay pending.
setCurrentConversationId(id);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* v1.16.4 — quiet adopt-into-self-context offer once a
clarifying question has been answered. Self-removes
after settle; v1.16.5 reports the outcome back into the
guided machine for the closing summary. */}
{pendingAdopt && !send.isStreaming ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => setPendingAdopt(null)}
onSettled={(adoption) =>
dispatchGuided({
type: "ADOPTION_SETTLED",
index: pendingAdopt.guidedIndex,
adoption,
})
}
/>
) : null}
{/* v1.16.5 — guided clarifying-questions entry card (V2 of
the v1.16.0 chips). Offers the in-chat sequence while
questions pend and the flow hasn't started. */}
{guided.phase === "idle" && pendingQuestions.length > 0 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
)}
{/* Drawer surface keeps its existing body chrome: thread + rail-tray
strip + docked composer, with the conversation list + sources as
mobile trays. The "Conversations" affordance hands off to the
full-page route (in-panel left tray kept breaking inside the
sheet). The page surface is handled above and never reaches here. */}
<CoachDrawerBody
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={composerStack}
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
<MobileRailTray
historyOpen={historyTrayOpen}
@@ -9,10 +9,25 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { Loader2, Mic, Send, Square } from "lucide-react";
import Link from "next/link";
import {
Loader2,
MessagesSquare,
Mic,
Plus,
Send,
Settings,
Square,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -44,6 +59,15 @@ import { useTranslations } from "@/lib/i18n/context";
* On a browser without the Web Speech API (or during SSR) it renders
* DISABLED with an explanatory tooltip rather than vanishing or sitting as
* a dead control that silently does nothing on tap.
*
* v1.18.11 (W11): the composer is the conversation's control hub on the
* full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a
* second action row INSIDE the rounded field: a leading `+` actions menu
* (new chat + open conversations), a settings deep-link, the dictation mic,
* and the send / stop control on the same baseline. The drawer surface omits
* `showHub` and keeps the single-row composer (its own header carries new
* chat + settings), so this is purely additive — the existing composer
* behaviour and markup are untouched when the hub is off.
*/
export interface CoachInputProps {
value: string;
@@ -76,6 +100,22 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
/**
* v1.18.11 — mount the control-hub action row (leading `+` menu +
* settings link) inside the composer card. The page surface sets this;
* the drawer leaves it off and keeps the single-row composer.
*/
showHub?: boolean;
/**
* v1.18.11 — start a fresh conversation. Wired into the `+` actions
* menu. Required when `showHub` is set.
*/
onNewChat?: () => void;
/**
* v1.18.11 — open the left conversation-history drawer. Wired into the
* `+` actions menu. Required when `showHub` is set.
*/
onOpenHistory?: () => void;
}
/**
@@ -172,6 +212,9 @@ export function CoachInput({
inputId = "coach-composer-textarea",
autoFocusOnOpen = false,
placeholder,
showHub = false,
onNewChat,
onOpenHistory,
}: CoachInputProps) {
const { t, locale } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -336,6 +379,79 @@ export function CoachInput({
[canSubmit, onSubmit],
);
// v1.18.11 — the dictation mic. Lifted to a const so it can sit in the
// single-row composer (drawer) OR in the hub action row (page) without
// duplicating the wiring.
const micButton = (
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
);
// v1.18.11 — the send / stop control. Same const-lift rationale as the
// mic: one definition, two mount points.
const sendButton =
isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop control bound
// to the abort handler so the user can interrupt a long or off-track
// reply instead of waiting it out.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
);
return (
<form
data-slot="coach-input"
@@ -343,48 +459,26 @@ export function CoachInput({
className="flex flex-col"
>
{/* 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. */}
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. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"flex items-end gap-1.5 p-1.5 shadow-sm transition-colors",
"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",
)}
>
{/* v1.18.10 (W4) — the mic always renders so it stays discoverable.
When the browser lacks the Web Speech API (or during SSR) it is
DISABLED with an explanatory tooltip rather than vanishing or
sitting as a dead control that does nothing on tap. */}
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
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}
<textarea
id={inputId}
ref={textareaRef}
@@ -416,7 +510,7 @@ 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 send button so
// the placeholder and the icon share a baseline.
"px-2 py-1.5",
showHub ? "px-2 pt-1.5 pb-0.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
@@ -425,42 +519,68 @@ export function CoachInput({
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
{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>
</div>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
sendButton
)}
</div>
</form>