From 5117bc603c024bdc3bceecdebac39046a146c79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 11:23:47 +0200 Subject: [PATCH] feat(coach): settings in composer menu, dismissable seeded opener Add a Settings entry to the composer's "+" actions menu and remove the page-toolbar gear; the drawer header keeps its own gear. Let the seeded "worth a look" opener fill the composer column and give it a per-day dismiss control. Route the Conversations action to /coach/conversations and drop the in-page history drawer. --- .../coach-conversation-deeplink.test.tsx | 44 +++--- .../__tests__/scope-hint-badge.test.tsx | 54 +++++++ .../coach-panel/coach-conversation.tsx | 135 ++++++------------ .../insights/coach-panel/coach-input.tsx | 17 ++- .../insights/coach-panel/scope-hint-badge.tsx | 70 ++++++--- 5 files changed, 184 insertions(+), 136 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index e695b6148..a12f2b8e8 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -1,7 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +// v1.21.4 (B) — the page surface routes "Conversations" to the dedicated +// `/coach/conversations` page via the app router, so the shared surface now +// reads `useRouter`. Stub it for the static-markup render. +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { CoachConversation } from "../coach-conversation"; import type { @@ -136,30 +144,16 @@ describe(" page deep-link (#67)", () => { expect(html).toContain('data-slot="coach-hero"'); }); - // 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", () => { + // v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the + // composer's `+` actions menu. The page chrome is the composer alone, so the + // surface exposes the `+` actions trigger and no standalone toolbar gear. + it("drops the page-toolbar gear and exposes the composer + actions menu", () => { const html = render(, makeClient()); - expect(html).toContain('data-slot="coach-page-settings"'); - const gear = html.match(/]*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.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( - , - makeClient(), - ); - expect(html).toContain('data-slot="coach-hero"'); - expect(html).toContain('data-slot="coach-page-settings"'); + // The old page toolbar + its gear are gone. + expect(html).not.toContain('data-slot="coach-page-settings"'); + expect(html).not.toContain('data-slot="coach-page-toolbar"'); + // Settings now lives behind the composer's `+` actions menu (the menu + // content is portalled open-on-demand, so SSR shows only the trigger). + expect(html).toContain('data-slot="coach-input-actions"'); }); }); diff --git a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx index e16775507..268bce7a0 100644 --- a/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx +++ b/src/components/insights/coach-panel/__tests__/scope-hint-badge.test.tsx @@ -71,6 +71,60 @@ describe("", () => { onSeed(question); expect(onSeed).toHaveBeenCalledWith(question); }); + + // v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can + // wave off today's suggestion. The control is gated to the seeded variant. + it("renders the dismiss control for the seeded variant when onDismiss is given", () => { + const html = render( + {}} + onDismiss={() => {}} + />, + ); + expect(html).toContain('data-slot="coach-scope-hint-dismiss"'); + }); + + it("omits the dismiss control for the seeded variant without onDismiss", () => { + const html = render( + {}} + />, + ); + expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"'); + }); + + it("never renders the dismiss control for the non-dismissable scope pill", () => { + const html = render( + {}} + onDismiss={() => {}} + />, + ); + expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"'); + }); + + it("lets the seeded opener fill its column (no max-w-md cap)", () => { + const html = render( + {}} + onDismiss={() => {}} + />, + ); + // The opener grows to the composer column width; the old narrow cap is gone. + expect(html).not.toContain("max-w-md"); + }); }); describe(" scope hint slot", () => { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index c50af1cfb..3dc1dd5b2 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -2,17 +2,12 @@ import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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"; @@ -93,6 +88,19 @@ export function launchScopeToCoachScope( }; } +/** + * v1.21.4 (C2) — the localStorage key that records the seeded "worth a look" + * opener as dismissed for a given LOCAL calendar day. Date-stamped so the + * dismissal resets at midnight: a new day mints a new key the flag has not + * been written under yet, and the opener returns. + */ +function seededDismissStorageKey(now: Date = new Date()): string { + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `coach-seeded-dismissed:${year}-${month}-${day}`; +} + export interface CoachConversationProps { /** * Pre-fill for the composer (suggested-prompt chip click). Resets the @@ -175,13 +183,6 @@ export interface CoachConversationProps { * "new chat" or thread switch is never overridden. */ autoOpenMostRecent?: boolean; - /** - * v1.19.1 (C5) — page surface only: open the conversation-history drawer - * once on mount. The drawer's "Conversations" handoff routes to - * `/coach?view=conversations`, which sets this so the user always sees the - * conversation list on arrival instead of a blank new-chat pane. - */ - openHistoryOnMount?: boolean; } export function CoachConversation({ @@ -198,26 +199,15 @@ export function CoachConversation({ surface, initialConversationId, autoOpenMostRecent = false, - openHistoryOnMount = false, }: CoachConversationProps) { const { t } = useTranslations(); + const router = useRouter(); const [currentConversationId, setCurrentConversationId] = useState< string | null >(initialConversationId ?? null); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = 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. - // v1.19.1 (C5) — seed the history drawer open when the page is entered via - // the drawer's "Conversations" handoff (`?view=conversations`), so the - // list is on screen immediately rather than a blank hero. - const [historyDrawerOpen, setHistoryDrawerOpen] = useState( - () => openHistoryOnMount, - ); 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 @@ -425,7 +415,7 @@ export function CoachConversation({ } showHub={surface === "page"} onNewChat={handleNewChat} - onOpenHistory={() => setHistoryDrawerOpen(true)} + onOpenHistory={() => router.push("/coach/conversations")} /> ); @@ -461,7 +451,19 @@ export function CoachConversation({ // it. When the server returns no signal the hint is null and the neutral // greeting stands — never a fabricated opener. const a2Metric = launchScope?.metric ?? null; - const seededEnabled = heroActive && a2Metric === null; + // v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone + // for the rest of the local calendar day; the fetch is skipped too, so a + // dismissed day costs nothing. SSR-safe lazy init guards `window`. + const [seededDismissedToday, setSeededDismissedToday] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(seededDismissStorageKey()) === "1"; + } catch { + return false; + } + }); + const seededEnabled = + heroActive && a2Metric === null && !seededDismissedToday; const { data: seeded } = useQuery({ queryKey: queryKeys.coachSeededQuestion(), queryFn: async () => @@ -474,6 +476,17 @@ export function CoachConversation({ setInputValue(question); } + function dismissSeeded() { + setSeededDismissedToday(true); + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(seededDismissStorageKey(), "1"); + } catch { + // Storage can throw (private mode / quota); the in-memory flag still + // hides the opener for this session. + } + } + // `t()` returns the key string itself when a key is missing, so the only // reliable "is this key defined" signal is `t(key) !== key`. function tOrNull(key: string): string | null { @@ -507,7 +520,7 @@ export function CoachConversation({ onSeed={seedComposer} /> ); - } else if (seeded?.signal) { + } else if (seeded?.signal && !seededDismissedToday) { // A3 — the notable derived signal. The label + opener are keyed on the // signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel // (future detector additions) skips the opener rather than guessing. @@ -523,6 +536,7 @@ export function CoachConversation({ label={signalLabel} question={signalQuestion} onSeed={seedComposer} + onDismiss={dismissSeeded} /> ); } @@ -577,41 +591,6 @@ export function CoachConversation({ ); - // 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 @@ -626,32 +605,9 @@ export function CoachConversation({ data-variant={surface} className={cn("flex min-h-0 flex-1 flex-col", className)} > - {/* 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. */} -
- -
+ {/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives + in the composer's `+` actions menu alongside New chat and + Conversations, keeping the page chrome to the composer alone. */} {heroActive ? ( ) : ( @@ -675,7 +631,6 @@ export function CoachConversation({ )} - {historyDrawer} ); } diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 372682588..dfa9540a0 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -9,7 +9,16 @@ import { useState, useSyncExternalStore, } from "react"; -import { Loader2, MessagesSquare, Mic, Plus, 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"; @@ -479,6 +488,12 @@ export function CoachInput({