diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index ffa64c88e..90e0a3765 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -280,8 +280,8 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { page, }, testInfo) => { // The in-panel history tray is gone from the drawer; the button - // navigates to `/coach?view=conversations`, where the full page opens - // the conversation list on arrival instead of a blank pane. + // navigates to the dedicated `/coach/conversations` page, which renders + // the search + recency-grouped conversation list. test.skip( testInfo.project.name !== "chromium-desktop", "exercises the desktop drawer layout", @@ -312,12 +312,18 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await expect(historyButton).toBeVisible({ timeout: 10_000 }); await historyButton.click(); - // No in-panel tray opens — the click navigates to the full view, which - // carries the `view=conversations` handoff so the list opens on arrival. - await page.waitForURL(/\/coach(\?|$)/, { timeout: 10_000 }); - await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({ - timeout: 10_000, - }); + // No in-panel tray opens — the click navigates to the dedicated + // conversation-history page, which renders the search field + list. + await page.waitForURL(/\/coach\/conversations(\?|$)/, { timeout: 10_000 }); + await expect( + page.locator('[data-slot="coach-conversations-page"]'), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.locator('[data-slot="coach-conversations-search"]'), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.locator('[data-slot="coach-conversations-list"]'), + ).toBeVisible({ timeout: 10_000 }); await expect( page.locator('[data-slot="coach-drawer-history-tray"]'), ).toHaveCount(0); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx new file mode 100644 index 000000000..0855a364b --- /dev/null +++ b/src/app/coach/conversations/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { MessagesSquare, Search, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; +import { + useCoachConversations, + useDeleteCoachConversation, +} from "@/components/insights/coach-panel/use-coach"; +import type { CoachConversationDTO } from "@/lib/ai/coach/types"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useTranslations } from "@/lib/i18n/context"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; +import { cn } from "@/lib/utils"; + +/** + * v1.21.4 (Coach-UI B) — dedicated conversation-history page. + * + * Replaces the left slide-in `` drawer that the `+` menu's + * "Conversations" affordance used to open. The list now reads as a + * sibling of the Coach page itself: a search field at the top, then the + * recent conversations grouped by recency (Today / Yesterday / This week + * / Earlier). Selecting a row routes to `/coach?c=` — the existing + * deep-link open mechanism — so there is NO new API and NO new state; + * the page is a thin read over `useCoachConversations()`. + * + * Gating mirrors `/coach`: the operator master flag OR a per-user opt-out + * hides the Coach entirely and redirects back to `/insights` rather than + * painting a dead shell. The full-bleed sizing wrapper matches the Coach + * page so the two surfaces share one chrome. + */ + +type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier"; + +interface RecencyGroup { + id: RecencyGroupId; + labelKey: string; + conversations: CoachConversationDTO[]; +} + +/** + * Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO + * string; comparisons run against local day boundaries so "Today" tracks + * the user's own midnight, not UTC. + */ +function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + // "This week" = the six days before yesterday (a rolling 7-day window + // that already excludes today + yesterday, which have their own groups). + const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000; + + const groups: Record = { + today: [], + yesterday: [], + thisWeek: [], + earlier: [], + }; + + for (const conversation of conversations) { + const updated = new Date(conversation.updatedAt).getTime(); + if (updated >= startOfToday) groups.today.push(conversation); + else if (updated >= startOfYesterday) groups.yesterday.push(conversation); + else if (updated >= startOfWeek) groups.thisWeek.push(conversation); + else groups.earlier.push(conversation); + } + + return ( + [ + { id: "today", labelKey: "insights.coach.history.groupToday" }, + { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" }, + { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" }, + { id: "earlier", labelKey: "insights.coach.history.groupEarlier" }, + ] as const + ) + .map((g) => ({ ...g, conversations: groups[g.id] })) + .filter((g) => g.conversations.length > 0); +} + +function CoachConversationsBody() { + const { t } = useTranslations(); + const router = useRouter(); + const { conversations, isLoading } = useCoachConversations(); + const deleteMutation = useDeleteCoachConversation(); + const [filter, setFilter] = useState(""); + const [confirmId, setConfirmId] = useState(null); + + // Mirror the rail's title substring filter. + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return conversations; + return conversations.filter((c) => c.title.toLowerCase().includes(q)); + }, [conversations, filter]); + + const groups = useMemo(() => groupByRecency(filtered), [filtered]); + + function handleSelect(id: string) { + // Reuse the existing `?c=` open mechanism on the Coach page. + router.push(`/coach?c=${id}`); + } + + function handleDeleteRequest(id: string) { + if (confirmId === id) { + deleteMutation.mutate(id); + setConfirmId(null); + } else { + setConfirmId(id); + } + } + + return ( +
+
+

+

+
+
+
+ +
+ {isLoading && conversations.length === 0 ? ( +

+ {t("common.loading")} +

+ ) : groups.length === 0 ? ( +
+ + +

+ {t("insights.coach.historyEmpty")} +

+
+ ) : ( + groups.map((group) => ( +
+

+ {t(group.labelKey)} +

+
    + {group.conversations.map((c) => { + const isConfirming = confirmId === c.id; + return ( +
  • + + +
  • + ); + })} +
+
+ )) + )} +
+
+ ); +} + +export default function CoachConversationsPage() { + const router = useRouter(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + const coachUnavailable = !flags.coach || disableCoach; + + // Same gating as `/coach`: operator master flag OR per-user opt-out + // redirects back to the Insights mother page so the route is never a + // dead-end. + useEffect(() => { + if (coachUnavailable) { + router.replace("/insights"); + } + }, [coachUnavailable, router]); + + // Keep the launch context referenced so the shared FAB drawer the page + // hands back to stays mounted (mirrors `/coach`). + void launch; + + if (coachUnavailable) return null; + + return ( +
+ +
+ ); +} diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 3e82f4059..85870cf99 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -49,10 +49,9 @@ import { apiGet } from "@/lib/api/api-fetch"; * the v1.19.0 (W7) "always resume most-recent" default the maintainer * disliked. Resolution order on mount: * - `?c=` → open that specific thread (explicit deep-link, unchanged). + * The dedicated conversation-history page (`/coach/conversations`, added + * in v1.21.4) routes back here with `?c=` when a row is selected. * - `?c=new` or no param → the new-chat hero. - * - `?view=conversations` → land on the new-chat hero AND open the - * conversation-history drawer, so the drawer's "Conversations" handoff - * (C5) never drops the user on a blank pane. * - EXCEPTION: when the Coach has proactively written an UNREAD message * (`/api/insights/coach/nudge-status` → `unread`), auto-open the * most-recent thread so the user lands on what the Coach said. @@ -66,19 +65,16 @@ function CoachPageBody() { // defaults to the new-chat hero. const rawC = searchParams.get("c"); const deepLinkedId = rawC && rawC !== "new" ? rawC : null; - // C5 — the drawer's "Conversations" handoff routes here with this flag so - // the page opens the history drawer on arrival instead of a blank hero. - const openConversations = searchParams.get("view") === "conversations"; // C1 exception — an unread coach-initiated message opens the most-recent // conversation (which holds that proactive turn). Only consulted when the - // entry did not pin a specific thread or ask for a fresh chat / list. + // entry did not pin a specific thread or ask for a fresh chat. const exceptionEligible = deepLinkedId === null && rawC !== "new"; const { data: nudge } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), queryFn: async (): Promise => apiGet("/api/insights/coach/nudge-status"), - enabled: exceptionEligible && !openConversations, + enabled: exceptionEligible, staleTime: 5 * 60 * 1000, }); const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true; @@ -91,7 +87,6 @@ function CoachPageBody() { // Default is the new-chat hero; only resume most-recent for the // unread coach-initiated exception. autoOpenMostRecent={hasUnreadCoachMessage} - openHistoryOnMount={openConversations} /> ); } diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 06f2cf7bf..f0bf22f75 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -100,15 +100,15 @@ export function CoachDrawer({ router.push("/coach"); }, [onOpenChange, router]); - // v1.19.1 (C5) — the drawer's "Conversations" affordance hands off to the - // full page WITH the `?view=conversations` flag so the page opens the - // conversation-history drawer on arrival. Previously it shared - // `handleMaximize` and, after the v1.19.1 new-chat default (C1), landed on - // a blank hero with no list — the dead-end the maintainer reported. + // v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands + // off to the dedicated conversation-history page (`/coach/conversations`), + // which renders the search + recency-grouped list as a sibling of the + // Coach page. The former `?view=conversations` in-page slide-in drawer is + // gone; selecting a row there routes back to `/coach?c=`. const handleOpenConversations = useCallback(() => { onOpenChange(false); resetRef.current?.(); - router.push("/coach?view=conversations"); + router.push("/coach/conversations"); }, [onOpenChange, router]); return ( @@ -141,10 +141,10 @@ export function CoachDrawer({ launchScope={scope} autoFocusComposer registerReset={registerReset} - // v1.16.1 — the "Conversations" affordance hands off to the - // full-page route instead of opening the broken in-panel left - // tray. v1.19.1 (C5) — it now carries `?view=conversations` so the - // page opens the history list on arrival, never a blank hero. + // v1.16.1 — the "Conversations" affordance hands off to a + // dedicated route instead of opening the broken in-panel left + // tray. v1.21.4 (Coach-UI B) — it now navigates to + // `/coach/conversations`, the standalone search + grouped-list page. onRequestFullView={handleOpenConversations} // Radix requires an accessible name + description on the // dialog content. Mount them through the title/description