feat(coach): full-history search + pagination; document the labs watchdog

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 05:06:24 +02:00
1 parent 8dd04e0170
commit 1a642f1173
20 files changed
+691 -61

No files matched your search

+12 -2
View File
@@ -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.
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
@@ -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: <T extends (...args: unknown[]) => 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<Envelope>;
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" }),
);
});
});
+17 -2
View File
@@ -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=<id>&limit=<n>
* GET /api/insights/chat?cursor=<id>&limit=<n>&q=<text>
*
* 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);
+70 -17
View File
@@ -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=<id>` — the existing
* deep-link open mechanism — so there is NO new API and NO new state;
* the page is a thin read over `useCoachConversations()`.
* deep-link open mechanism. Reuses the SAME `/coach?c=<id>` 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 `<HistoryRail>` 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<string>("");
// 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<string>("");
// 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<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
onLoadMore: fetchNextPage,
root: listNode,
});
function handleSelect(id: string) {
// Reuse the existing `?c=<id>` open mechanism on the Coach page.
@@ -175,20 +205,21 @@ function CoachConversationsBody() {
</header>
<div
ref={setListNode}
data-slot="coach-conversations-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
{isLoading && visible.length === 0 ? (
<p
data-slot="coach-conversations-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : 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() {
<MessagesSquare className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("insights.coach.historyEmpty")}
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
</p>
</div>
) : (
@@ -278,6 +311,26 @@ function CoachConversationsBody() {
</section>
))
)}
{/* 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 ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
{isFetchingNextPage ? (
<div
data-slot="coach-conversations-loading-more"
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 py-3 text-xs"
>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.coach.historyLoadingMore")}
</div>
) : null}
</div>
</div>
);
@@ -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 `<QueryClientProvider>`. 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 `<QueryClientProvider>`. 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("<HistoryRail>", () => {
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(
<HistoryRail activeId={null} onSelect={() => {}} />,
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(
<HistoryRail activeId={null} onSelect={() => {}} />,
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(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).not.toMatch(/class="h-px"/);
});
});
@@ -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<string>("");
// 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<string>("");
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<HTMLDivElement | null>(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({
/>
</div>
<div
ref={setListNode}
data-slot="coach-history-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1",
@@ -133,22 +168,37 @@ export function HistoryRail({
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
{isLoading && visible.length === 0 ? (
<p
data-slot="coach-history-loading"
className="text-muted-foreground px-2 py-3 text-sm"
>
{t("common.loading")}
</p>
) : 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.
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
>
<p>{t("common.loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RotateCw className="size-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : visible.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
>
{t("insights.coach.historyEmpty")}
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
</p>
) : (
filtered.map((c) => {
visible.map((c) => {
const isActive = c.id === activeId;
return (
<div
@@ -214,6 +264,26 @@ export function HistoryRail({
);
})
)}
{/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
div into the rail's own scrollport pulls the next cursor page.
Empty + aria-hidden — the loading row right below it is the
a11y-visible signal. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
{isFetchingNextPage ? (
<div
data-slot="coach-history-loading-more"
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 px-2 py-3 text-xs"
>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.coach.historyLoadingMore")}
</div>
) : null}
</div>
</div>
);
@@ -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 `<HistoryRail>` 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<CoachConversationsPage> => {
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<CoachConversationsPage>(
`/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
+22
View File
@@ -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<T>(value: T, delayMs = 200): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(handle);
}, [value, delayMs]);
return debounced;
}
+59
View File
@@ -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<HTMLDivElement | null> {
const sentinelRef = useRef<HTMLDivElement | null>(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;
}
@@ -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([
{
+12
View File
@@ -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,
+8 -5
View File
@@ -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",
+9 -1
View File
@@ -642,7 +642,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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": {
+12
View File
@@ -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;