fix(coach): default to new chat and never dead-end on conversations

Reverse the resume-most-recent entry default: opening the Coach now lands
on the new-chat hero. The only exception is an unread coach-initiated
message (nudge-status `unread`), which auto-opens the most-recent thread so
the user sees what the Coach wrote. The `?c=<id>` deep-link is unchanged.

Add a dedicated, always-visible Conversations button (plus a New chat
button) to the page surface so reaching past conversations no longer hides
in the composer menu. The drawer's Conversations handoff now routes to
`/coach?view=conversations`, which opens the history drawer on arrival, so
the side-view to full-view path always shows the list instead of a blank
pane.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 13:59:05 +02:00
1 parent a86abd3662
commit c6f4772606
4 files changed
+120 -18

No files matched your search

+38 -13
View File
@@ -2,11 +2,15 @@
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
@@ -41,32 +45,53 @@ import { useDisableCoach } from "@/hooks/use-disable-coach";
* the active conversation. All of that lives in `<CoachConversation>`'s
* page branch, so the page stays a thin gating + sizing wrapper.
*
* v1.18.11 (W11, #67) — the page is now a deep-link target INTO a
* conversation. A `?c=<id>` query param opens that specific thread on
* mount; absent it, the page auto-opens the user's most-recent thread
* (server-authoritative `updatedAt desc` head of the rail, so it lands the
* same thread on web + mobile). Both feed `<CoachConversation>`'s existing
* selection state — the dashboard Coach entry navigates straight into the
* live conversation instead of a blank landing. `?c=new` (or no thread at
* all) keeps the new-chat hero. The search-param read sits in a Suspense
* child so the client-bailout never de-opts the route.
* v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing
* 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).
* - `?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.
* The search-param read sits in a Suspense child so the client-bailout
* never de-opts the route.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
// `?c=new` is an explicit "start a fresh chat" escape hatch; any other
// value is treated as a conversation id to open. A blank/absent param
// falls through to most-recent auto-open.
// 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.
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,
staleTime: 5 * 60 * 1000,
});
const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true;
return (
<CoachConversation
surface="page"
autoFocusComposer
initialConversationId={deepLinkedId}
// Only auto-resolve most-recent when the entry did not pin a thread
// and did not ask for a fresh chat.
autoOpenMostRecent={deepLinkedId === null && rawC !== "new"}
// Default is the new-chat hero; only resume most-recent for the
// unread coach-initiated exception.
autoOpenMostRecent={hasUnreadCoachMessage}
openHistoryOnMount={openConversations}
/>
);
}
@@ -135,4 +135,25 @@ describe("<CoachConversation> page deep-link (#67)", () => {
// though recent conversations exist in the cache.
expect(html).toContain('data-slot="coach-hero"');
});
// v1.19.1 (C2) — a dedicated, always-visible Conversations button on the
// page surface (not buried in the composer `+` menu).
it("renders the dedicated Conversations button on the page surface", () => {
const html = render(<CoachConversation surface="page" />, makeClient());
expect(html).toContain('data-slot="coach-page-conversations"');
expect(html).toContain('data-slot="coach-page-new-chat"');
});
// v1.19.1 (C5) — entering via the drawer handoff (`?view=conversations`)
// keeps the new-chat hero (no thread auto-resumed) while opening the
// history drawer; the toolbar + button stay present so the pane is never
// a blank dead-end.
it("keeps the hero and toolbar when opened with openHistoryOnMount", () => {
const html = render(
<CoachConversation surface="page" openHistoryOnMount />,
makeClient(),
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain('data-slot="coach-page-conversations"');
});
});
@@ -3,7 +3,7 @@
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Settings, Sparkles } from "lucide-react";
import { MessagesSquare, Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -137,6 +137,13 @@ 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({
@@ -152,6 +159,7 @@ export function CoachConversation({
surface,
initialConversationId,
autoOpenMostRecent = false,
openHistoryOnMount = false,
}: CoachConversationProps) {
const { t } = useTranslations();
@@ -165,7 +173,12 @@ export function CoachConversation({
// 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);
// 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
@@ -484,6 +497,37 @@ export function CoachConversation({
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{/* v1.19.1 (C2) — a clear, always-visible "Conversations" button on
the page surface. The composer's `+` menu still carries the same
action, but the maintainer wanted an obvious, dedicated affordance
to reach past conversations rather than one buried in a menu. */}
<div
data-slot="coach-page-toolbar"
className="flex shrink-0 items-center justify-between gap-2 px-4 pt-2 sm:px-6"
>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setHistoryDrawerOpen(true)}
data-slot="coach-page-conversations"
className="text-muted-foreground hover:text-foreground -ml-1 gap-1.5"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNewChat}
data-slot="coach-page-new-chat"
className="text-muted-foreground hover:text-foreground -mr-1 gap-1.5"
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</Button>
</div>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
@@ -88,6 +88,17 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
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.
const handleOpenConversations = useCallback(() => {
onOpenChange(false);
resetRef.current?.();
router.push("/coach?view=conversations");
}, [onOpenChange, router]);
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
@@ -118,9 +129,10 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
// full-page route (same path as maximize) instead of opening
// the broken in-panel left tray.
onRequestFullView={handleMaximize}
// 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.
onRequestFullView={handleOpenConversations}
// Radix requires an accessible name + description on the
// dialog content. Mount them through the title/description
// render-props so the page can swap in a plain heading.