mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
commit
d91cb35569
10 files changed
+429
-169
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user