mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in the conversation itself: a round 56 px button with a plus, the floating Coach button's face, named "New conversation". It sits in the composer's row, so it always belongs to the chat column, never to the list or a docked day beside it. Where the column is wide it takes the bottom right corner beside the composer; where the composer fills the width (a phone, the drawer, a narrow column) it rises above the composer's edge. The drawer shows it once there is a conversation to leave, in place of the plus in its header. An empty conversation shows nothing either way. The docked conversations panel folds like the day: its header opens with a fold control, and folded it keeps a 48 px edge whose button opens it again (aria-expanded, focus moved between the two, Escape inside folds it). The top bar's toggle remains for the sheet below 1280 px. On /coach the day docks right of the panel. Below 1600 px only one of the two is open: a day opening folds the list without touching the remembered choice, and opening the list folds the day to its edge. From 1600 px both stay open. Switching or starting a conversation keeps an open day in the URL. The empty conversation drops everything under the composer: the daily seeded opener and the scope pill are gone, and with them the only reader of GET /api/insights/coach/seeded-question and of the coach.ambientSuggestions preference. The route, its OpenAPI entry, the preference and its Settings switch go too.
This commit is contained in:
1 parent
9d5a529d53
commit
08f532b428
29 files changed
+659
-1338
No files matched your search
@@ -31,12 +31,17 @@ describe("<CoachHero>", () => {
|
||||
expect(html).toContain("Frage mich etwas zu deinen Daten");
|
||||
});
|
||||
|
||||
it("does not render starter-question suggestion chips", () => {
|
||||
// v1.18.10 (W4) — the two starter chips below the composer were
|
||||
// removed; the hero is greeting + composer only.
|
||||
const html = render(<CoachHero composer={null} />);
|
||||
it("leaves nothing under the composer", () => {
|
||||
// No starter chips, no seeded opener, no scope pill: the empty
|
||||
// conversation is the greeting and the field.
|
||||
const html = render(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-hero-chips"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-chip"');
|
||||
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
|
||||
expect(html).not.toContain("coach-scope-hint");
|
||||
const afterComposer = html.split('data-slot="test-composer"')[1] ?? "";
|
||||
expect(afterComposer.replace(/<\/div>|composer|>/g, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { BOTH_PANELS_QUERY, listYields } from "../conversations-panel";
|
||||
import { NewChatFab } from "../new-chat-fab";
|
||||
|
||||
/**
|
||||
* The Coach page holds two docked panels beside the conversation: the
|
||||
* conversations and, right of them, the day. Below 1600 px only one is open.
|
||||
* The list folds when a day opens or the window narrows with both open; it
|
||||
* never folds again right after the person opened it beside a day (that
|
||||
* folds the day instead). The click paths and focus run in
|
||||
* `e2e/coach-layout.spec.ts`.
|
||||
*/
|
||||
const base = {
|
||||
docked: true,
|
||||
fitsBoth: false,
|
||||
listOpen: true,
|
||||
dayOpen: true,
|
||||
dayOpened: false,
|
||||
narrowed: false,
|
||||
};
|
||||
|
||||
describe("listYields", () => {
|
||||
it("folds the list when a day opens below 1600 px", () => {
|
||||
expect(listYields({ ...base, dayOpened: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("folds the list when the window narrows with both open", () => {
|
||||
expect(listYields({ ...base, narrowed: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps both open from 1600 px", () => {
|
||||
expect(listYields({ ...base, fitsBoth: true, dayOpened: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a list the person opened beside a day alone", () => {
|
||||
// No opening and no narrowing: the day is the one that gives way.
|
||||
expect(listYields(base)).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for a folded list, a closed day, or the sheet", () => {
|
||||
expect(listYields({ ...base, dayOpened: true, listOpen: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(listYields({ ...base, narrowed: true, dayOpen: false })).toBe(false);
|
||||
expect(listYields({ ...base, dayOpened: true, docked: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("splits at 1600 px", () => {
|
||||
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folded list", () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/conversations-panel.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps an edge with its button, and sits left of the day", () => {
|
||||
expect(source).toContain('data-slot="coach-panel-rail"');
|
||||
expect(source).toContain('data-slot="coach-panel-expand"');
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
|
||||
expect(source).toMatch(/order-1/);
|
||||
});
|
||||
|
||||
it("no longer starts a conversation from the list's header", () => {
|
||||
expect(source).not.toContain("coach-panel-new-chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<NewChatFab>", () => {
|
||||
it("is the Coach button's round face with a plus, named New conversation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NewChatFab onNewChat={() => undefined} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const button = html.match(/<button[^>]*>/)?.[0] ?? "";
|
||||
expect(button).toContain('data-slot="coach-new-chat-fab"');
|
||||
expect(button).toContain('aria-label="New conversation"');
|
||||
expect(button).toContain("size-14");
|
||||
expect(button).toContain("rounded-full");
|
||||
expect(button).toContain("from-primary");
|
||||
// Above the composer by default, beside it where the column is wide.
|
||||
expect(button).toContain("bottom-full");
|
||||
expect(button).toContain("@min-[54rem]/composer:bottom-8");
|
||||
expect(html).toContain("lucide-plus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coachPageHref", () => {
|
||||
it("keeps an open day when the conversation changes", async () => {
|
||||
const { coachPageHref } = await import("../coach-conversation");
|
||||
expect(coachPageHref("c1")).toBe("/coach?c=c1");
|
||||
expect(coachPageHref(null)).toBe("/coach");
|
||||
expect(coachPageHref("c1", "?c=c0&day=2026-09-24")).toBe(
|
||||
"/coach?c=c1&day=2026-09-24",
|
||||
);
|
||||
expect(coachPageHref(null, "?day=2026-09-24&c=c0")).toBe(
|
||||
"/coach?day=2026-09-24",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ScopeHintBadge } from "../scope-hint-badge";
|
||||
import { CoachHero } from "../coach-hero";
|
||||
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A2 + A3) — the visible scope/opener affordance.
|
||||
*
|
||||
* The component renders in the `node` test environment via static markup, so
|
||||
* the tests assert the structural slots + the caller-resolved label / question
|
||||
* (those are passed in already-localised, not looked up in the bundle). The
|
||||
* prefix/tap copy resolves through the live i18n bundle.
|
||||
*/
|
||||
describe("<ScopeHintBadge>", () => {
|
||||
it("renders the scope pill with the active metric and the tappable seed", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="blood pressure"
|
||||
question="Walk me through my blood pressure trend."
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-scope-hint"');
|
||||
expect(html).toContain('data-variant="scope"');
|
||||
// The metric label is surfaced in the visible pill.
|
||||
expect(html).toContain('data-slot="coach-scope-hint-pill"');
|
||||
expect(html).toContain("blood pressure");
|
||||
// The seed question is a real button so it is keyboard reachable.
|
||||
expect(html).toContain('data-slot="coach-scope-hint-seed"');
|
||||
expect(html).toContain("Walk me through my blood pressure trend.");
|
||||
});
|
||||
|
||||
it("renders the seeded variant for a notable derived signal", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-variant="seeded"');
|
||||
expect(html).toContain("readiness");
|
||||
expect(html).toContain("Why is my readiness lower today?");
|
||||
});
|
||||
|
||||
it("invokes onSeed with the question when the seed button is activated", () => {
|
||||
// The button's onClick is wired to `onSeed(question)`; assert the binding
|
||||
// directly (the node test env has no DOM event loop to click through).
|
||||
const onSeed = vi.fn();
|
||||
const question = "Walk me through my pulse readings.";
|
||||
// Render once to prove no throw, then exercise the handler contract.
|
||||
render(
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label="pulse"
|
||||
question={question}
|
||||
onSeed={onSeed}
|
||||
/>,
|
||||
);
|
||||
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("sizes the seeded opener to its text and keeps it single-line", () => {
|
||||
const html = render(
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label="readiness"
|
||||
question="Why is my readiness lower today?"
|
||||
onSeed={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
const seedTag = html.match(
|
||||
/<button[^>]*data-slot="coach-scope-hint-seed"[^>]*>/,
|
||||
);
|
||||
expect(seedTag).not.toBeNull();
|
||||
// v1.25.0 — intrinsic width, not stretched to the composer column: the
|
||||
// opener grows to its text and caps at the full width. The old narrow cap
|
||||
// and the full-width stretch are both gone.
|
||||
expect(html).not.toContain("max-w-md");
|
||||
// Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not
|
||||
// the old `flex-1` / `w-full` full-width stretch.
|
||||
expect(seedTag?.[0]).toContain("inline-flex");
|
||||
expect(seedTag?.[0]).toContain("max-w-full");
|
||||
expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/);
|
||||
// The question span truncates with an ellipsis rather than wrapping.
|
||||
expect(html).toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CoachHero> scope hint slot", () => {
|
||||
it("renders the scope hint above-composer slot when provided", () => {
|
||||
const html = render(
|
||||
<CoachHero
|
||||
composer={<div data-slot="test-composer">composer</div>}
|
||||
scopeHint={<div data-slot="injected-hint">hint</div>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-hero-scope-hint"');
|
||||
expect(html).toContain('data-slot="injected-hint"');
|
||||
});
|
||||
|
||||
it("falls back to the neutral hero when no scope hint is given", () => {
|
||||
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
|
||||
// is the calm greeting + composer with NO opener affordance.
|
||||
const html = render(
|
||||
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-hero"');
|
||||
expect(html).toContain("Ask me anything about your data");
|
||||
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricScopeLabelFallback", () => {
|
||||
it("maps a scope source to its brand-free domain label", () => {
|
||||
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
|
||||
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
|
||||
});
|
||||
|
||||
it("returns null for an absent metric (a generic open)", () => {
|
||||
expect(metricScopeLabelFallback(null)).toBeNull();
|
||||
expect(metricScopeLabelFallback(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,17 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Paperclip, Plus, Sparkles } from "lucide-react";
|
||||
import { Paperclip, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { DAY_QUERY_PARAM } from "@/lib/day/contract";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
|
||||
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
|
||||
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
|
||||
import { CoachDrawerBody } from "./coach-drawer-body";
|
||||
import { CoachHero } from "./coach-hero";
|
||||
import { ScopeHintBadge } from "./scope-hint-badge";
|
||||
import { CoachInput } from "./coach-input";
|
||||
import {
|
||||
GuidedQuestionBubble,
|
||||
@@ -53,12 +51,12 @@ import { CoachSettingsOverlay } from "./coach-settings-overlay";
|
||||
import { CoachTopBarTrail } from "./coach-top-bar-trail";
|
||||
import { ConversationsPanel } from "./conversations-panel";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { NewChatFab } from "./new-chat-fab";
|
||||
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
|
||||
import { AttachmentPicker } from "./attachment-picker";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
|
||||
import {
|
||||
useAttachCoachDocument,
|
||||
useCoachConversation,
|
||||
@@ -127,19 +125,6 @@ export function dropInvalidStagedAttachments(
|
||||
return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -269,10 +254,18 @@ export interface CoachConversationProps {
|
||||
* `useSearchParams` in step with it) so a reload lands on the same thread
|
||||
* without a server round trip or a new history entry per switch.
|
||||
*/
|
||||
function coachPageHref(conversationId: string | null): string {
|
||||
return conversationId
|
||||
export function coachPageHref(
|
||||
conversationId: string | null,
|
||||
search = "",
|
||||
): string {
|
||||
const base = conversationId
|
||||
? `/coach?c=${encodeURIComponent(conversationId)}`
|
||||
: "/coach";
|
||||
// An open day stays open: the day docked beside the conversation is the
|
||||
// URL's `?day=`, and switching conversations must not close it.
|
||||
const day = new URLSearchParams(search).get(DAY_QUERY_PARAM);
|
||||
if (!day) return base;
|
||||
return `${base}${conversationId ? "&" : "?"}${DAY_QUERY_PARAM}=${encodeURIComponent(day)}`;
|
||||
}
|
||||
|
||||
export function CoachConversation({
|
||||
@@ -372,7 +365,7 @@ export function CoachConversation({
|
||||
// a shared tab) lands on it. The drawer has no URL of its own.
|
||||
function syncPageUrl(conversationId: string | null) {
|
||||
if (surface !== "page" || typeof window === "undefined") return;
|
||||
const href = coachPageHref(conversationId);
|
||||
const href = coachPageHref(conversationId, window.location.search);
|
||||
if (window.location.pathname + window.location.search === href) return;
|
||||
window.history.replaceState(window.history.state, "", href);
|
||||
}
|
||||
@@ -973,62 +966,16 @@ export function CoachConversation({
|
||||
pendingQuestions.length === 0 &&
|
||||
!pendingAdopt;
|
||||
|
||||
// v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero.
|
||||
//
|
||||
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
|
||||
// visible — a "the Coach is already on <metric>" pill plus the data-aware
|
||||
// seed question (the launch prefill) the user can tap into the composer —
|
||||
// instead of the old hidden prefill.
|
||||
//
|
||||
// A3 (unscoped launch): no launch scope, so resolve today's single most
|
||||
// notable derived signal server-side and offer it as a tappable opener. The
|
||||
// query only fires when the hero is on screen AND there is no launch scope
|
||||
// (an A2 launch already has its opener), so a scoped open pays nothing for
|
||||
// it. When the server returns no signal the hint is null and the neutral
|
||||
// greeting stands — never a fabricated opener.
|
||||
// Something to leave: a stored conversation or a turn on its way.
|
||||
const threadStarted =
|
||||
currentConversationId !== null ||
|
||||
send.isStreaming ||
|
||||
!!send.optimisticUser ||
|
||||
!!send.streaming.content;
|
||||
|
||||
// The launch scope, for the drawer's sources rail: a conversation opened
|
||||
// from a metric surface names the metric there until its first turn.
|
||||
const a2Metric = launchScope?.metric ?? 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;
|
||||
}
|
||||
});
|
||||
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
|
||||
// seeded opener (and skips its fetch when off). The A2 launch scope below is
|
||||
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
|
||||
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
|
||||
const seededEnabled =
|
||||
heroActive &&
|
||||
a2Metric === null &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled;
|
||||
const { data: seeded } = useQuery({
|
||||
queryKey: queryKeys.coachSeededQuestion(),
|
||||
queryFn: async () =>
|
||||
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
|
||||
enabled: seededEnabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
function seedComposer(question: string) {
|
||||
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`.
|
||||
@@ -1037,10 +984,9 @@ export function CoachConversation({
|
||||
return resolved === key ? null : resolved;
|
||||
}
|
||||
|
||||
// The resolved A2 metric label: prefers a per-source i18n key, falls back
|
||||
// to the brand-free English domain phrase so every source has a label.
|
||||
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
|
||||
// the same string. Null when the launch carried no metric.
|
||||
// The resolved metric label: prefers a per-source i18n key, falls back to
|
||||
// the brand-free English domain phrase so every source has a label. Null
|
||||
// when the launch carried no metric.
|
||||
const a2LabelKey = scopeSourceMetricLabelKey(a2Metric);
|
||||
const a2MetricLabel = a2Metric
|
||||
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
|
||||
@@ -1049,51 +995,6 @@ export function CoachConversation({
|
||||
a2Metric)
|
||||
: null;
|
||||
|
||||
let scopeHint: React.ReactNode = null;
|
||||
if (fenced) {
|
||||
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
|
||||
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
|
||||
// honest fencing banner (below) is the only chrome the fenced hero shows.
|
||||
scopeHint = null;
|
||||
} else if (a2Metric && a2MetricLabel) {
|
||||
// A2 — the launch prefill IS the data-aware opener; fall back to the
|
||||
// generic per-metric question when the launch carried no prefill.
|
||||
const seedQuestion =
|
||||
(prefill ?? "").trim() || t("insights.coach.scope.question");
|
||||
scopeHint = (
|
||||
<ScopeHintBadge
|
||||
variant="scope"
|
||||
label={a2MetricLabel}
|
||||
question={seedQuestion}
|
||||
onSeed={seedComposer}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
seeded?.signal &&
|
||||
!seededDismissedToday &&
|
||||
ambientSuggestionsEnabled
|
||||
) {
|
||||
// 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.
|
||||
const sentinel = seeded.signal.sourceMetric;
|
||||
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
|
||||
const signalQuestion = tOrNull(
|
||||
`insights.coach.seeded.question.${sentinel}`,
|
||||
);
|
||||
if (signalLabel && signalQuestion) {
|
||||
scopeHint = (
|
||||
<ScopeHintBadge
|
||||
variant="seeded"
|
||||
label={signalLabel}
|
||||
question={signalQuestion}
|
||||
onSeed={seedComposer}
|
||||
onDismiss={dismissSeeded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
|
||||
// the guided-questions entry card stack above the live composer. Shared by
|
||||
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
|
||||
@@ -1218,7 +1119,7 @@ export function CoachConversation({
|
||||
/>
|
||||
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
|
||||
{heroActive ? (
|
||||
<CoachHero composer={composerNode} scopeHint={scopeHint} />
|
||||
<CoachHero composer={composerNode} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -1234,9 +1135,10 @@ export function CoachConversation({
|
||||
</div>
|
||||
<div
|
||||
data-slot="coach-page-composer"
|
||||
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
className="@container/composer relative shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
|
||||
<NewChatFab onNewChat={handleNewChat} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1300,18 +1202,6 @@ export function CoachConversation({
|
||||
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
data-slot="coach-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
title={t("insights.coach.newChat")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{/* The same quick settings the page's panel opens: model and what
|
||||
the Coach may read. Everything else stays in Settings → AI. */}
|
||||
<CoachSettingsOverlay
|
||||
@@ -1342,6 +1232,11 @@ export function CoachConversation({
|
||||
/>
|
||||
}
|
||||
composer={composerStack}
|
||||
// New conversation floats in the conversation itself, once there is
|
||||
// one to leave.
|
||||
newChat={
|
||||
threadStarted ? <NewChatFab onNewChat={handleNewChat} /> : null
|
||||
}
|
||||
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
export interface CoachDrawerBodyProps {
|
||||
thread: React.ReactNode;
|
||||
composer: React.ReactNode;
|
||||
/** The New conversation button, floating above the composer. */
|
||||
newChat?: React.ReactNode;
|
||||
/**
|
||||
* Inline conversation list (page surface only). When set, it renders
|
||||
* as a collapsible left column on lg+ and the history button collapses
|
||||
@@ -67,6 +69,7 @@ export interface CoachDrawerBodyProps {
|
||||
export function CoachDrawerBody({
|
||||
thread,
|
||||
composer,
|
||||
newChat,
|
||||
historyRail,
|
||||
historyOpen = false,
|
||||
onToggleHistory,
|
||||
@@ -234,9 +237,10 @@ export function CoachDrawerBody({
|
||||
{/* Composer pinned to the bottom. */}
|
||||
<div
|
||||
data-slot="coach-drawer-composer"
|
||||
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
className="border-border/70 @container/composer relative flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
|
||||
>
|
||||
{composer}
|
||||
{newChat}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
|
||||
* users get the layout instantly.
|
||||
*
|
||||
* v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot
|
||||
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
|
||||
* pill + seed question when launched scoped (A2), or the pre-seeded
|
||||
* notable-signal opener when launched unscoped (A3). When absent the hero
|
||||
* is the calm greeting + composer exactly as before, so the neutral
|
||||
* blank-state fallback is structural.
|
||||
* The space under the composer stays empty: no suggested prompts and no
|
||||
* openers, so an empty conversation is the greeting and the field.
|
||||
*/
|
||||
export interface CoachHeroProps {
|
||||
/** The live `<CoachInput>` composer, re-parented into the hero. */
|
||||
composer: React.ReactNode;
|
||||
/**
|
||||
* v1.21.2 — optional scope/opener affordance rendered above the
|
||||
* composer. The parent owns the data (launch scope / seeded signal) and
|
||||
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
|
||||
*/
|
||||
scopeHint?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
export function CoachHero({ composer }: CoachHeroProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -104,23 +94,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
>
|
||||
{composer}
|
||||
</div>
|
||||
|
||||
{/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath
|
||||
the composer so the greeting → composer rhythm stays intact and
|
||||
the opener reads as a follow-on suggestion. Renders only when the
|
||||
parent resolved a scope or a notable signal. */}
|
||||
{scopeHint ? (
|
||||
<div
|
||||
data-slot="coach-hero-scope-hint"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
|
||||
)}
|
||||
style={{ animationDelay: "240ms", animationFillMode: "both" }}
|
||||
>
|
||||
{scopeHint}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
|
||||
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
|
||||
|
||||
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -42,18 +43,26 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Docked, the panel is a column of the shell beside the content column
|
||||
* (top bar and page): it runs the full height of the window, and the top bar
|
||||
* ends at its left edge. The panel's header row is the top bar's band, so
|
||||
* the two bottom borders draw one line. Shut, it is gone entirely (no rail)
|
||||
* and the top bar runs to the window's edge. The toggle is the last item of
|
||||
* the top bar, so it sits against the open panel and at the window's edge
|
||||
* when the panel is shut. The panel header carries the title, a link to
|
||||
* Plans, the settings gear and, in the sheet, a close button. New chat
|
||||
* opens the header row, left of the title: a fixed control that never sits
|
||||
* over the list, so the last conversation needs no reserve below it.
|
||||
* the two bottom borders draw one line. It folds like the day beside it: the
|
||||
* header's first control folds it to a narrow edge, and the edge's button
|
||||
* (in the same place) opens it again. Folded, the edge names the list.
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
*
|
||||
* Keyboard: Escape closes the docked panel when focus is inside it and
|
||||
* The day docks right of the panel on this page too (page, conversations,
|
||||
* day). Below 1600 px only one of the two is open: a day opening folds the
|
||||
* list to its edge without touching the remembered choice, and opening the
|
||||
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
|
||||
*
|
||||
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
|
||||
* the top bar.
|
||||
*
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key, then focus returns to the toggle. In the sheet
|
||||
* Radix owns Escape, the focus trap and the return.
|
||||
* already claimed the key; focus moves to the edge's button, and opening
|
||||
* from there lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
|
||||
@@ -68,6 +77,50 @@ const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
|
||||
|
||||
const noSubscription = () => () => {};
|
||||
|
||||
/** Wide enough for the conversations and a day beside the conversation. */
|
||||
export const BOTH_PANELS_QUERY = "(min-width: 1600px)";
|
||||
|
||||
function subscribeBothPanels(callback: () => void): () => void {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mql = window.matchMedia(BOTH_PANELS_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function useBothPanelsFit(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeBothPanels,
|
||||
() =>
|
||||
typeof window !== "undefined" && !!window.matchMedia
|
||||
? window.matchMedia(BOTH_PANELS_QUERY).matches
|
||||
: false,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the docked list gives way to an open day: it does when the day
|
||||
* opens, or the window narrows, while both would be open below 1600 px.
|
||||
* Only those two moments fold it, so opening the list again beside an open
|
||||
* day (which folds the day) is never undone.
|
||||
*/
|
||||
export function listYields(state: {
|
||||
docked: boolean;
|
||||
fitsBoth: boolean;
|
||||
listOpen: boolean;
|
||||
dayOpened: boolean;
|
||||
narrowed: boolean;
|
||||
dayOpen: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
state.docked &&
|
||||
!state.fitsBoth &&
|
||||
state.listOpen &&
|
||||
state.dayOpen &&
|
||||
(state.dayOpened || state.narrowed)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone sheet is open. A settings deep link opens it only once
|
||||
* the viewport is known: the server and hydration renders take the narrow
|
||||
@@ -181,22 +234,62 @@ export function ConversationsPanel({
|
||||
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
|
||||
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
|
||||
|
||||
const dockedOpen = rememberedOpen || revealed;
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
|
||||
function toggle() {
|
||||
if (docked) {
|
||||
setRevealed(false);
|
||||
setOpen(!dockedOpen);
|
||||
} else {
|
||||
setSheetOpen(!sheetOpen);
|
||||
// The day beside the list (docked on this page from 1280 px too).
|
||||
const openDay = useOpenDay();
|
||||
const fitsBoth = useBothPanelsFit();
|
||||
// Folded for the day, without rewriting the remembered choice.
|
||||
const [yielded, setYielded] = useState(false);
|
||||
const dockedOpen = (rememberedOpen || revealed) && !yielded;
|
||||
const [seen, setSeen] = useState<{ day: string | null; fitsBoth: boolean }>({
|
||||
day: null,
|
||||
fitsBoth,
|
||||
});
|
||||
if (seen.day !== openDay || seen.fitsBoth !== fitsBoth) {
|
||||
setSeen({ day: openDay, fitsBoth });
|
||||
if (
|
||||
listYields({
|
||||
docked,
|
||||
fitsBoth,
|
||||
listOpen: dockedOpen,
|
||||
dayOpen: openDay !== null,
|
||||
dayOpened: seen.day === null && openDay !== null,
|
||||
narrowed: seen.fitsBoth && !fitsBoth,
|
||||
})
|
||||
) {
|
||||
setYielded(true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocked() {
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
useEffect(() => {
|
||||
const target = focusAfter.current;
|
||||
focusAfter.current = null;
|
||||
if (target === "collapse") collapseRef.current?.focus();
|
||||
if (target === "expand") expandRef.current?.focus();
|
||||
}, [dockedOpen]);
|
||||
|
||||
function toggleSheet() {
|
||||
setSheetOpen(!sheetOpen);
|
||||
}
|
||||
|
||||
function expandDocked() {
|
||||
focusAfter.current = "collapse";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
// Below 1600 px the day makes room: it folds to its edge.
|
||||
if (!fitsBoth && openDay !== null) yieldDay();
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
focusAfter.current = "expand";
|
||||
setYielded(false);
|
||||
setRevealed(false);
|
||||
setOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
|
||||
function afterPick() {
|
||||
@@ -224,32 +317,34 @@ export function ConversationsPanel({
|
||||
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
afterPick();
|
||||
}}
|
||||
data-slot="coach-panel-new-chat"
|
||||
aria-label={t("insights.coach.newChat")}
|
||||
// The panel's one action, so the primary wash marks it apart
|
||||
// from the muted utility buttons on the right.
|
||||
className={cn(
|
||||
PANEL_HEADER_BUTTON,
|
||||
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.newChat")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{inSheet ? null : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={collapseRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={collapseDocked}
|
||||
data-slot="coach-panel-collapse"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={true}
|
||||
aria-label={t("insights.coach.frame.hidePanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
{/* The day's own fold control and glyph, mirrored so it
|
||||
points the way the panel goes. */}
|
||||
<PanelRightClose
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("insights.coach.frame.hidePanel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inSheet ? (
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
|
||||
{t("insights.coach.historyTitle")}
|
||||
@@ -321,14 +416,16 @@ export function ConversationsPanel({
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
</TopBarActions>
|
||||
{docked ? null : (
|
||||
<TopBarActions>
|
||||
<PanelToggle
|
||||
ref={toggleRef}
|
||||
expanded={expanded}
|
||||
label={toggleLabel}
|
||||
onToggle={toggleSheet}
|
||||
/>
|
||||
</TopBarActions>
|
||||
)}
|
||||
|
||||
{docked ? (
|
||||
<ShellSidePanel>
|
||||
@@ -337,28 +434,87 @@ export function ConversationsPanel({
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
inert={!dockedOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
closeDocked();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so it
|
||||
// runs from the top of the window to the bottom beside the top
|
||||
// bar. The width animates (offcanvas: shut, nothing of it
|
||||
// shows); the inner column
|
||||
// keeps 18rem and is pinned to the panel's left edge, so it
|
||||
// slides out to the right instead of squeezing. The thread column
|
||||
// keeps its width; only its centring moves.
|
||||
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
|
||||
// bar, left of a docked day (`order-1`, the day is `order-2`).
|
||||
// The width animates between the open column and the folded
|
||||
// edge; the open column keeps 18rem and is pinned to the
|
||||
// panel's left edge, so it slides instead of squeezing.
|
||||
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
|
||||
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
|
||||
dockedOpen ? "w-72" : "w-0",
|
||||
dockedOpen ? "w-72" : "w-12",
|
||||
)}
|
||||
>
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
{dockedOpen ? (
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="coach-panel-rail"
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the bottom borders draw one line;
|
||||
the button sits where the open panel keeps its fold
|
||||
control. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-sidebar-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={expandDocked}
|
||||
data-slot="coach-panel-expand"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={false}
|
||||
aria-label={t("insights.coach.frame.showPanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("insights.coach.frame.showPanel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the
|
||||
list; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expandDocked}
|
||||
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* New conversation, as the round button in the conversation's own window:
|
||||
* the floating Coach button's size and look, with a plus.
|
||||
*
|
||||
* It sits in the composer's row, so it belongs to the conversation and never
|
||||
* to the list beside it, and it moves wherever that column goes (a docked
|
||||
* list or day narrows the column, the button stays inside it). Where the
|
||||
* column is wide enough, it takes the bottom right corner beside the
|
||||
* composer, where the Coach button sits on other pages; where the composer
|
||||
* fills the width (a phone, the drawer, a narrow column), it rises just
|
||||
* above the composer's top edge so it never covers the field.
|
||||
*
|
||||
* The parent is the composer's wrapper and must be `relative` and an
|
||||
* `@container/composer`.
|
||||
*/
|
||||
export function NewChatFab({
|
||||
onNewChat,
|
||||
className,
|
||||
}: {
|
||||
onNewChat: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const label = t("insights.coach.newConversation");
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-new-chat-fab"
|
||||
// Steps aside while the on-screen keyboard is open (globals.css).
|
||||
data-keyboard-hide=""
|
||||
onClick={onNewChat}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"absolute right-4 bottom-full z-10 mb-3 size-14 rounded-full shadow-lg sm:right-6",
|
||||
// Beside the capped composer, in the column's bottom right corner.
|
||||
"@min-[54rem]/composer:right-8 @min-[54rem]/composer:bottom-8 @min-[54rem]/composer:mb-0",
|
||||
// The Coach button's face: a dark glyph on the brand gradient.
|
||||
"from-primary to-brand-pink text-background bg-gradient-to-br",
|
||||
"hover:from-primary/90 hover:to-brand-pink/90",
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Plus className="text-background size-6" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance.
|
||||
*
|
||||
* Two modes, one surface:
|
||||
*
|
||||
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
|
||||
* metric page or an insight card). Instead of the old hidden composer
|
||||
* prefill, render a small pill — "The Coach is already on <metric>" —
|
||||
* plus a tappable seed question that drops the data-aware opener into
|
||||
* the composer / sends it. This turns the built-but-invisible scope
|
||||
* plumbing into the core ambient feeling.
|
||||
*
|
||||
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
|
||||
* server surfaced today's single most notable derived signal. Render it
|
||||
* as a tappable suggested opener so the blank box is never cold. When no
|
||||
* signal crosses the gate the caller renders nothing and the neutral
|
||||
* greeting stands — there is no fake opener.
|
||||
*
|
||||
* Presentational + self-contained: the parent supplies the resolved label
|
||||
* and the seed question, and an `onSeed` callback wired to the composer.
|
||||
* The badge owns no data fetching.
|
||||
*/
|
||||
export interface ScopeHintBadgeProps {
|
||||
/**
|
||||
* Which affordance this is. `scope` shows the "already on <metric>" pill;
|
||||
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
|
||||
* copy only — the tappable seed question is shared.
|
||||
*/
|
||||
variant: "scope" | "seeded";
|
||||
/**
|
||||
* The visible metric / signal label, already resolved to the user's
|
||||
* locale by the caller (e.g. "blood pressure", "readiness").
|
||||
*/
|
||||
label: string;
|
||||
/** The tappable seed question — the data-aware opener. */
|
||||
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;
|
||||
}
|
||||
|
||||
export function ScopeHintBadge({
|
||||
variant,
|
||||
label,
|
||||
question,
|
||||
onSeed,
|
||||
onDismiss,
|
||||
className,
|
||||
}: ScopeHintBadgeProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const Icon = variant === "scope" ? Target : Sparkles;
|
||||
const prefix =
|
||||
variant === "scope"
|
||||
? t("insights.coach.scope.prefix", { metric: label })
|
||||
: t("insights.coach.seeded.prefix", { signal: label });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-scope-hint"
|
||||
data-variant={variant}
|
||||
className={cn("flex w-full flex-col items-center gap-2.5", className)}
|
||||
>
|
||||
{/* The scope/signal pill — the visible "already on …" line. */}
|
||||
<span
|
||||
data-slot="coach-scope-hint-pill"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-muted-foreground",
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
|
||||
"text-xs font-medium",
|
||||
)}
|
||||
>
|
||||
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{prefix}</span>
|
||||
</span>
|
||||
|
||||
{/* The tappable seed question — the data-aware opener. A real button so
|
||||
keyboard + screen-reader users get the same affordance. v1.25.0 — the
|
||||
opener sizes to its TEXT (intrinsic width, centred) instead of being
|
||||
stretched to the full composer width, which left a large empty tail
|
||||
that read as broken. It is guaranteed single-line: the question
|
||||
truncates with an ellipsis on a narrow phone rather than wrapping or
|
||||
forcing a horizontal scroll. The seeded variant keeps the dismiss
|
||||
control at the trailing edge. */}
|
||||
<div className="flex w-full items-center justify-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",
|
||||
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
|
||||
"transition-colors",
|
||||
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm leading-snug">
|
||||
{question}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-foreground shrink-0 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-input-focus focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,6 @@ interface CoachPrefsShape {
|
||||
nudgeVitals: boolean;
|
||||
nudgeRoutine: boolean;
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
ambientSuggestions: boolean;
|
||||
nudgeAiComposed: boolean;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,6 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
};
|
||||
|
||||
@@ -306,28 +304,6 @@ export function CoachNudgeCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
|
||||
push-nudge master switch above (a user can keep nudges but silence
|
||||
the example prompts), so it sits outside the `enabled` gate and rides
|
||||
the same `coach` prefs blob. */}
|
||||
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{t("notifications.coachNudge.suggestionsLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("notifications.coachNudge.suggestionsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={resolved.ambientSuggestions}
|
||||
onCheckedChange={(next) =>
|
||||
patchCoach({ ambientSuggestions: next }, null)
|
||||
}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.coachNudge.suggestionsAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
Reference in new issue
Block a user