From 1a642f117364e4460440d56869313abc57928223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 04:40:01 +0200 Subject: [PATCH] feat(coach): full-history search + pagination; document the labs watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach conversation history was capped at the rail's first ~20 threads and searched only that loaded page. The rail and the standalone conversations page now walk the caller's entire history via cursor pagination (useInfiniteQuery + a scroll sentinel), and search runs server-side against the conversation title (GET /api/insights/chat?q=) instead of filtering an already-truncated client array. Also extends the "automatic screening" info line in the metric catalog to name the labs "what changed since your last panel" card alongside the rhythm/breathing/health-status watchdogs it already covered — it un-mounts the same way when there's nothing to flag and was missing from the copy. --- docs/api/openapi.yaml | 14 +- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../chat/__tests__/route-list-query.test.ts | 176 ++++++++++++++++++ src/app/api/insights/chat/route.ts | 19 +- src/app/coach/conversations/page.tsx | 87 +++++++-- .../__tests__/history-rail.test.tsx | 84 ++++++++- .../insights/coach-panel/history-rail.tsx | 104 +++++++++-- .../insights/coach-panel/use-coach.ts | 90 ++++++++- src/hooks/use-debounced-value.ts | 22 +++ src/hooks/use-load-more-sentinel.ts | 59 ++++++ .../persistence-document-scope.test.ts | 26 +++ src/lib/ai/coach/persistence.ts | 12 ++ src/lib/insights/metric-catalog.ts | 13 +- src/lib/openapi/routes/coach.ts | 10 +- src/lib/query-keys/coach.ts | 12 ++ 20 files changed, 691 insertions(+), 61 deletions(-) create mode 100644 src/app/api/insights/chat/__tests__/route-list-query.test.ts create mode 100644 src/hooks/use-debounced-value.ts create mode 100644 src/hooks/use-load-more-sentinel.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b3bfdcd2a..3101aff53 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5068,8 +5068,10 @@ paths: description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at - the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; - the owner is narrowed from the session. + the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text + (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. + Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner + is narrowed from the session. parameters: - name: cursor in: query @@ -5086,6 +5088,14 @@ paths: maximum: 50 default: 20 description: Page size. Defaults to 20, capped at 50. + - name: q + in: query + required: false + schema: + type: string + maxLength: 200 + description: v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted + and not searched). Omit for the unfiltered list. responses: "200": description: A page of the caller's conversations. diff --git a/messages/de.json b/messages/de.json index c4e0a4ea5..ae466c900 100644 --- a/messages/de.json +++ b/messages/de.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Automatische Überwachung", - "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr sowie Warnungen bei Abweichungen von deiner Baseline — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature." + "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten — und zeigt eine Karte nur, wenn etwas auffällt. Keine Karte ist ein gutes Zeichen, kein fehlendes Feature." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.", "historyDeleteAria": "Unterhaltung löschen", "historyDeleted": "Unterhaltung gelöscht", + "historySearchEmpty": "Keine Unterhaltungen entsprechen deiner Suche.", + "historyLoadingMore": "Mehr wird geladen…", "sourcesTitle": "Worauf ich zugreife", "sourcesFresh": "Aktuell", "sourcesStale": "Veraltet", diff --git a/messages/en.json b/messages/en.json index f6bc18f83..ec91a82a7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Automatic screening", - "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, plus baseline-drift alerts — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature." + "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels — and surfaces a card only when something stands out. No card showing is a good sign, not a missing feature." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Your conversations will appear here. Ask anything to start.", "historyDeleteAria": "Delete conversation", "historyDeleted": "Conversation deleted", + "historySearchEmpty": "No conversations match your search.", + "historyLoadingMore": "Loading more…", "sourcesTitle": "What I can see", "sourcesFresh": "Fresh", "sourcesStale": "Stale", diff --git a/messages/es.json b/messages/es.json index 8d741a5b9..e4a444d59 100644 --- a/messages/es.json +++ b/messages/es.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Supervisión automática", - "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, además de alertas de desviación de tu valor de referencia — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente." + "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio — y solo muestra una tarjeta cuando algo destaca. Que no aparezca ninguna tarjeta es una buena señal, no una función ausente." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.", "historyDeleteAria": "Eliminar conversación", "historyDeleted": "Conversación eliminada", + "historySearchEmpty": "Ninguna conversación coincide con tu búsqueda.", + "historyLoadingMore": "Cargando más…", "sourcesTitle": "A qué accedo", "sourcesFresh": "Actuales", "sourcesStale": "Desactualizados", diff --git a/messages/fr.json b/messages/fr.json index 1de8e7e72..f792f1c31 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Surveillance automatique", - "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, ainsi que des alertes d'écart par rapport à ta valeur de référence — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante." + "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire — et n'affiche une carte que si quelque chose se démarque. L'absence de carte est bon signe, pas une fonctionnalité manquante." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.", "historyDeleteAria": "Supprimer la conversation", "historyDeleted": "Conversation supprimée", + "historySearchEmpty": "Aucune conversation ne correspond à ta recherche.", + "historyLoadingMore": "Chargement…", "sourcesTitle": "Ce à quoi j’accède", "sourcesFresh": "À jour", "sourcesStale": "Obsolètes", diff --git a/messages/it.json b/messages/it.json index 870fc1256..9aa2d1297 100644 --- a/messages/it.json +++ b/messages/it.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Monitoraggio automatico", - "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, oltre ad avvisi di scostamento dalla tua base di riferimento — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante." + "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio — mostrando una scheda solo quando qualcosa emerge. Nessuna scheda visibile è un buon segno, non una funzione mancante." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.", "historyDeleteAria": "Elimina conversazione", "historyDeleted": "Conversazione eliminata", + "historySearchEmpty": "Nessuna conversazione corrisponde alla tua ricerca.", + "historyLoadingMore": "Caricamento…", "sourcesTitle": "A cosa accedo", "sourcesFresh": "Aggiornate", "sourcesStale": "Datate", diff --git a/messages/pl.json b/messages/pl.json index a7899e3b1..cbc2aa405 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -599,7 +599,7 @@ }, "watchdog": { "title": "Automatyczny nadzór", - "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, a także alerty o odchyleniu od Twojej wartości bazowej — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja." + "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Brak karty to dobry znak, a nie brakująca funkcja." } }, "mood": { @@ -2264,6 +2264,8 @@ "historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.", "historyDeleteAria": "Usuń rozmowę", "historyDeleted": "Rozmowa usunięta", + "historySearchEmpty": "Żadna rozmowa nie pasuje do wyszukiwania.", + "historyLoadingMore": "Wczytywanie…", "sourcesTitle": "Do czego mam dostęp", "sourcesFresh": "Aktualne", "sourcesStale": "Nieaktualne", diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts new file mode 100644 index 000000000..c5165fdb7 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.30.2 (QoL H1) — `GET /api/insights/chat` gained an optional `q` search + * param so the history rail's search reaches the caller's FULL conversation + * set (title-only, server-side) instead of filtering only the loaded page. + * This suite pins the route's parsing: trimmed, length-capped, and omitted + * (not an empty string) when absent — `listConversations` treats `undefined` + * and `""` identically, but keeping the route from ever forwarding a bare + * empty string documents the contract at the call site. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + requireAssistantSurface: vi.fn(async () => undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); +vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); + +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: vi.fn(), + runStreamingRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/coach/types", () => ({ + coachChatRequestSchema: { safeParse: () => ({ success: false }) }, +})); +vi.mock("@/lib/ai/coach/tools", () => ({ + COACH_TOOL_DEFS: [], + MAX_ROUNDS: 3, + buildCoachDataInventory: vi.fn(), + renderDataInventory: vi.fn(), + renderFocusHint: vi.fn(() => ""), + buildToolModeAddendum: vi.fn(), + runCoachToolLoop: vi.fn(), +})); + +interface ListConversationsCallArgs { + userId: string; + cursor?: string | null; + limit?: number; + q?: string; +} + +// `vi.mock` factories are hoisted above every top-level declaration in the +// file, so a plain `const listConversations = vi.fn(...)` referenced inside +// the factory below would hit the temporal dead zone. `vi.hoisted` runs +// before that hoisting and is the documented escape hatch. +const { listConversations } = vi.hoisted(() => ({ + listConversations: vi.fn(async (params: ListConversationsCallArgs) => { + void params; // typed purely so `.mock.calls[n][0]` below is not `never` + return { conversations: [], nextCursor: null }; + }), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), + fetchConversationWithMessages: vi.fn(), + listConversations, +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() })); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(), + reserveBudget: vi.fn(), + reconcileSpend: vi.fn(), + resolveDailyCap: vi.fn(() => 200_000), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ + getCoachSystemPrompt: vi.fn(), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() })); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ + parseKeyValuesSentinel: vi.fn(), +})); +vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() })); +vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() })); + +import { GET } from "../route"; + +type Envelope = { data: unknown; error: string | null; status: number }; +const get = GET as unknown as (req: Request) => Promise; + +function listReq(qs = ""): Request { + return new Request(`http://localhost/api/insights/chat${qs}`, { + method: "GET", + }); +} + +describe("GET /api/insights/chat — q search param", () => { + beforeEach(() => { + vi.clearAllMocks(); + requireModuleEnabled.mockResolvedValue({ enabled: true }); + requireAssistantSurface.mockResolvedValue(undefined); + listConversations.mockResolvedValue({ + conversations: [], + nextCursor: null, + }); + }); + + it("omits q from listConversations when absent", async () => { + await get(listReq()); + expect(listConversations).toHaveBeenCalledWith( + expect.objectContaining({ userId: "u1", q: undefined }), + ); + }); + + it("trims and forwards q", async () => { + await get(listReq("?q=%20blood%20pressure%20")); + expect(listConversations).toHaveBeenCalledWith( + expect.objectContaining({ q: "blood pressure" }), + ); + }); + + it("caps an oversized q at 200 chars", async () => { + const long = "a".repeat(500); + await get(listReq(`?q=${long}`)); + const call = listConversations.mock.calls[0][0]; + expect(call.q).toHaveLength(200); + }); + + it("treats a whitespace-only q the same as absent (undefined, not empty string)", async () => { + await get(listReq("?q=%20%20%20")); + const call = listConversations.mock.calls[0][0]; + // Trimmed to "" — the route still passes the (falsy) trimmed string + // through; listConversations treats "" the same as undefined. + expect(call.q).toBe(""); + }); + + it("still forwards cursor + limit unchanged alongside q", async () => { + await get(listReq("?cursor=abc&limit=10&q=weight")); + expect(listConversations).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "abc", limit: 10, q: "weight" }), + ); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 7462ed30a..ec846ba12 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -1435,13 +1435,25 @@ function streamProviderError(args: { code: string }): Response { // the conversationId existence check + 20-turn cap. export const POST = apiHandler(handleChatRequest); +/** v1.30.2 (QoL H1) — hard cap on the `?q=` search string length. */ +const LIST_QUERY_MAX_LEN = 200; + /** - * GET /api/insights/chat?cursor=&limit= + * GET /api/insights/chat?cursor=&limit=&q= * * Cursor-paginated list of the caller's conversations for the rail. * Default limit 20, hard cap 50. Cursor is the id of the last item * on the previous page; callers receive `{ nextCursor: null }` when * they have reached the end. + * + * v1.30.2 (QoL H1) — optional `q` narrows the page to conversations whose + * TITLE contains the text (case-insensitive substring). This makes the + * history rail's search reach the caller's FULL conversation set instead + * of only the loaded page; the client re-issues the cursor walk under the + * new `q` whenever the search box changes rather than filtering client- + * side. Message bodies are encrypted at rest and are NOT searched — a + * decrypt-and-scan over every message would be prohibitively expensive + * for a live keystroke search and is out of scope for this pass. */ export const GET = apiHandler(async (request: NextRequest) => { const auth = await requireAuth(); @@ -1456,11 +1468,14 @@ export const GET = apiHandler(async (request: NextRequest) => { const cursor = url.searchParams.get("cursor"); const limitRaw = url.searchParams.get("limit"); const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined; + const qRaw = url.searchParams.get("q"); + const q = qRaw ? qRaw.trim().slice(0, LIST_QUERY_MAX_LEN) : undefined; const page = await listConversations({ userId: auth.user.id, cursor, limit: Number.isFinite(limit) ? (limit as number) : undefined, + q, // v1.28.51 (Documents R3, Design A) — the rail now surfaces BOTH health // threads and doc-scoped threads (the DTO carries `documentId` + // `documentTitle` so the client badges the fenced ones). Omitting the @@ -1472,7 +1487,7 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.coach.list" }, - meta: { count: page.conversations.length }, + meta: { count: page.conversations.length, hasQuery: Boolean(q) }, }); return apiSuccess(page); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 2201a3fcc..6c1f7aaa9 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { + Loader2, MessagesSquare, Paperclip, Search, @@ -18,13 +19,15 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { - useCoachConversations, + useCoachConversationHistory, useDeleteCoachConversationWithUndo, } from "@/components/insights/coach-panel/use-coach"; import type { CoachConversationDTO } from "@/lib/ai/coach/types"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel"; import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { cn } from "@/lib/utils"; @@ -37,8 +40,14 @@ import { cn } from "@/lib/utils"; * 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=` — the existing - * deep-link open mechanism — so there is NO new API and NO new state; - * the page is a thin read over `useCoachConversations()`. + * deep-link open mechanism. Reuses the SAME `/coach?c=` open + * mechanism the rail uses. + * + * v1.30.2 (QoL H1) — the page is a thin read over + * `useCoachConversationHistory()` (cursor-paginated + server-searched, see + * the route's doc comment), which the drawer's `` also + * consumes — the two surfaces can no longer drift onto different + * pagination behaviour. * * Gating mirrors `/coach`: the operator master flag OR a per-user opt-out * hides the Coach entirely and redirects back to `/insights` rather than @@ -101,21 +110,42 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { function CoachConversationsBody() { const { t } = useTranslations(); const router = useRouter(); - const { conversations, isLoading, isError, refetch } = - useCoachConversations(); + const [filter, setFilter] = useState(""); + // v1.30.2 (QoL H1) — search now drives the server-side title query; + // debounce the keystroke draft instead of re-issuing a request per key. + const debouncedFilter = useDebouncedValue(filter, 200); + const { + conversations, + isLoading, + isError, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useCoachConversationHistory({ search: debouncedFilter }); const { pendingDeleteIds, requestDelete, undoDelete } = useDeleteCoachConversationWithUndo(); - const [filter, setFilter] = useState(""); - // Mirror the rail's title substring filter. - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase(); - const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id)); - if (!q) return visible; - return visible.filter((c) => c.title.toLowerCase().includes(q)); - }, [conversations, filter, pendingDeleteIds]); + // pendingDeleteIds is a client-only hide filter for the just-armed undo + // window — the server already resolved the rest of `visible` against the + // search term. + const visible = useMemo( + () => conversations.filter((c) => !pendingDeleteIds.has(c.id)), + [conversations, pendingDeleteIds], + ); + const isSearching = debouncedFilter.trim().length > 0; - const groups = useMemo(() => groupByRecency(filtered), [filtered]); + const groups = useMemo(() => groupByRecency(visible), [visible]); + + // Same callback-ref pattern as the rail: a `useState` setter (not a plain + // `useRef`) so the sentinel hook's `root` re-fires its effect once the + // real scroll-container node exists (null on the very first render). + const [listNode, setListNode] = useState(null); + const sentinelRef = useLoadMoreSentinel({ + enabled: hasNextPage && !isFetchingNextPage, + onLoadMore: fetchNextPage, + root: listNode, + }); function handleSelect(id: string) { // Reuse the existing `?c=` open mechanism on the Coach page. @@ -175,20 +205,21 @@ function CoachConversationsBody() {
- {isLoading && conversations.length === 0 ? ( + {isLoading && visible.length === 0 ? (

{t("common.loading")}

- ) : isError && conversations.length === 0 ? ( + ) : isError && visible.length === 0 ? ( // A load failure must not masquerade as "no conversations yet" — // that reads as a data-loss scare and offers no way back. Mirror // /coach/plans and surface a retry. @@ -202,7 +233,9 @@ function CoachConversationsBody() {
) : ( @@ -278,6 +311,26 @@ function CoachConversationsBody() { )) )} + {/* v1.30.2 (QoL H1) — same IntersectionObserver sentinel as the + drawer rail: scrolling this div into view pulls the next cursor + page. Empty + aria-hidden; the status row below is the a11y + signal. */} + {visible.length > 0 && hasNextPage ? ( + ); diff --git a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx index 17017cd3c..70de68d4b 100644 --- a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx @@ -3,23 +3,46 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { HistoryRail } from "../history-rail"; import type { CoachConversationsPage } from "@/lib/ai/coach/types"; /** * v1.4.20 phase B2b — history-rail render snapshot. + * v1.30.2 (QoL H1) — the rail now reads `useCoachConversationHistory` + * (`useInfiniteQuery`), so the seeded cache shape moved from a single + * `CoachConversationsPage` under the flat `coachConversations()` key to the + * `{ pages, pageParams }` infinite-query shape under + * `queryKeys.coachConversationHistory(search)`. * * The rail mounts a TanStack Query subscription so we have to wrap it - * in a ``. We seed the cache with a synthetic - * conversations page so the rail renders the populated state in one - * pass — server-side rendering doesn't drive a real network call. + * in a ``. We seed the cache with synthetic page(s) + * so the rail renders the populated state in one pass — server-side + * rendering doesn't drive a real network call. */ -function makeClientWithConversations(page: CoachConversationsPage) { +function makeClientWithConversations( + page: CoachConversationsPage, + search = "", +) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - client.setQueryData(["coachConversations"], page); + client.setQueryData(queryKeys.coachConversationHistory(search), { + pages: [page], + pageParams: [null], + }); + return client; +} + +function makeClientWithPages(pages: CoachConversationsPage[], search = "") { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(queryKeys.coachConversationHistory(search), { + pages, + pageParams: pages.map((_, i) => (i === 0 ? null : `cursor-${i}`)), + }); return client; } @@ -152,4 +175,55 @@ describe("", () => { const buttons = html.match(/data-slot="coach-history-delete"/g) ?? []; expect(buttons.length).toBe(2); }); + + // v1.30.2 (QoL H1) — infinite history: reachability beyond the first page. + it("renders every conversation across multiple cached pages (not just page one)", () => { + const client = makeClientWithPages([ + samplePage, + { + conversations: [ + { + id: "c3", + title: "A much older thread", + createdAt: "2026-01-01T08:00:00.000Z", + updatedAt: "2026-01-01T08:01:00.000Z", + messageCount: 1, + fenced: false, + }, + ], + nextCursor: null, + }, + ]); + const html = render( + {}} />, + client, + ); + const rows = html.match(/data-slot="coach-history-item"/g) ?? []; + expect(rows.length).toBe(3); + expect(html).toContain("A much older thread"); + }); + + it("renders the load-more sentinel when a cached page still carries a nextCursor", () => { + const client = makeClientWithConversations({ + ...samplePage, + nextCursor: "c2", + }); + const html = render( + {}} />, + client, + ); + // The sentinel div is aria-hidden — IntersectionObserver never fires + // during a static SSR render, but the element itself must be present + // for the rail to ever pull the next page in a real browser. + expect(html).toMatch(/aria-hidden="true"[^>]*class="h-px"/); + }); + + it("does not render the load-more sentinel once the caller has reached the end", () => { + const client = makeClientWithConversations(samplePage); // nextCursor: null + const html = render( + {}} />, + client, + ); + expect(html).not.toMatch(/class="h-px"/); + }); }); diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 10da1062e..f2a223cbb 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -1,7 +1,14 @@ "use client"; import { useMemo, useState } from "react"; -import { MessagesSquare, Paperclip, Search, Trash2 } from "lucide-react"; +import { + Loader2, + MessagesSquare, + Paperclip, + RotateCw, + Search, + Trash2, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -9,10 +16,12 @@ import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel"; import { COACH_SCROLLBAR } from "./message-thread"; import { - useCoachConversations, + useCoachConversationHistory, useDeleteCoachConversationWithUndo, } from "./use-coach"; @@ -20,9 +29,10 @@ import { * v1.4.20 phase B2b — conversation history rail. * * Lives in the left column of the Coach drawer on `lg+`. Renders the - * paginated list returned by `GET /api/insights/chat`, lets the user - * filter by title (substring match), select one to resume, and delete - * with a confirm-then-go flow. + * caller's FULL conversation history (v1.30.2 — cursor-paginated via + * `useInfiniteQuery`, loaded incrementally as the list scrolls near its + * end), lets the user search by title (server-side, debounced 200 ms), + * select one to resume, and delete with an undo-able confirm. * * Selection / activeId is a controlled prop so the drawer owns the * authoritative state across the message thread + composer. @@ -55,17 +65,41 @@ export function HistoryRail({ hideHeading = false, }: HistoryRailProps) { const { t } = useTranslations(); - const { conversations, isLoading } = useCoachConversations(); + const [filter, setFilter] = useState(""); + // v1.30.2 (QoL H1) — the search box now drives a SERVER-side query + // (title-only substring, see the route doc comment), so debounce the + // keystroke draft rather than re-issuing a request per character. + const debouncedFilter = useDebouncedValue(filter, 200); + const { + conversations, + isLoading, + isError, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useCoachConversationHistory({ search: debouncedFilter }); const { pendingDeleteIds, requestDelete, undoDelete } = useDeleteCoachConversationWithUndo(); - const [filter, setFilter] = useState(""); - const filtered = useMemo(() => { - const q = filter.trim().toLowerCase(); - const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id)); - if (!q) return visible; - return visible.filter((c) => c.title.toLowerCase().includes(q)); - }, [conversations, filter, pendingDeleteIds]); + // pendingDeleteIds is a client-only hide filter for the just-armed undo + // window — the server-side search already resolved the rest of `visible`. + const visible = useMemo( + () => conversations.filter((c) => !pendingDeleteIds.has(c.id)), + [conversations, pendingDeleteIds], + ); + const isSearching = debouncedFilter.trim().length > 0; + + // A `useState`-backed callback ref (not a plain `useRef`) so the + // container's assignment itself triggers a re-render — the sentinel + // hook's effect depends on `root` and must re-run once the real DOM + // node exists (it's null during the very first render pass). + const [listNode, setListNode] = useState(null); + const sentinelRef = useLoadMoreSentinel({ + enabled: hasNextPage && !isFetchingNextPage, + onLoadMore: fetchNextPage, + root: listNode, + }); // v1.30.1 M5 — a single tap hides the row and schedules the real // delete; the toast's Undo action cancels it within the grace @@ -126,6 +160,7 @@ export function HistoryRail({ />
- {isLoading && conversations.length === 0 ? ( + {isLoading && visible.length === 0 ? (

{t("common.loading")}

- ) : filtered.length === 0 ? ( + ) : isError && visible.length === 0 ? ( + // A load failure must not masquerade as "no conversations yet" — + // offer a retry instead of the empty copy. +
+

{t("common.loadFailed")}

+ +
+ ) : visible.length === 0 ? (

- {t("insights.coach.historyEmpty")} + {isSearching + ? t("insights.coach.historySearchEmpty") + : t("insights.coach.historyEmpty")}

) : ( - filtered.map((c) => { + visible.map((c) => { const isActive = c.id === activeId; return (
0 && hasNextPage ? ( +
); diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 911f96a06..19b8ffbc3 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -1,7 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import type { CoachConversationAttachmentDTO, @@ -41,10 +46,13 @@ const QUERY_KEYS = { }; /** - * List of conversations for the rail. Cursor pagination is exposed via - * `loadMore` — the rail can call it when the user scrolls past the - * end. v1.4.20 lands with default-page-only; cursors are pre-wired so - * v1.4.21 / v1.5 can add infinite scroll without reshaping the hook. + * The single (first) page of conversations. Used ONLY for the + * auto-open-most-recent-thread behaviour on mount + * (`coach-conversation.tsx`), which never needs more than the rail's + * server-authoritative head. Every surface that lets the user BROWSE or + * SEARCH history (the drawer rail, the standalone conversations page) reads + * `useCoachConversationHistory` below instead — it walks the full cursor + * chain via `useInfiniteQuery` rather than stopping at page one. */ export function useCoachConversations(enabled = true) { const query = useQuery({ @@ -67,6 +75,76 @@ export function useCoachConversations(enabled = true) { }; } +const HISTORY_PAGE_LIMIT = 20; + +export interface UseCoachConversationHistoryOptions { + /** + * Server-side title search (see `GET /api/insights/chat?q=`). The caller + * debounces the raw input (`useDebouncedValue`) before passing it here — + * every distinct value keys its own cache page chain, so a fast typist + * never sees a stale search's tail page bleed into a fresh one. + */ + search?: string; + enabled?: boolean; +} + +/** + * v1.30.2 (QoL H1) — the full, paginated + server-searched conversation + * history. Replaces the old "first page only, client-side substring + * filter" behaviour: `useInfiniteQuery` walks the cursor chain + * (`nextCursor`) so every conversation the caller has ever had is + * reachable via `fetchNextPage`, and `search` narrows the SERVER'S query + * (title-only — see the route doc comment) rather than filtering an + * already-truncated client array. + * + * Shared by the drawer's `` and the standalone + * `/coach/conversations` page so the two surfaces can never drift onto + * different pagination behaviour again. + */ +export function useCoachConversationHistory( + options: UseCoachConversationHistoryOptions = {}, +) { + const { search = "", enabled = true } = options; + const trimmedSearch = search.trim(); + + const query = useInfiniteQuery({ + queryKey: queryKeys.coachConversationHistory(trimmedSearch), + queryFn: async ({ + pageParam, + }: { + pageParam: string | null; + }): Promise => { + const params = new URLSearchParams(); + if (pageParam) params.set("cursor", pageParam); + params.set("limit", String(HISTORY_PAGE_LIMIT)); + if (trimmedSearch) params.set("q", trimmedSearch); + return apiGet( + `/api/insights/chat?${params.toString()}`, + { headers: { Accept: "application/json" } }, + ); + }, + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled, + staleTime: 30 * 1000, + }); + + const conversations = useMemo( + () => query.data?.pages.flatMap((page) => page.conversations) ?? [], + [query.data], + ); + + return { + conversations, + isLoading: query.isLoading, + isError: query.isError, + hasNextPage: query.hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, + refetch: query.refetch, + }; +} + /** * Fetch one conversation with every message decrypted server-side. * `id === null` disables the query so the drawer can mount without a diff --git a/src/hooks/use-debounced-value.ts b/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..ae134b7c5 --- /dev/null +++ b/src/hooks/use-debounced-value.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Debounces a fast-changing value (typically a search-input draft) so a + * dependent effect / query key only reacts once the value has settled for + * `delayMs`. Mirrors the inline 200 ms pattern the Dokumente vault search + * already used (`documents-view.tsx`), centralised here so other search + * surfaces (v1.30.2 — the Coach conversation history) don't hand-roll the + * same timeout. + */ +export function useDebouncedValue(value: T, delayMs = 200): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(handle); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/hooks/use-load-more-sentinel.ts b/src/hooks/use-load-more-sentinel.ts new file mode 100644 index 000000000..1566407f3 --- /dev/null +++ b/src/hooks/use-load-more-sentinel.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +/** + * IntersectionObserver-driven "load more" sentinel for a scrollable list + * backed by `useInfiniteQuery`. Mount the returned ref on an empty element + * at the end of the rendered list; once it scrolls into `root` (the list's + * own scroll container — pass `null` to watch the viewport instead), + * `onLoadMore` fires. Gated by `enabled` so an in-flight fetch or an + * exhausted list (no `nextCursor`) never re-triggers. + * + * v1.30.2 — extracted for the Coach conversation history (rail + the + * standalone `/coach/conversations` page), which both need the same + * scroll-to-load-more behaviour the Dokumente vault timeline gets from its + * `@tanstack/react-virtual` windowing; a virtualizer would be overkill here + * (conversation counts don't approach document-vault scale), so this is the + * plain-DOM equivalent of that same "pull the next page near the end of the + * window" contract. + */ +export function useLoadMoreSentinel({ + enabled, + onLoadMore, + root = null, +}: { + /** True only when there IS a next page and no fetch is already in flight. */ + enabled: boolean; + onLoadMore: () => void; + /** Scroll container to observe within; `null` watches the viewport. */ + root?: Element | null; +}): RefObject { + const sentinelRef = useRef(null); + // Latest-callback-wins ref so the observer callback never captures a stale + // `onLoadMore` closure without having to re-create the observer on every + // render. + const onLoadMoreRef = useRef(onLoadMore); + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + }, [onLoadMore]); + + useEffect(() => { + if (!enabled) return; + const el = sentinelRef.current; + if (!el) return; + if (typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onLoadMoreRef.current(); + } + }, + { root, rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [enabled, root]); + + return sentinelRef; +} diff --git a/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts b/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts index f3a460131..819c41023 100644 --- a/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts +++ b/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts @@ -103,6 +103,32 @@ describe("listConversations — filter + DTO", () => { expect(where.attachments).toEqual({ some: { documentId: "doc-9" } }); }); + // v1.30.2 (QoL H1) — server-side title search for the full-history rail. + it("applies a case-insensitive title-contains filter when q is given", async () => { + findMany.mockResolvedValue([]); + await listConversations({ userId: "user-1", q: "blood pressure" }); + const where = findMany.mock.calls[0][0].where; + expect(where.userId).toBe("user-1"); + expect(where.title).toEqual({ + contains: "blood pressure", + mode: "insensitive", + }); + }); + + it("trims q and omits the title filter for an empty/whitespace-only query", async () => { + findMany.mockResolvedValue([]); + await listConversations({ userId: "user-1", q: " " }); + const where = findMany.mock.calls[0][0].where; + expect("title" in where).toBe(false); + }); + + it("omits the title filter entirely when q is not given (existing behaviour unchanged)", async () => { + findMany.mockResolvedValue([]); + await listConversations({ userId: "user-1" }); + const where = findMany.mock.calls[0][0].where; + expect("title" in where).toBe(false); + }); + it("falls back to filename when the first attachment has no title", async () => { findMany.mockResolvedValue([ { diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 36bc5d10c..7b255becf 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -435,6 +435,16 @@ export interface ListConversationsParams { * live join row). `userId` stays narrowed regardless. */ attachedDocumentId?: string; + /** + * v1.30.2 (QoL H1) — optional server-side title search for the history + * rail + the standalone conversations page. Case-insensitive substring + * match against `title` only — the only plaintext column on the row; + * `CoachMessage.encryptedContent` cannot be searched without decrypting + * every message, so message BODIES are out of scope for this pass (see + * the route's doc comment). Trimmed empty string is treated as "no + * filter", matching the pre-existing cursor/limit handling style. + */ + q?: string; } /** @@ -449,12 +459,14 @@ export async function listConversations( nextCursor: string | null; }> { const limit = Math.min(Math.max(params.limit ?? 20, 1), 50); + const q = params.q?.trim(); const rows = await prisma.coachConversation.findMany({ where: { userId: params.userId, ...(params.attachedDocumentId ? { attachments: { some: { documentId: params.attachedDocumentId } } } : {}), + ...(q ? { title: { contains: q, mode: "insensitive" } } : {}), }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], take: limit + 1, diff --git a/src/lib/insights/metric-catalog.ts b/src/lib/insights/metric-catalog.ts index d2ef75963..1f6e07d83 100644 --- a/src/lib/insights/metric-catalog.ts +++ b/src/lib/insights/metric-catalog.ts @@ -209,11 +209,14 @@ const ECG_ENTRY: CatalogEntry = { /** * Discoverability audit finding A5 — the alert-style watchdogs (rhythm- * event notifications, breathing-disturbance screening, baseline-drift - * health status) are CORRECT to stay unmounted when there is nothing to - * flag ("no alert" must not render an alarming empty shell), but nothing - * anywhere told the user they were running. This is the "single line" - * fix the audit recommended in place of per-card empty states: an - * always-shown info row, no present/absent gate, no CTA. + * health status, and the labs "what changed since your last panel" card) + * are CORRECT to stay unmounted when there is nothing to flag ("no + * alert" must not render an alarming empty shell), but nothing anywhere + * told the user they were running. This is the "single line" fix the + * audit recommended in place of per-card empty states: an always-shown + * info row, no present/absent gate, no CTA. The description copy covers + * all four watchdogs — extend it (not a new catalog row) if a fifth one + * ships. */ const WATCHDOG_ENTRY: CatalogEntry = { id: "watchdog", diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 4a926d446..68dd521c5 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -642,7 +642,7 @@ export const coachPaths: NonNullable = { tags: ["Insights"], summary: "List the caller's Coach conversations", description: - 'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.', + 'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.', parameters: [ { name: "cursor", @@ -659,6 +659,14 @@ export const coachPaths: NonNullable = { schema: { type: "integer", minimum: 1, maximum: 50, default: 20 }, description: "Page size. Defaults to 20, capped at 50.", }, + { + name: "q", + in: "query", + required: false, + schema: { type: "string", maxLength: 200 }, + description: + "v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted and not searched). Omit for the unfiltered list.", + }, ], responses: { "200": { diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts index 591c2b29c..7f36a0f41 100644 --- a/src/lib/query-keys/coach.ts +++ b/src/lib/query-keys/coach.ts @@ -20,6 +20,18 @@ export const coachKeys = { * share one factory-routed key. */ coachConversations: () => ["coachConversations"] as const, + /** + * v1.30.2 — the full, paginated + searchable rail history + * (`useInfiniteQuery` over `GET /api/insights/chat?cursor&q`). Nested under + * the `coachConversations` prefix (not a sibling key) so the existing + * delete-mutation + post-turn invalidation (`coachConversations()`) still + * reaches every cached search variant of this infinite list — TanStack's + * default `invalidateQueries` match is prefix-based, so no extra + * invalidation call site was needed. `search` is the trimmed query text; + * empty string keys the unfiltered ("browse everything") variant. + */ + coachConversationHistory: (search = "") => + ["coachConversations", "history", search.trim()] as const, /** * v1.18.7 — one decrypted Coach conversation (`GET /api/insights/chat/[id]`). * Keyed on the conversation id so two open threads never share a cache slot;