From 1469336cfeffd0f173335945d2278dae464b54cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 18:57:18 +0200 Subject: [PATCH] feat(coach): rebuild the Coach page around a ChatGPT-style composer hub Drop the dedicated page's top header bar and the conversation-history strip. The composer now carries the controls: a leading actions menu (new chat + open conversations) and a settings deep-link beside the mic and send. Conversation history opens as a left slide-in drawer that works on phone and desktop. Pin the composer to one centred, max-width-capped column shared with the thread so its width stays constant from the new-chat hero into an active conversation. The drawer surface keeps its own header and body chrome unchanged. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/coach/page.tsx | 17 +- .../__tests__/coach-input.test.tsx | 59 ++++ .../coach-panel/coach-conversation.tsx | 256 ++++++++++------- .../insights/coach-panel/coach-input.tsx | 260 +++++++++++++----- 10 files changed, 429 insertions(+), 169 deletions(-) diff --git a/messages/de.json b/messages/de.json index 53988cf76..9bf4e25e8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 7e15661ef..c95da55f1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index f283e8fd7..92f869149 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index b58eff6cb..8d8d40a7e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index cc2c72640..93037e331 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f5ea68ef1..13ec008aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index bc02ea74a..6ff9d5c03 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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 ``'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)]" > - ( -

{title}

- )} - /> + ); } diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index eb15e9b62..6f113d0c4 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -222,6 +222,65 @@ describe("", () => { 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( + {}} 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( + {}} + 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( + /]*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( + {}} + onSubmit={() => {}} + showHub + onNewChat={() => {}} + onOpenHistory={() => {}} + />, + ); + const actions = html.match( + /]*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 diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index cbe6afae1..425da3635 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 = ( 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 = ( +
+ {/* 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 ? ( + 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 ? ( + + dispatchGuided({ + type: "START", + questions: pendingQuestions, + }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } + /> + ) : null} + {composerNode} +
+ ); + + // 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 `` + conversation-list + // query; respects `prefers-reduced-motion` (the Sheet primitive disables + // its slide there). Selecting a conversation closes the drawer. + const historyDrawer = ( + + + + + {t("insights.coach.historyTitle")} + + +
+ { + // v1.16.5 — switching conversations drops the guided session; + // unanswered questions stay pending. + setCurrentConversationId(id); + setHistoryDrawerOpen(false); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); + }} + /> +
+
+
+ ); + + // 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 ( +
+ {heroActive ? ( + + ) : ( + <> +
+ +
+ {/* 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. */} +
+
{composerStack}
+
+ + )} + {historyDrawer} +
+ ); + } + return (
- {heroActive ? ( - - ) : ( - setHistoryRailOpen((open) => !open) - : undefined - } - historyRail={ - surface === "page" ? ( - ` band already renders the - // "Conversations"

; suppress the rail's own

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={ - - } - composer={ -
- {/* 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 ? ( - 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 ? ( - - dispatchGuided({ - type: "START", - questions: pendingQuestions, - }) - } - onLater={() => dispatchGuided({ type: "LATER" })} - onDismissAll={() => - dismissQuestions.mutate(undefined, { - onSuccess: () => dispatchGuided({ type: "LATER" }), - }) - } - /> - ) : null} - {composerNode} -
- } - 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. */} + + } + composer={composerStack} + onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))} + onOpenSourcesTray={() => setSourcesTrayOpen(true)} + /> 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(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 = ( + + ); + + // 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. + + ) : ( + + ); + return (
{/* 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. */}
{/* 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. */} - + 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}