mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
8dd04e0170
commit
1a642f1173
20 files changed
+691
-61
No files matched your search
+12
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user