mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
commit
430f7e81a1
6 files changed
+131
-196
No files matched your search
+38
-13
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user