diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index b1b998ee6..7d1591264 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -206,12 +206,22 @@ async function handleChatRequest(request: NextRequest): Promise { // the new user message. v1.4.23 H4 — fold per-user prefs into the // system-prompt prefix; the snapshot builder reads the same prefs // separately so excluded metrics never even leave the DB. + // + // v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved + // analysis-window preference. Merge it into the snapshot scope when + // the client didn't supply a per-conversation override; the override + // (header pill / sources rail) always wins. Keep the merge cheap so + // we don't accidentally widen narrow per-call scopes. const prefsRow = await prisma.user.findUnique({ where: { id: userId }, select: { coachPrefsJson: true }, }); const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson); - const snapshot = await buildCoachSnapshot(userId, scope); + const effectiveScope = + scope?.window === undefined && coachPrefs.defaultWindow + ? { ...(scope ?? {}), window: coachPrefs.defaultWindow } + : scope; + const snapshot = await buildCoachSnapshot(userId, effectiveScope); const systemPrompt = getCoachSystemPrompt(locale, coachPrefs); const window = buildHistoryWindow([ ...priorTurns, diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx index 9cce6fedf..db6c8c2b8 100644 --- a/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx @@ -104,4 +104,14 @@ describe("", () => { expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1); expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved."); }); + + // v1.4.25 W5 — defaultWindow picker added. + it("renders the default-window picker once persisted prefs land", () => { + queryState.data = DEFAULT_COACH_PREFS; + const html = render( + {}} />, + ); + expect(html).toContain('data-slot="coach-prefs-default-window"'); + expect(html).toContain("Default analysis window"); + }); }); diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 7179e6209..d3f7afa72 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -5,6 +5,13 @@ import type { Dispatch, SetStateAction } from "react"; import { Plus, Settings, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Sheet, SheetContent, @@ -14,6 +21,7 @@ import { } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { useCoachPrefs } from "@/hooks/use-coach-prefs"; import { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; @@ -153,6 +161,13 @@ export function CoachDrawer({ // selector). Resets to the all-source last30days default each time // the drawer mounts; no conversation-level persistence in this // hotfix per the v1.4.20.1 plan. + // + // v1.4.25 W5 — the scope.window field is now a two-layer override: + // • the user's saved `coachPrefs.defaultWindow` is the base + // • the header pill drops a per-conversation override into + // `windowOverride`; null = "use the saved default" + // The override resets to null on drawer close so the next session + // starts on the user's saved preference. const [scope, setScope] = useState<{ sources: CoachScopeSource[]; window: CoachScopeWindow; @@ -160,15 +175,31 @@ export function CoachDrawer({ sources: [...DEFAULT_COACH_SCOPE.sources], window: DEFAULT_COACH_SCOPE.window, })); + const [windowOverride, setWindowOverride] = + useState(null); + + // v1.4.25 W5 — load the user's saved Coach prefs so the default + // window picks up the cog's saved selection. The hook gates the + // fetch on `enabled` so the network call only fires while the + // drawer is open. Falls through to the legacy "last30days" default + // when the row is missing or the request is in flight. + const { data: coachPrefs } = useCoachPrefs({ enabled: open }); + const savedDefaultWindow: CoachScopeWindow = + coachPrefs?.defaultWindow ?? DEFAULT_COACH_SCOPE.window; + const effectiveWindow: CoachScopeWindow = + windowOverride ?? savedDefaultWindow; const handleOpenChange = useCallback( (next: boolean) => { if (!next) { // Reset thread + composer on close so the next open starts on // the rail's empty hint instead of re-rendering the previous - // conversation by accident. + // conversation by accident. v1.4.25 W5 — also drop the + // per-conversation window override so the next session starts + // on the user's saved `coachPrefs.defaultWindow` again. setCurrentConversationId(null); setInputValue(""); + setWindowOverride(null); } onOpenChange(next); }, @@ -189,15 +220,21 @@ export function CoachDrawer({ // Pass the scope only when the user has narrowed it from the // defaults — keeps the wire payload minimal and lets the route // tell "no opinion" apart from "intentionally narrow". - const isDefault = - scope.window === DEFAULT_COACH_SCOPE.window && + // + // v1.4.25 W5 — `scope.window` now reflects the per-conversation + // override (header pill / rail picker) layered on top of the saved + // `coachPrefs.defaultWindow`. Send the override only when the user + // explicitly diverged from the saved default; otherwise the route + // re-applies the saved preference itself. + const allSourcesSelected = scope.sources.length === DEFAULT_COACH_SCOPE.sources.length && DEFAULT_COACH_SCOPE.sources.every((s) => scope.sources.includes(s)); + const isDefault = allSourcesSelected && windowOverride === null; const scopePayload: CoachScope | undefined = isDefault ? undefined : { sources: scope.sources, - window: scope.window, + window: effectiveWindow, }; await send.send({ conversationId: currentConversationId ?? undefined, @@ -256,6 +293,49 @@ export function CoachDrawer({ {t("insights.coach.tagline")} + {/* v1.4.25 W5 — per-conversation window override. The pill + defaults to the user's saved `coachPrefs.defaultWindow` + and resets to it on drawer close. Changing the pill flips + `windowOverride`; the rail's window picker mirrors the + same source-of-truth so the user can drive the override + from either surface. */} +