Merge C1/C2/C3/C5 Coach UI rework into release/v1.19.1

C1: /coach defaults to the new-chat hero, auto-resuming only on an unread
coach-initiated message. C2: always-visible Conversations + New chat
toolbar. C3: replace the thinking disclosure with the typing-dots into
word-fade streaming animation. C5: the drawer Conversations handoff opens
the history list on arrival so the full view is never a blank pane.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 13:59:48 +02:00
commit 430f7e81a1
6 files changed
+131 -196

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.
@@ -26,7 +26,6 @@ import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { StreamedProse } from "./streamed-prose";
import { ThinkingDisclosure } from "./thinking-disclosure";
import { MessageTokenFooter } from "./message-token-footer";
import type {
CoachConversationDetailDTO,
@@ -470,11 +469,8 @@ export function MessageThread({
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the elapsed-time thinking
// disclosure + the just-landed token footer.
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
startedAt={streaming.startedAt}
reasoning={streaming.reasoning}
usage={streaming.usage}
/>
</div>
@@ -513,17 +509,6 @@ interface ChatBubbleProps {
* settled plain text.
*/
streaming?: boolean;
/**
* v1.18.9 — send-anchored timestamp (epoch ms) for the streaming turn,
* feeding the thinking disclosure's elapsed timer. Null on persisted
* bubbles.
*/
startedAt?: number | null;
/**
* v1.18.9 — reasoning-summary text from the additive `reasoning` SSE
* frame, shown inside the thinking disclosure when present.
*/
reasoning?: string;
/**
* v1.18.9 — per-turn token usage for the just-finished streaming bubble
* (from `done.usage`). Persisted bubbles read `tokensUsed` / `model`
@@ -548,8 +533,6 @@ function ChatBubble({
errorCode,
messageId,
streaming,
startedAt,
reasoning,
usage,
tokensUsed,
model,
@@ -678,28 +661,16 @@ function ChatBubble({
)}
</div>
<div className="flex max-w-[calc(80%-2.625rem)] flex-col gap-2">
{/* v1.18.9 — the single thinking indicator (live elapsed-time line,
then an auto-collapsed past-tense disclosure). Renders only for
the live streaming turn — persisted bubbles pass no `startedAt`,
so the disclosure returns null and history stays settled. This
replaces the old <TypingDots> dots-plus-word combo: there is
never a three-dots indicator AND a "Thinking" word at once. */}
{streaming && startedAt != null && (
<ThinkingDisclosure
startedAt={startedAt}
inProgress={!!inProgress}
hasContent={!!content}
reasoning={reasoning}
/>
)}
{/* The prose bubble. Skipped while the live turn is still thinking
with no prose (the disclosure carries the live state above), so
the user never sees an empty bubble under the indicator. The
`<TypingDots>` fallback below only fires when there is no live
disclosure (e.g. a defensive null `startedAt`). */}
{(content ||
safeError ||
(inProgress && !(streaming && startedAt != null))) && (
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
The dots render inside the prose bubble below; the disclosure is
retired. Persisted bubbles never stream, so history stays settled. */}
{/* The prose bubble. While the live turn is still thinking with no
prose the bubble carries the typing animation; once tokens land
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
<div
className={cn(
"border-border/60 bg-muted/40 text-foreground",
@@ -1,138 +0,0 @@
"use client";
import { useEffect, useId, useState } from "react";
import { ChevronRight, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — the ONE Coach thinking indicator on the page surface.
*
* Replaces the old dots-plus-word combo (`<TypingDots>`), which showed
* three bouncing dots AND the word "Thinking…" at the same time — the
* exact pairing the maintainer disliked. This is a single, calm control:
*
* - While the turn is thinking (streaming, no prose yet): a live
* "Thinking… N s" line with a small spinner. `role="status"` so a
* screen reader announces "thinking" once, not on every tick.
* - Once the first token lands OR the turn settles: it relabels to the
* past tense ("Thought for N s") and auto-collapses into a quiet
* `<details>` disclosure. Expanding reveals the reasoning summary when
* a reasoning-capable provider supplied one (the additive `reasoning`
* SSE frame), otherwise a one-line factual fallback — never fabricated
* chain-of-thought.
*
* The elapsed time is computed from the send-anchored `startedAt`
* timestamp: it ticks live while thinking and freezes the instant the
* first token (or the settled state) arrives.
*/
export interface ThinkingDisclosureProps {
/**
* Epoch-ms timestamp the turn started (the moment the send fired). Null
* disables the elapsed timer (the component renders nothing).
*/
startedAt: number | null;
/** True until the turn settles (the `done`/`error` frame closed it). */
inProgress: boolean;
/**
* Whether any assistant prose has streamed in yet. Drives the live →
* past-tense relabel + auto-collapse: the moment the answer starts, the
* "Thinking…" line freezes and folds away.
*/
hasContent: boolean;
/**
* Optional reasoning-summary text from the additive `reasoning` SSE
* frame, rendered inside the expanded disclosure when present.
*/
reasoning?: string;
}
export function ThinkingDisclosure({
startedAt,
inProgress,
hasContent,
reasoning,
}: ThinkingDisclosureProps) {
const { t } = useTranslations();
const panelId = useId();
const [open, setOpen] = useState(false);
// The turn is "thinking" only while it is in progress with no prose yet.
const thinking = inProgress && hasContent === false && startedAt !== null;
// `elapsed` ticks once a second while thinking; the interval stops the
// moment `thinking` flips false, so the last value it set is the frozen
// "thought for N s" figure — no render-time ref, no sync setState in the
// effect body (the first tick lands on the interval, not inline).
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!thinking || startedAt === null) return;
const tick = () =>
setElapsed(Math.max(0, Math.round((Date.now() - startedAt) / 1000)));
tick();
const handle = window.setInterval(tick, 1000);
return () => window.clearInterval(handle);
}, [thinking, startedAt]);
if (startedAt === null) return null;
// ── Live "thinking" line (single indicator, no dots-plus-word) ──
if (thinking) {
return (
<div
data-slot="coach-thinking-live"
role="status"
aria-live="polite"
className="text-muted-foreground inline-flex items-center gap-2 py-0.5 text-xs"
>
<Loader2
aria-hidden="true"
className="text-dracula-purple/70 size-3.5 animate-spin motion-reduce:animate-none"
/>
<span>{t("insights.coach.thinking")}</span>
<span aria-hidden="true" className="tabular-nums opacity-70">
{t("insights.coach.thinkingElapsed", { seconds: elapsed })}
</span>
</div>
);
}
// ── Settled: past-tense collapsible disclosure (auto-collapsed) ──
const reasoningText = reasoning?.trim() ?? "";
return (
<details
data-slot="coach-thinking-disclosure"
open={open}
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
className="group inline-block"
>
<summary
data-slot="coach-thinking-summary"
aria-controls={panelId}
aria-expanded={open}
aria-label={t("insights.coach.thinkingExpandAria")}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex cursor-pointer items-center gap-1 text-xs",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>
{t("insights.coach.thinkingDuration", { seconds: elapsed })}
</span>
</summary>
<p
id={panelId}
data-slot="coach-thinking-detail"
className="text-muted-foreground mt-1.5 pl-4 text-xs leading-relaxed"
>
{reasoningText || t("insights.coach.thinkingDetail")}
</p>
</details>
);
}