feat(coach): move conversation history to a dedicated page

Replace the left slide-in conversations drawer with a standalone
/coach/conversations page: a search field above a recency-grouped list
(Today / Yesterday / This week / Earlier) built on the existing
conversations query. Selecting a row routes to /coach?c=<id>, reusing the
deep-link open mechanism — no new API, no new state. The drawer's
Conversations affordance now navigates to the new route, and the Coach
page drops its ?view=conversations in-page drawer handling.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 11:21:32 +02:00
1 parent 7d2852a312
commit 31b7fa6fc6
4 files changed
+305 -27

No files matched your search

+14 -8
View File
@@ -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);
+277
View File
@@ -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 `<Sheet>` 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=<id>` — 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<RecencyGroupId, CoachConversationDTO[]> = {
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<string>("");
const [confirmId, setConfirmId] = useState<string | null>(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=<id>` 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 (
<div className="mx-auto flex h-full min-h-0 w-full max-w-2xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
<header className="flex flex-col gap-3">
<h1
data-slot="coach-conversations-heading"
className="text-foreground flex items-center gap-2 text-lg font-semibold"
>
<MessagesSquare className="size-5" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</h1>
<div className="relative">
<Search
aria-hidden="true"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
/>
<Input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-conversations-search"
className="h-11 pl-9"
/>
</div>
</header>
<div
data-slot="coach-conversations-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
<p
data-slot="coach-conversations-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : groups.length === 0 ? (
<div
data-slot="coach-conversations-empty"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
>
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
<MessagesSquare className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("insights.coach.historyEmpty")}
</p>
</div>
) : (
groups.map((group) => (
<section
key={group.id}
data-slot="coach-conversations-group"
data-group={group.id}
className="flex flex-col gap-1.5"
>
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
{t(group.labelKey)}
</h2>
<ul className="flex flex-col gap-1">
{group.conversations.map((c) => {
const isConfirming = confirmId === c.id;
return (
<li
key={c.id}
data-slot="coach-conversations-item"
className={cn(
"group flex items-center gap-1.5 rounded-xl px-3 py-2.5",
"text-sm transition-colors",
"text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
<button
type="button"
onClick={() => handleSelect(c.id)}
className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left"
data-slot="coach-conversations-select"
>
<span className="block truncate font-medium">
{c.title}
</span>
<span className="text-muted-foreground block text-xs">
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
data-slot="coach-conversations-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</li>
);
})}
</ul>
</section>
))
)}
</div>
</div>
);
}
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 (
<div
data-slot="coach-conversations-page"
// Match `/coach`'s full-bleed sizing: cancel the AuthShell padding
// and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
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)]"
>
<CoachConversationsBody />
</div>
);
}
+4 -9
View File
@@ -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=<id>` → 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=<id>` 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<CoachNudgeStatus> =>
apiGet<CoachNudgeStatus>("/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}
/>
);
}
@@ -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=<id>`.
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