mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branches 'origin/wave/v1214-b' and 'origin/wave/v1214-ac' into release/v1.21.4
This commit is contained in:
commit
c443e2db21
9 files changed
+489
-163
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-25
@@ -1,7 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// v1.21.4 (B) — the page surface routes "Conversations" to the dedicated
|
||||
// `/coach/conversations` page via the app router, so the shared surface now
|
||||
// reads `useRouter`. Stub it for the static-markup render.
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachConversation } from "../coach-conversation";
|
||||
import type {
|
||||
@@ -136,30 +144,16 @@ describe("<CoachConversation> page deep-link (#67)", () => {
|
||||
expect(html).toContain('data-slot="coach-hero"');
|
||||
});
|
||||
|
||||
// v1.21.0 — the page toolbar is a single trailing affordance: the settings
|
||||
// gear in the top-right corner. The "Conversations" + "New chat" buttons
|
||||
// were removed from the toolbar (both still live in the composer `+` menu),
|
||||
// and the settings gear moved here from the composer.
|
||||
it("renders the settings gear in the top-right page toolbar", () => {
|
||||
// v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the
|
||||
// composer's `+` actions menu. The page chrome is the composer alone, so the
|
||||
// surface exposes the `+` actions trigger and no standalone toolbar gear.
|
||||
it("drops the page-toolbar gear and exposes the composer + actions menu", () => {
|
||||
const html = render(<CoachConversation surface="page" />, makeClient());
|
||||
expect(html).toContain('data-slot="coach-page-settings"');
|
||||
const gear = html.match(/<a[^>]*data-slot="coach-page-settings"[^>]*>/);
|
||||
expect(gear?.[0]).toContain('href="/settings/ai"');
|
||||
// The old toolbar Conversations + New chat buttons are gone.
|
||||
expect(html).not.toContain('data-slot="coach-page-conversations"');
|
||||
expect(html).not.toContain('data-slot="coach-page-new-chat"');
|
||||
});
|
||||
|
||||
// v1.21.0 — entering via the drawer handoff (`?view=conversations`) keeps
|
||||
// the new-chat hero (no thread auto-resumed) while opening the history
|
||||
// drawer; the toolbar gear stays present so the pane is never a blank
|
||||
// dead-end.
|
||||
it("keeps the hero and toolbar gear 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-settings"');
|
||||
// The old page toolbar + its gear are gone.
|
||||
expect(html).not.toContain('data-slot="coach-page-settings"');
|
||||
expect(html).not.toContain('data-slot="coach-page-toolbar"');
|
||||
// Settings now lives behind the composer's `+` actions menu (the menu
|
||||
// content is portalled open-on-demand, so SSR shows only the trigger).
|
||||
expect(html).toContain('data-slot="coach-input-actions"');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,60 @@ describe("<ScopeHintBadge>", () => {
|
||||
onSeed(question);
|
||||
expect(onSeed).toHaveBeenCalledWith(question);
|
||||
});
|
||||
|
||||
// v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can
|
||||
// wave off today's suggestion. The control is gated to the seeded variant.
|
||||
it("renders the dismiss control for the seeded variant when onDismiss is given", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("omits the dismiss control for the seeded variant without onDismiss", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("never renders the dismiss control for the non-dismissable scope pill", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
|
||||
});
|
||||
|
||||
it("lets the seeded opener fill its column (no max-w-md cap)", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The opener grows to the composer column width; the old narrow cap is gone.
|
||||
expect(html).not.toContain("max-w-md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachHero> scope hint slot", () => {
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Settings, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -93,6 +88,19 @@ export function launchScopeToCoachScope(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.4 (C2) — the localStorage key that records the seeded "worth a look"
|
||||
* opener as dismissed for a given LOCAL calendar day. Date-stamped so the
|
||||
* dismissal resets at midnight: a new day mints a new key the flag has not
|
||||
* been written under yet, and the opener returns.
|
||||
*/
|
||||
function seededDismissStorageKey(now: Date = new Date()): string {
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `coach-seeded-dismissed:${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export interface CoachConversationProps {
|
||||
/**
|
||||
* Pre-fill for the composer (suggested-prompt chip click). Resets the
|
||||
@@ -175,13 +183,6 @@ 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({
|
||||
@@ -198,26 +199,15 @@ export function CoachConversation({
|
||||
surface,
|
||||
initialConversationId,
|
||||
autoOpenMostRecent = false,
|
||||
openHistoryOnMount = false,
|
||||
}: CoachConversationProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(initialConversationId ?? null);
|
||||
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
|
||||
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
|
||||
// v1.18.11 (W11) — page surface only: conversation history is a LEFT
|
||||
// slide-in drawer (overlay on every viewport, ChatGPT-style), opened from
|
||||
// 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.
|
||||
// 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
|
||||
@@ -425,7 +415,7 @@ export function CoachConversation({
|
||||
}
|
||||
showHub={surface === "page"}
|
||||
onNewChat={handleNewChat}
|
||||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||
onOpenHistory={() => router.push("/coach/conversations")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -461,7 +451,19 @@ export function CoachConversation({
|
||||
// it. When the server returns no signal the hint is null and the neutral
|
||||
// greeting stands — never a fabricated opener.
|
||||
const a2Metric = launchScope?.metric ?? null;
|
||||
const seededEnabled = heroActive && a2Metric === null;
|
||||
// v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone
|
||||
// for the rest of the local calendar day; the fetch is skipped too, so a
|
||||
// dismissed day costs nothing. SSR-safe lazy init guards `window`.
|
||||
const [seededDismissedToday, setSeededDismissedToday] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(seededDismissStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const seededEnabled =
|
||||
heroActive && a2Metric === null && !seededDismissedToday;
|
||||
const { data: seeded } = useQuery({
|
||||
queryKey: queryKeys.coachSeededQuestion(),
|
||||
queryFn: async () =>
|
||||
@@ -474,6 +476,17 @@ export function CoachConversation({
|
||||
setInputValue(question);
|
||||
}
|
||||
|
||||
function dismissSeeded() {
|
||||
setSeededDismissedToday(true);
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(seededDismissStorageKey(), "1");
|
||||
} catch {
|
||||
// Storage can throw (private mode / quota); the in-memory flag still
|
||||
// hides the opener for this session.
|
||||
}
|
||||
}
|
||||
|
||||
// `t()` returns the key string itself when a key is missing, so the only
|
||||
// reliable "is this key defined" signal is `t(key) !== key`.
|
||||
function tOrNull(key: string): string | null {
|
||||
@@ -507,7 +520,7 @@ export function CoachConversation({
|
||||
onSeed={seedComposer}
|
||||
/>
|
||||
);
|
||||
} else if (seeded?.signal) {
|
||||
} else if (seeded?.signal && !seededDismissedToday) {
|
||||
// A3 — the notable derived signal. The label + opener are keyed on the
|
||||
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
|
||||
// (future detector additions) skips the opener rather than guessing.
|
||||
@@ -523,6 +536,7 @@ export function CoachConversation({
|
||||
label={signalLabel}
|
||||
question={signalQuestion}
|
||||
onSeed={seedComposer}
|
||||
onDismiss={dismissSeeded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -577,41 +591,6 @@ export function CoachConversation({
|
||||
</div>
|
||||
);
|
||||
|
||||
// v1.18.11 (W11) — page surface: the conversation history is a LEFT
|
||||
// slide-in drawer (overlay on every viewport), opened from the composer's
|
||||
// `+` actions menu. Reuses the shared `<HistoryRail>` + conversation-list
|
||||
// query; respects `prefers-reduced-motion` (the Sheet primitive disables
|
||||
// its slide there). Selecting a conversation closes the drawer.
|
||||
const historyDrawer = (
|
||||
<Sheet open={historyDrawerOpen} onOpenChange={setHistoryDrawerOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
data-slot="coach-history-drawer"
|
||||
className="w-[88vw] max-w-[340px] p-0"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
hideHeading
|
||||
onSelect={(id) => {
|
||||
// v1.16.5 — switching conversations drops the guided session;
|
||||
// unanswered questions stay pending.
|
||||
setCurrentConversationId(id);
|
||||
setHistoryDrawerOpen(false);
|
||||
setPendingAdopt(null);
|
||||
dispatchGuided({ type: "RESET" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
// v1.18.11 (W11) — the PAGE surface drops the top header bar and the
|
||||
// rail-tray strip entirely. The composer is the single control hub; the
|
||||
// thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
|
||||
@@ -626,32 +605,9 @@ export function CoachConversation({
|
||||
data-variant={surface}
|
||||
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
||||
>
|
||||
{/* v1.21.0 — the page toolbar is now a single trailing affordance:
|
||||
the settings gear in the top-right corner. The "Conversations"
|
||||
and "New chat" controls were removed here — both still live in the
|
||||
composer's `+` actions menu, keeping the new-chat surface calm and
|
||||
uncluttered. The gear deep-links to Settings → AI (one place for
|
||||
model + behaviour), matching the drawer header's gear. */}
|
||||
<div
|
||||
data-slot="coach-page-toolbar"
|
||||
className="flex shrink-0 items-center justify-end px-4 pt-2 sm:px-6"
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="coach-page-settings"
|
||||
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
|
||||
>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
aria-label={t("insights.coach.settingsAriaLabel")}
|
||||
title={t("insights.coach.settingsAriaLabel")}
|
||||
>
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
|
||||
in the composer's `+` actions menu alongside New chat and
|
||||
Conversations, keeping the page chrome to the composer alone. */}
|
||||
{heroActive ? (
|
||||
<CoachHero composer={composerNode} scopeHint={scopeHint} />
|
||||
) : (
|
||||
@@ -675,7 +631,6 @@ export function CoachConversation({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{historyDrawer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,16 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { Loader2, MessagesSquare, Mic, Plus, Send, Square } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Plus,
|
||||
Send,
|
||||
Settings,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -479,6 +488,12 @@ export function CoachInput({
|
||||
<MessagesSquare className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.historyTitle")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
|
||||
<Link href="/settings/ai">
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.settings")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, Target } from "lucide-react";
|
||||
import { Sparkles, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -43,6 +43,12 @@ export interface ScopeHintBadgeProps {
|
||||
question: string;
|
||||
/** Fired with the seed question when the user taps the opener. */
|
||||
onSeed: (question: string) => void;
|
||||
/**
|
||||
* Dismiss the opener. Only honoured for `variant="seeded"` — the seeded
|
||||
* opener is a soft suggestion the user can wave off for the day, while the
|
||||
* A2 scope pill reflects how the chat was launched and is not dismissable.
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -51,6 +57,7 @@ export function ScopeHintBadge({
|
||||
label,
|
||||
question,
|
||||
onSeed,
|
||||
onDismiss,
|
||||
className,
|
||||
}: ScopeHintBadgeProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -84,26 +91,49 @@ export function ScopeHintBadge({
|
||||
</span>
|
||||
|
||||
{/* The tappable seed question — the data-aware opener. A real button so
|
||||
keyboard + screen-reader users get the same affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-seed"
|
||||
onClick={() => onSeed(question)}
|
||||
className={cn(
|
||||
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
|
||||
"flex w-full max-w-md items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm leading-snug">{question}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
|
||||
keyboard + screen-reader users get the same affordance. The opener
|
||||
fills the column (= the composer's max-width) so it reads as one line
|
||||
with the field below; the seeded variant adds a dismiss control at the
|
||||
trailing edge, mirroring the composer's send affordance. */}
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-seed"
|
||||
onClick={() => onSeed(question)}
|
||||
className={cn(
|
||||
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
|
||||
"flex min-w-0 flex-1 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{t("insights.coach.scope.tap")}
|
||||
</span>
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||
{question}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground text-xs whitespace-nowrap"
|
||||
>
|
||||
{t("insights.coach.scope.tap")}
|
||||
</span>
|
||||
</button>
|
||||
{variant === "seeded" && onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-scope-hint-dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("insights.coach.seeded.dismiss")}
|
||||
title={t("insights.coach.seeded.dismiss")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
|
||||
"flex size-9 items-center justify-center rounded-xl transition-colors",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user