diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 0274097d2..3e82f4059 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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 ``'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=` 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 ``'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=` → 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 => + apiGet("/api/insights/coach/nudge-status"), + enabled: exceptionEligible && !openConversations, + staleTime: 5 * 60 * 1000, + }); + const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true; return ( ); } 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 fd9894a48..c7bbc9049 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 @@ -135,4 +135,25 @@ describe(" 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(, 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( + , + makeClient(), + ); + expect(html).toContain('data-slot="coach-hero"'); + expect(html).toContain('data-slot="coach-page-conversations"'); + }); }); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index b166d6334..add693bda 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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. */} +
+ + +
{heroActive ? ( ) : ( diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 07885095e..bcbfce7d3 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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 ( @@ -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({ )}
- {/* 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 dots-plus-word combo: there is - never a three-dots indicator AND a "Thinking" word at once. */} - {streaming && startedAt != null && ( - - )} - {/* 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 - `` 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) && (
`), 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 - * `
` 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 ( -
-
- ); - } - - // ── Settled: past-tense collapsible disclosure (auto-collapsed) ── - const reasoningText = reasoning?.trim() ?? ""; - return ( -
setOpen((e.target as HTMLDetailsElement).open)} - className="group inline-block" - > - - -

- {reasoningText || t("insights.coach.thinkingDetail")} -

-
- ); -}