From 5b24ca0fa117c5b969c8cd4e1f6c9c17d21dd3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 11:01:59 +0200 Subject: [PATCH] feat(coach): document chats live in the Coach view and history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking the Coach about a document opened a separate, half-finished chat panel that never appeared in Coach history. The bespoke drawer is gone; a document conversation now opens the real Coach view, appears in the Coach list with a document badge and a "Chatting about: " note, and is read back through the same history rail as any other thread. The prompt-injection fence is unchanged and stays server-side. A document turn is still sent through the hardened fenced endpoint (no tools, no health snapshot, grounded in the document text only); the coach tool route continues to fetch with documentId null, so a document-scoped conversation can never be continued through the tool loop even from a hostile client. Only the surface moved into the Coach; the boundary did not. The relaxed conversation-list and detail filters drop only the documentId predicate — userId scoping is unchanged, so a document thread is still visible only to its owner. CoachConversationDTO gains documentId + documentTitle (server-derived, title/filename only); no schema change (documentId exists since 0230). OpenAPI regenerated. --- CHANGELOG.md | 14 +- docs/api/openapi.yaml | 25 +- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/pl.json | 23 +- package.json | 2 +- public/sw.js | 2 +- src/app/api/insights/chat/[id]/route.ts | 15 +- src/app/api/insights/chat/route.ts | 21 +- src/app/coach/conversations/page.tsx | 14 +- src/app/coach/page.tsx | 13 +- .../__tests__/document-chat-panel.test.tsx | 162 ---------- .../documents/document-chat-drawer.tsx | 211 ------------ .../documents/document-chat-panel.tsx | 278 ---------------- .../documents/document-detail-sheet.tsx | 24 +- src/components/documents/use-document-chat.ts | 303 ------------------ .../__tests__/coach-send-target.test.ts | 76 +++++ .../coach-panel/coach-conversation.tsx | 88 ++++- .../insights/coach-panel/history-rail.tsx | 15 +- .../insights/coach-panel/use-coach.ts | 67 +++- .../persistence-document-scope.test.ts | 169 ++++++++++ src/lib/ai/coach/persistence.ts | 30 ++ src/lib/ai/coach/types.ts | 16 + src/lib/openapi/routes/coach.ts | 24 ++ src/lib/query-keys/documents.ts | 10 - 28 files changed, 605 insertions(+), 1112 deletions(-) delete mode 100644 src/components/documents/__tests__/document-chat-panel.test.tsx delete mode 100644 src/components/documents/document-chat-drawer.tsx delete mode 100644 src/components/documents/document-chat-panel.tsx delete mode 100644 src/components/documents/use-document-chat.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-send-target.test.ts create mode 100644 src/lib/ai/coach/__tests__/persistence-document-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index daf0b6c2c..9f9c8b5da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ ## [Unreleased] -## [1.28.50] — 2026-07-16 — View your ECG recordings in Insights +## [1.28.51] — 2026-07-16 — Chatting about a document lives in the Coach now + +Asking the Coach about a document used to open a separate, half-finished chat +panel that never showed up in your Coach history. It now opens the real Coach: +the conversation appears in your Coach list like any other, marked with a small +document badge and a "Chatting about: <title>" note so it's clear which +document it's scoped to. + +The safeguards that make document chat safe are unchanged. A conversation about +a document still runs in its fenced mode — grounded in that document's text +only, with no access to your health data or any tools — so text inside an +uploaded file can't reach anything it shouldn't. Only the surface moved into the +Coach; the boundary did not. ECG recordings synced from a compatible watch were stored but never shown. They now have a place in Insights: a list of your recordings, each opening the full diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 402e6b32b..08bc59afa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.50 + version: 1.28.51 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -21934,6 +21934,18 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + documentId: + description: When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced + endpoint). Null/absent for a normal health thread. + anyOf: + - type: string + - type: "null" + documentTitle: + description: The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a + health thread or when the document has neither. + anyOf: + - type: string + - type: "null" required: - id - title @@ -21988,6 +22000,17 @@ components: anyOf: - type: string - type: "null" + documentId: + description: When set, this thread is scoped to one stored document; the client routes its turns through the hardened + fenced document endpoint, never the tool route. + anyOf: + - type: string + - type: "null" + documentTitle: + description: The owning document's resolved title for the scope badge. Null on a health thread. + anyOf: + - type: string + - type: "null" required: - id - title diff --git a/messages/de.json b/messages/de.json index ba9a11a80..f52346a16 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.", "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", "newChat": "Neuer Chat", + "docScope": { + "badge": "Dokument", + "chattingAbout": "Chat über: {title}", + "notIndexedHint": "Lies dieses Dokument zuerst im Archiv mit KI ein, damit der Coach dazu antworten kann." + }, "actionsMenu": "Aktionen", "maximizeAriaLabel": "Im Vollbild öffnen", "minimizeAriaLabel": "Zurück zum Seitenfenster", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Über dieses Dokument chatten", - "notIndexed": "Lies das Dokument zuerst mit KI, um darüber zu chatten.", - "empty": "Stelle eine Frage zu diesem Dokument, um zu beginnen.", - "placeholder": "Frage zu diesem Dokument…", - "inputLabel": "Deine Frage zu diesem Dokument", - "send": "Senden", - "thinking": "Dokument wird gelesen…", - "safety": "Antworten beschreiben dieses Dokument und sind keine medizinische Beratung.", - "close": "Chat schließen", - "errorHistory": "Unterhaltung konnte nicht geladen werden. Bitte erneut versuchen.", - "errorGeneric": "Etwas ist schiefgelaufen. Bitte erneut versuchen.", - "errorRateLimited": "Zu viele Nachrichten. Bitte kurz warten und erneut versuchen.", - "errorBudget": "Das tägliche KI-Limit ist erreicht. Bitte morgen erneut versuchen.", - "errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.", - "errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.", - "errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.", "openAria": "Coach über dieses Dokument fragen", - "openButton": "Coach fragen", - "scopeBadge": "Chat über: {title}" + "openButton": "Coach fragen" } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index a335675f8..c89cc3935 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.", "tagline": "Personal health coach, grounded in your data.", "newChat": "New chat", + "docScope": { + "badge": "Document", + "chattingAbout": "Chatting about: {title}", + "notIndexedHint": "Read this document with AI first in the vault so the Coach can answer about it." + }, "actionsMenu": "Actions", "maximizeAriaLabel": "Open in full page", "minimizeAriaLabel": "Back to the drawer", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Chat about this document", - "notIndexed": "Read this document with AI first to chat about it.", - "empty": "Ask a question about this document to get started.", - "placeholder": "Ask about this document…", - "inputLabel": "Your question about this document", - "send": "Send", - "thinking": "Reading the document…", - "safety": "Answers describe this document and are not medical advice.", - "close": "Close chat", - "errorHistory": "Couldn't load the conversation. Try again.", - "errorGeneric": "Something went wrong. Try again.", - "errorRateLimited": "Too many messages. Wait a moment and try again.", - "errorBudget": "The daily AI limit has been reached. Try again tomorrow.", - "errorCredential": "The AI provider needs reconnecting in settings.", - "errorConsent": "Turn on AI consent in settings to chat about this document.", - "errorNetwork": "You appear to be offline. Check your connection and try again.", "openAria": "Ask the Coach about this document", - "openButton": "Ask the Coach", - "scopeBadge": "Chatting about: {title}" + "openButton": "Ask the Coach" } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index ad6da1321..8db8034d5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.", "tagline": "Coach personal de salud, apoyado en tus datos.", "newChat": "Nueva conversación", + "docScope": { + "badge": "Documento", + "chattingAbout": "Hablando sobre: {title}", + "notIndexedHint": "Primero lee este documento con IA en el archivo para que el Coach pueda responder sobre él." + }, "actionsMenu": "Acciones", "maximizeAriaLabel": "Abrir en pantalla completa", "minimizeAriaLabel": "Volver al panel", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Chatea sobre este documento", - "notIndexed": "Primero lee este documento con IA para poder chatear sobre él.", - "empty": "Haz una pregunta sobre este documento para empezar.", - "placeholder": "Pregunta sobre este documento…", - "inputLabel": "Tu pregunta sobre este documento", - "send": "Enviar", - "thinking": "Leyendo el documento…", - "safety": "Las respuestas describen este documento y no son consejo médico.", - "close": "Cerrar chat", - "errorHistory": "No se pudo cargar la conversación. Inténtalo de nuevo.", - "errorGeneric": "Algo salió mal. Inténtalo de nuevo.", - "errorRateLimited": "Demasiados mensajes. Espera un momento e inténtalo de nuevo.", - "errorBudget": "Se alcanzó el límite diario de IA. Inténtalo de nuevo mañana.", - "errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.", - "errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.", - "errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.", "openAria": "Pregunta al Coach sobre este documento", - "openButton": "Preguntar al Coach", - "scopeBadge": "Chateando sobre: {title}" + "openButton": "Preguntar al Coach" } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 7171c27d5..e016d3a1d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.", "tagline": "Coach personnel de santé, ancré sur tes données.", "newChat": "Nouvelle conversation", + "docScope": { + "badge": "Document", + "chattingAbout": "Discussion sur : {title}", + "notIndexedHint": "Lis d'abord ce document avec l'IA dans le coffre pour que le Coach puisse y répondre." + }, "actionsMenu": "Actions", "maximizeAriaLabel": "Ouvrir en pleine page", "minimizeAriaLabel": "Revenir au panneau", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Discuter de ce document", - "notIndexed": "Lisez d'abord ce document avec l'IA pour pouvoir en discuter.", - "empty": "Posez une question sur ce document pour commencer.", - "placeholder": "Une question sur ce document…", - "inputLabel": "Votre question sur ce document", - "send": "Envoyer", - "thinking": "Lecture du document…", - "safety": "Les réponses décrivent ce document et ne constituent pas un avis médical.", - "close": "Fermer le chat", - "errorHistory": "Impossible de charger la conversation. Réessayez.", - "errorGeneric": "Une erreur s'est produite. Réessayez.", - "errorRateLimited": "Trop de messages. Patientez un instant et réessayez.", - "errorBudget": "La limite quotidienne d'IA est atteinte. Réessayez demain.", - "errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.", - "errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.", - "errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.", "openAria": "Interroger le Coach sur ce document", - "openButton": "Demander au Coach", - "scopeBadge": "Discussion sur : {title}" + "openButton": "Demander au Coach" } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 7d2f4f058..117e6fa7c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.", "tagline": "Coach personale di salute, basato sui tuoi dati.", "newChat": "Nuova chat", + "docScope": { + "badge": "Documento", + "chattingAbout": "Stai parlando di: {title}", + "notIndexedHint": "Leggi prima questo documento con l'IA nell'archivio così il Coach può rispondere." + }, "actionsMenu": "Azioni", "maximizeAriaLabel": "Apri a pagina intera", "minimizeAriaLabel": "Torna al pannello", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Chatta su questo documento", - "notIndexed": "Leggi prima questo documento con l'IA per poterne parlare.", - "empty": "Fai una domanda su questo documento per iniziare.", - "placeholder": "Una domanda su questo documento…", - "inputLabel": "La tua domanda su questo documento", - "send": "Invia", - "thinking": "Lettura del documento…", - "safety": "Le risposte descrivono questo documento e non sono un consiglio medico.", - "close": "Chiudi chat", - "errorHistory": "Impossibile caricare la conversazione. Riprova.", - "errorGeneric": "Qualcosa è andato storto. Riprova.", - "errorRateLimited": "Troppi messaggi. Attendi un momento e riprova.", - "errorBudget": "Limite giornaliero di IA raggiunto. Riprova domani.", - "errorCredential": "Il provider IA va ricollegato nelle impostazioni.", - "errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.", - "errorNetwork": "Sembri offline. Controlla la connessione e riprova.", "openAria": "Chiedi al Coach su questo documento", - "openButton": "Chiedi al Coach", - "scopeBadge": "Stai chattando su: {title}" + "openButton": "Chiedi al Coach" } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index febb2845d..bbc1d20d6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2213,6 +2213,11 @@ "errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.", "tagline": "Osobisty coach zdrowia, oparty na twoich danych.", "newChat": "Nowa rozmowa", + "docScope": { + "badge": "Dokument", + "chattingAbout": "Rozmowa o: {title}", + "notIndexedHint": "Najpierw wczytaj ten dokument z pomocą AI w archiwum, aby Coach mógł na jego temat odpowiadać." + }, "actionsMenu": "Działania", "maximizeAriaLabel": "Otwórz na pełnej stronie", "minimizeAriaLabel": "Powrót do panelu", @@ -8407,24 +8412,8 @@ }, "chat": { "title": "Porozmawiaj o tym dokumencie", - "notIndexed": "Najpierw przeczytaj ten dokument z pomocą AI, aby o nim porozmawiać.", - "empty": "Zadaj pytanie o ten dokument, aby zacząć.", - "placeholder": "Zapytaj o ten dokument…", - "inputLabel": "Twoje pytanie o ten dokument", - "send": "Wyślij", - "thinking": "Czytanie dokumentu…", - "safety": "Odpowiedzi opisują ten dokument i nie są poradą medyczną.", - "close": "Zamknij czat", - "errorHistory": "Nie udało się wczytać rozmowy. Spróbuj ponownie.", - "errorGeneric": "Coś poszło nie tak. Spróbuj ponownie.", - "errorRateLimited": "Zbyt wiele wiadomości. Poczekaj chwilę i spróbuj ponownie.", - "errorBudget": "Osiągnięto dzienny limit AI. Spróbuj ponownie jutro.", - "errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.", - "errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.", - "errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.", "openAria": "Zapytaj Coacha o ten dokument", - "openButton": "Zapytaj Coacha", - "scopeBadge": "Rozmowa o: {title}" + "openButton": "Zapytaj Coacha" } }, "selfDescription": { diff --git a/package.json b/package.json index d3bceb52b..307b3b6d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.50", + "version": "1.28.51", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 4e485b61e..6de42b312 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.50"; + /* @sw-version-fallback */ "v1.28.51"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/insights/chat/[id]/route.ts b/src/app/api/insights/chat/[id]/route.ts index 5ac66f28d..eefb2eb07 100644 --- a/src/app/api/insights/chat/[id]/route.ts +++ b/src/app/api/insights/chat/[id]/route.ts @@ -33,11 +33,16 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => { const { id } = await ctx.params; if (!id) return apiError("coach.conversation.notFound", 404); - // v1.27.33 — Coach detail is scoped to health threads (documentId null); a - // document chat is read through its own document-scoped route, not here. - const detail = await fetchConversationWithMessages(auth.user.id, id, { - documentId: null, - }); + // v1.28.51 (Documents R3, Design A) — the Coach detail READER now returns + // both health and doc-scoped threads so `/coach?c=<id>` can display a document + // conversation inside the real coach chrome. Dropping the `documentId: null` + // opt removes the scope filter while `auth.user.id` stays narrowed, so a + // caller can only ever read their OWN thread (a foreign id is still 404). + // This reader only decrypts + returns persisted messages — no tool loop, no + // snapshot — so surfacing a doc thread here does not reopen the injection + // surface the fenced SEND path (documents route) guards. The DTO carries + // `documentId` + `documentTitle` so the client picks the fenced send path. + const detail = await fetchConversationWithMessages(auth.user.id, id); if (!detail) { return apiError("coach.conversation.notFound", 404); } diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 3efca151a..a347ba82a 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -304,8 +304,15 @@ async function handleChatRequest(request: NextRequest): Promise<Response> { let priorSummary: string | null = null; if (conversationId) { - // v1.27.33 — scope to Coach threads (documentId null); a document chat can - // never be loaded through the Coach route. + // v1.27.33 / v1.28.51 — the SEND path stays scoped to Coach threads + // (`documentId: null`) DELIBERATELY, even though the read paths (rail list + + // detail GET) were relaxed to surface doc-scoped threads in the Coach UI. + // This is the load-bearing prompt-injection fence: a document chat runs its + // tool-loop + health-snapshot NEVER — a doc turn is routed by the client to + // the hardened `/api/documents/inbound/[id]/chat` endpoint. Should a + // doc-scoped conversation id ever reach THIS tool route, the null-scope + // fetch below returns nothing and the turn 404s rather than executing an + // injected instruction against the coach's write tools. Do not relax. const existing = await fetchConversationWithMessages( userId, conversationId, @@ -1437,9 +1444,13 @@ export const GET = apiHandler(async (request: NextRequest) => { userId: auth.user.id, cursor, limit: Number.isFinite(limit) ? (limit as number) : undefined, - // v1.27.33 — the Coach rail shows only health threads; document chats - // (documentId set) live on the document sheet and are filtered out here. - documentId: null, + // 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 + // `documentId` key drops the filter entirely — a union of both scopes — + // while `userId` stays narrowed from the session, so the relaxation never + // widens ownership. Doc turns still POST to the hardened document endpoint, + // never this route (see the `documentId: null` guard on the POST path). }); annotate({ diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index e5df84226..1f5242bd3 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { MessagesSquare, Search, Target, Trash2 } from "lucide-react"; +import { FileText, MessagesSquare, Search, Target, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -223,8 +223,16 @@ function CoachConversationsBody() { className="flex min-h-11 min-w-0 flex-1 flex-col justify-center text-left" data-slot="coach-conversations-select" > - <span className="block truncate font-medium"> - {c.title} + <span className="flex min-w-0 items-center gap-1.5 font-medium"> + {/* v1.28.51 — badge doc-scoped threads (fenced + endpoint) so they read as distinct in the list. */} + {c.documentId ? ( + <FileText + className="text-primary size-3.5 shrink-0" + aria-label={t("insights.coach.docScope.badge")} + /> + ) : null} + <span className="truncate">{c.title}</span> </span> <span className="text-muted-foreground block text-xs"> {formatRelativeTime(c.updatedAt, t)} diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index c956134fc..96c077437 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -65,11 +65,19 @@ function CoachPageBody() { // defaults to the new-chat hero. const rawC = searchParams.get("c"); const deepLinkedId = rawC && rawC !== "new" ? rawC : null; + // v1.28.51 (Documents R3, Design A) — `?doc=<id>` seeds a fresh chat SCOPED + // to a stored document (the vault detail sheet's "Ask the Coach" action). An + // explicit `?c=<id>` thread wins over it (that thread carries its own scope). + const rawDoc = searchParams.get("doc"); + const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null; // C1 exception — an unread coach-initiated message opens the most-recent // conversation (which holds that proactive turn). Only consulted when the // entry did not pin a specific thread or ask for a fresh chat. - const exceptionEligible = deepLinkedId === null && rawC !== "new"; + // A `?doc=` open is an explicit fresh doc-scoped chat — never override it by + // resuming the most-recent thread on an unread nudge. + const exceptionEligible = + deepLinkedId === null && rawC !== "new" && seedDocumentId === null; const { data: nudge } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), queryFn: async (): Promise<CoachNudgeStatus> => @@ -84,6 +92,9 @@ function CoachPageBody() { surface="page" autoFocusComposer initialConversationId={deepLinkedId} + // v1.28.51 — seed the document scope for a `?doc=<id>` open so the first + // turn is created + sent through the hardened fenced document endpoint. + initialDocumentId={seedDocumentId} // Default is the new-chat hero; only resume most-recent for the // unread coach-initiated exception. autoOpenMostRecent={hasUnreadCoachMessage} diff --git a/src/components/documents/__tests__/document-chat-panel.test.tsx b/src/components/documents/__tests__/document-chat-panel.test.tsx deleted file mode 100644 index 7ea182826..000000000 --- a/src/components/documents/__tests__/document-chat-panel.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { - DocumentChatConversation, - documentChatErrorKey, -} from "../document-chat-panel"; -import type { DocumentChatMessage } from "../use-document-chat"; - -/** - * The scoped "chat about this document" surface: - * - the OPEN conversation body renders a plain-text message log + composer + - * an always-on safety note; assistant/user prose is XSS-safe React text - * (raw HTML in a turn is escaped, never injected); - * - error codes map to calm translation keys, never a raw provider string. - * - * The stateful container + indexing gate live in `document-chat-drawer.tsx` - * (Coach-drawer chrome scoped to one document); its open/not-indexed branches - * ride the Radix Sheet portal, so they are pinned by the e2e suite rather than - * a static render here. - */ - -function renderPure(node: React.ReactNode) { - return renderToStaticMarkup( - <I18nProvider initialLocale="en">{node}</I18nProvider>, - ); -} - -const noop = () => {}; - -function conversation( - overrides: Partial< - React.ComponentProps<typeof DocumentChatConversation> - > = {}, -) { - const props: React.ComponentProps<typeof DocumentChatConversation> = { - messages: [], - optimisticContent: null, - streamingContent: "", - isStreaming: false, - streamErrorKey: null, - historyPending: false, - historyError: false, - draft: "", - onDraftChange: noop, - onSubmit: noop, - onClose: noop, - ...overrides, - }; - return <DocumentChatConversation {...props} />; -} - -const messages: DocumentChatMessage[] = [ - { - id: "u1", - role: "user", - content: "What does the Impression say?", - createdAt: "2026-07-07T10:00:00.000Z", - }, - { - id: "a1", - role: "assistant", - content: "Per the report's Impression, the findings are unremarkable.", - createdAt: "2026-07-07T10:00:02.000Z", - }, -]; - -describe("<DocumentChatConversation> (open body)", () => { - it("renders the composer + always-on safety note", () => { - const html = renderPure(conversation()); - expect(html).toContain('data-slot="document-chat"'); - expect(html).toContain('data-state="open"'); - expect(html).toContain('data-slot="document-chat-input"'); - expect(html).toContain('data-slot="document-chat-send"'); - // Safety-visible copy: describes the document, not medical advice. - expect(html).toContain('data-slot="document-chat-safety"'); - expect(html).toContain("not medical advice"); - }); - - it("shows the empty prompt before any turn", () => { - const html = renderPure(conversation()); - expect(html).toContain('data-slot="document-chat-empty"'); - }); - - it("renders a user + assistant turn, and the assistant text plainly", () => { - const html = renderPure(conversation({ messages })); - expect(html).toContain('data-role="user"'); - expect(html).toContain('data-role="assistant"'); - expect(html).toContain("What does the Impression say?"); - // A citation-style phrase is just text. - expect(html).toContain("Per the report's Impression"); - // No empty prompt once turns exist. - expect(html).not.toContain('data-slot="document-chat-empty"'); - }); - - it("renders assistant prose as XSS-safe text — raw HTML is escaped", () => { - const html = renderPure( - conversation({ - messages: [ - { - id: "a2", - role: "assistant", - content: "<img src=x onerror=alert(1)> the value is normal.", - createdAt: "2026-07-07T10:00:00.000Z", - }, - ], - }), - ); - // The markup is escaped, never injected as a live element. - expect(html).not.toContain("<img src=x"); - expect(html).toContain("<img"); - }); - - it("shows the streaming assistant tail once tokens arrive", () => { - const html = renderPure( - conversation({ - streamingContent: "Reading the values", - isStreaming: true, - }), - ); - // The live tail streams word-by-word (each token in its own span), so - // assert the words are present rather than a contiguous run. - expect(html).toContain("Reading"); - expect(html).toContain("values"); - // The thinking placeholder is gone once content streams. - expect(html).not.toContain('data-slot="document-chat-thinking"'); - }); - - it("shows the thinking placeholder while streaming with no tokens yet", () => { - const html = renderPure( - conversation({ streamingContent: "", isStreaming: true }), - ); - expect(html).toContain('data-slot="document-chat-thinking"'); - }); - - it("surfaces a mapped error key, never a raw provider string", () => { - const html = renderPure( - conversation({ streamErrorKey: "documents.chat.errorRateLimited" }), - ); - expect(html).toContain('role="alert"'); - expect(html).toContain("Too many messages"); - }); -}); - -describe("documentChatErrorKey", () => { - it("maps provider + client codes to calm keys, defaulting to generic", () => { - expect(documentChatErrorKey("documents.chat.budget.exceeded")).toBe( - "documents.chat.errorBudget", - ); - expect(documentChatErrorKey("documents.inbound.rateLimited")).toBe( - "documents.chat.errorRateLimited", - ); - expect(documentChatErrorKey("consent.ai.required")).toBe( - "documents.chat.errorConsent", - ); - expect(documentChatErrorKey("something.unknown")).toBe( - "documents.chat.errorGeneric", - ); - expect(documentChatErrorKey(null)).toBe("documents.chat.errorGeneric"); - }); -}); diff --git a/src/components/documents/document-chat-drawer.tsx b/src/components/documents/document-chat-drawer.tsx deleted file mode 100644 index 359ce27af..000000000 --- a/src/components/documents/document-chat-drawer.tsx +++ /dev/null @@ -1,211 +0,0 @@ -"use client"; - -/** - * The scoped "chat about this document" surface, presented in the Coach chat - * drawer chrome (right-side `<Sheet>` on desktop, bottom sheet on phones — the - * same width caps and slide behaviour as the general Coach drawer). It is - * CHROME reuse only: the conversation body, the client hooks, and the endpoint - * stay the document-scoped ones, so the v1.27.33 posture is untouched — the - * drawer POSTs `/api/documents/inbound/[id]/chat` (no tools, no health - * snapshot, fenced document, inbound/replay refusal, outbound screen, numeric - * grounding, consent + budget + per-user rate bucket, indexed-only), and the - * assistant prose renders as PLAIN React text (no markdown library, no - * `dangerouslySetInnerHTML`). - * - * Unlike the general Coach drawer this one has NO maximize and NO - * conversations rail — both hand off to `/coach`, which does not apply to a - * single document thread. It carries a quiet scope badge ("Chatting about: - * <document>") so the user always sees the conversation is fenced to this one - * document, and a single close control. - * - * Open/close is owned by the detail sheet (the top-right neutral Coach icon - * toggles it); this component is fully controlled. When the document is not - * content-indexed the body shows the calm read-it-first hint (never an error), - * matching the former inline panel. - */ -import { FileText, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetTitle, -} from "@/components/ui/sheet"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -import { - DocumentChatConversation, - documentChatErrorKey, -} from "./document-chat-panel"; -import { - useDocumentChatThread, - useSendDocumentChatMessage, -} from "./use-document-chat"; - -export function DocumentChatDrawer({ - open, - onOpenChange, - documentId, - indexed, - documentTitle, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - documentId: string; - /** True when the document has a content index (its text grounds the chat). */ - indexed: boolean; - /** Resolved document title for the scope badge. */ - documentTitle: string; -}) { - const { t, locale } = useTranslations(); - // Below `sm` (640 px) the drawer slides up from the bottom edge so the - // header chrome stays near the user's thumb; above it keeps the right-side - // slide + width cap — the same behaviour as the general Coach drawer. - const isPhoneViewport = useIsMobile("sm"); - - const [draft, setDraft] = useState(""); - const logRef = useRef<HTMLDivElement | null>(null); - - const thread = useDocumentChatThread(documentId, open && indexed); - const { streaming, isStreaming, optimisticUser, send, reset } = - useSendDocumentChatMessage(documentId); - - const messages = thread.data?.messages ?? []; - const conversationId = thread.data?.conversationId ?? undefined; - - // The optimistic bubble is dropped once its persisted twin lands (matched by - // content on the freshest user turn), so the user never sees it twice. - const lastUser = [...messages].reverse().find((m) => m.role === "user"); - const showOptimistic = - optimisticUser !== null && lastUser?.content !== optimisticUser; - - // Keep the newest turn in view as messages / tokens land. - useEffect(() => { - const el = logRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [messages.length, streaming.content, showOptimistic, isStreaming]); - - const chatLocale = locale === "de" ? "de" : "en"; - - const submit = () => { - const message = draft.trim(); - if (!message || isStreaming) return; - setDraft(""); - void send({ conversationId, message, locale: chatLocale }); - }; - - const handleOpenChange = (next: boolean) => { - // Closing discards the live stream state (the persisted history stays on - // disk and reloads next open) — mirrors the Coach drawer's reset-on-close. - if (!next) reset(); - onOpenChange(next); - }; - - return ( - <Sheet open={open} onOpenChange={handleOpenChange}> - <SheetContent - side={isPhoneViewport ? "bottom" : "right"} - showCloseButton={false} - data-slot="document-chat-drawer" - data-variant={isPhoneViewport ? "bottom-sheet" : "side-sheet"} - className={cn( - "w-full p-0 sm:max-w-[720px]", - "lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]", - isPhoneViewport - ? "flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl" - : "flex h-[100dvh] flex-col gap-0", - )} - > - {/* Header: the accessible name + description stay mounted for Radix; the - visible line is the quiet scope badge + the close control. */} - <div className="border-border/70 flex items-start justify-between gap-3 border-b p-4"> - <div className="min-w-0 flex-1"> - <SheetTitle className="sr-only"> - {t("documents.chat.title")} - </SheetTitle> - <SheetDescription className="sr-only"> - {t("documents.chat.safety")} - </SheetDescription> - <span - data-slot="document-chat-scope" - className={cn( - "border-border/60 bg-muted/40 text-muted-foreground", - "inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1", - "text-xs font-medium", - )} - > - <FileText - className="text-muted-foreground size-3.5 shrink-0" - aria-hidden="true" - /> - <span className="truncate"> - {t("documents.chat.scopeBadge", { title: documentTitle })} - </span> - </span> - </div> - <SheetClose asChild> - <Button - type="button" - variant="ghost" - size="icon-lg" - data-slot="document-chat-drawer-close" - aria-label={t("common.close")} - title={t("common.close")} - className="text-muted-foreground hover:text-foreground shrink-0" - > - <X className="size-4" aria-hidden="true" /> - </Button> - </SheetClose> - </div> - - <div - data-slot="document-chat-body" - className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4" - > - {indexed ? ( - <DocumentChatConversation - messages={messages} - optimisticContent={showOptimistic ? optimisticUser : null} - streamingContent={streaming.content} - isStreaming={isStreaming} - streamErrorKey={ - streaming.errorCode - ? documentChatErrorKey(streaming.errorCode) - : null - } - historyPending={thread.isPending} - historyError={thread.isError} - draft={draft} - onDraftChange={setDraft} - onSubmit={submit} - onClose={() => handleOpenChange(false)} - logRef={logRef} - /> - ) : ( - // Not indexed: a calm pointer to read it with AI first (never an - // error) — the drawer opens, it just has nothing to ground on yet. - <div - data-slot="document-chat" - className="border-border/60 space-y-2 border-t pt-3" - > - <p className="text-sm font-semibold"> - {t("documents.chat.title")} - </p> - <p - data-slot="document-chat-not-indexed" - className="text-muted-foreground text-xs" - > - {t("documents.chat.notIndexed")} - </p> - </div> - )} - </div> - </SheetContent> - </Sheet> - ); -} diff --git a/src/components/documents/document-chat-panel.tsx b/src/components/documents/document-chat-panel.tsx deleted file mode 100644 index 828ce72ee..000000000 --- a/src/components/documents/document-chat-panel.tsx +++ /dev/null @@ -1,278 +0,0 @@ -"use client"; - -/** - * v1.27.33 (Document vault P4) — the "Chat about this document" panel that lives - * inside the detail sheet's AI area, beneath the "Read with AI" actions. It opens - * a scoped conversation grounded ONLY in this one document's stored text: a - * message list + an input that streams the assistant reply token-by-token over - * the SSE route, with prior turns loaded via GET. - * - * Rendered by the sheet ONLY when a provider is available (it fills - * `DocumentAiSection`'s `chatSlot`, which sits in the provider-available branch), - * so the panel itself only gates on whether the document is content-INDEXED — - * the text is the sole grounding. Not indexed → a calm "read it with AI first" - * hint (never an error), pointing at the action directly above. - * - * Rendering posture (matches the rest of the AI surfaces): assistant prose is - * PLAIN React text via `ProseBlocks` / `StreamedProse` — there is no markdown - * library and no `dangerouslySetInnerHTML` (the project's XSS rule). Any citation - * the model emits ("per the report's Impression…") is just text. A one-line - * safety note states answers describe the document and are not medical advice, - * consistent with the summary panel's "not a diagnosis". - * - * This module owns the PURE surface: `DocumentChatConversation` renders the - * open body (log + input + safety note) from props, so the conversation is - * statically renderable and pinned by tests without a query client, and - * `documentChatErrorKey` maps a server / client code to a calm copy key. The - * stateful container (hooks + open/gate state) lives in - * `document-chat-drawer.tsx`, which presents this body in the Coach drawer - * chrome scoped to a single document. - */ -import { MessageSquare, Send } from "lucide-react"; -import { type RefObject } from "react"; - -import { StreamedProse } from "@/components/insights/coach-panel/streamed-prose"; -import { ProseBlocks } from "@/components/insights/prose-blocks"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -import { type DocumentChatMessage } from "./use-document-chat"; - -/** Map a server / client error code to a calm translation key. */ -export function documentChatErrorKey(code: string | null): string { - switch (code) { - case "documents.chat.provider.rate_limited": - case "documents.inbound.rateLimited": - return "documents.chat.errorRateLimited"; - case "documents.chat.budget.exceeded": - return "documents.chat.errorBudget"; - case "documents.chat.provider.credential_expired": - return "documents.chat.errorCredential"; - case "consent.ai.required": - return "documents.chat.errorConsent"; - case "documents.chat.network": - return "documents.chat.errorNetwork"; - default: - return "documents.chat.errorGeneric"; - } -} - -/** One settled turn — user bubble right, assistant prose left (plain text). */ -function ChatTurn({ message }: { message: DocumentChatMessage }) { - if (message.role === "user") { - return ( - <div - data-slot="document-chat-message" - data-role="user" - className="flex justify-end" - > - <div className="bg-primary/10 border-primary/20 text-foreground max-w-[85%] rounded-xl rounded-tr-sm border px-3 py-2 text-sm leading-relaxed"> - {/* User text is verbatim: no chart-token strip, no Learn linkify. */} - <ProseBlocks text={message.content} strip={false} linkify={false} /> - </div> - </div> - ); - } - return ( - <div - data-slot="document-chat-message" - data-role="assistant" - className="flex justify-start" - > - <div className="bg-card border-border text-foreground max-w-[85%] rounded-xl rounded-tl-sm border px-3 py-2 text-sm"> - <ProseBlocks text={message.content} /> - </div> - </div> - ); -} - -/** - * The open conversation body — pure (props in, markup out). Renders the message - * log (persisted + optimistic + streaming tail), the composer, the always-on - * safety note, and a close control. No hooks that need a query client, so it is - * statically renderable and unit-pinned. - */ -export function DocumentChatConversation({ - messages, - optimisticContent, - streamingContent, - isStreaming, - streamErrorKey, - historyPending, - historyError, - draft, - onDraftChange, - onSubmit, - onClose, - logRef, -}: { - messages: DocumentChatMessage[]; - /** The optimistic user turn to render, or null when none is pending. */ - optimisticContent: string | null; - /** Concatenated streamed assistant tokens so far (empty until the first). */ - streamingContent: string; - isStreaming: boolean; - /** Translation key for a stream error, or null. */ - streamErrorKey: string | null; - historyPending: boolean; - historyError: boolean; - draft: string; - onDraftChange: (value: string) => void; - onSubmit: () => void; - onClose: () => void; - logRef?: RefObject<HTMLDivElement | null>; -}) { - const { t } = useTranslations(); - const isEmpty = - !historyPending && - messages.length === 0 && - optimisticContent === null && - !isStreaming && - streamingContent.length === 0; - - return ( - <div - data-slot="document-chat" - data-state="open" - className="border-border/60 space-y-3 border-t pt-3" - > - <div className="flex items-center gap-2"> - <MessageSquare - className="text-foreground size-4 shrink-0" - aria-hidden - /> - <p className="text-sm font-semibold">{t("documents.chat.title")}</p> - </div> - - <div - ref={logRef} - data-slot="document-chat-log" - role="log" - aria-live="polite" - aria-relevant="additions text" - aria-label={t("documents.chat.title")} - className="max-h-72 space-y-3 overflow-y-auto overscroll-contain" - > - {historyPending ? ( - <div className="space-y-2" data-slot="document-chat-loading"> - <Skeleton className="ml-auto h-8 w-2/3 rounded-xl" /> - <Skeleton className="h-12 w-4/5 rounded-xl" /> - </div> - ) : null} - - {isEmpty ? ( - <p - data-slot="document-chat-empty" - className="text-muted-foreground text-xs" - > - {t("documents.chat.empty")} - </p> - ) : null} - - {messages.map((m) => ( - <ChatTurn key={m.id} message={m} /> - ))} - - {optimisticContent !== null ? ( - <ChatTurn - message={{ - id: "optimistic", - role: "user", - content: optimisticContent, - createdAt: "", - }} - /> - ) : null} - - {isStreaming && streamingContent.length === 0 && !streamErrorKey ? ( - <p - data-slot="document-chat-thinking" - className="text-muted-foreground text-xs" - > - {t("documents.chat.thinking")} - </p> - ) : null} - - {streamingContent.length > 0 ? ( - <div - data-slot="document-chat-message" - data-role="assistant" - className="flex justify-start" - > - <div className="bg-card border-border text-foreground max-w-[85%] rounded-xl rounded-tl-sm border px-3 py-2 text-sm"> - <StreamedProse - content={streamingContent} - streaming={isStreaming} - /> - </div> - </div> - ) : null} - </div> - - {streamErrorKey ? ( - <p role="alert" className="text-destructive text-sm"> - {t(streamErrorKey)} - </p> - ) : null} - - {historyError ? ( - <p role="alert" className="text-destructive text-sm"> - {t("documents.chat.errorHistory")} - </p> - ) : null} - - <form - className="flex items-end gap-2" - onSubmit={(e) => { - e.preventDefault(); - onSubmit(); - }} - > - <Input - value={draft} - onChange={(e) => onDraftChange(e.target.value)} - placeholder={t("documents.chat.placeholder")} - aria-label={t("documents.chat.inputLabel")} - maxLength={4000} - disabled={isStreaming} - data-slot="document-chat-input" - autoComplete="off" - /> - <Button - type="submit" - size="icon" - data-slot="document-chat-send" - disabled={isStreaming || draft.trim().length === 0} - aria-label={t("documents.chat.send")} - > - <Send className="size-4" aria-hidden /> - </Button> - </form> - - {/* Safety-visible: answers describe the document, not medical advice — - consistent with the summary panel's "not a diagnosis". */} - <p - data-slot="document-chat-safety" - className="text-muted-foreground text-xs" - > - {t("documents.chat.safety")} - </p> - - {/* Retiring the panel discards the live stream state (the persisted - history stays on disk and reloads next open). */} - <button - type="button" - data-slot="document-chat-close" - onClick={onClose} - className={cn( - "text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline", - )} - > - {t("documents.chat.close")} - </button> - </div> - ); -} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 48c3409b6..7320df94e 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -28,6 +28,7 @@ import { X, } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -69,7 +70,6 @@ import { type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; -import { DocumentChatDrawer } from "./document-chat-drawer"; import type { DocumentAiTarget } from "./document-ai-transport"; import { DocumentShareSheet } from "./document-share-sheet"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; @@ -216,6 +216,7 @@ export function DocumentDetailSheet({ const { t, locale } = useTranslations(); const format = useFormatters(); const queryClient = useQueryClient(); + const router = useRouter(); const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId ?? "none"), @@ -241,9 +242,6 @@ export function DocumentDetailSheet({ const [mutationError, setMutationError] = useState<string | null>(null); const [shareOpen, setShareOpen] = useState(false); - // The doc-scoped Coach chat drawer — opened by the neutral top-right icon in - // the sheet header, mounted as a sibling overlay of this detail sheet. - const [docChatOpen, setDocChatOpen] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>( @@ -264,7 +262,6 @@ export function DocumentDetailSheet({ setLastDocumentId(documentId); setEditingTitle(false); setShareOpen(false); - setDocChatOpen(false); setMutationError(null); setSuggestion(null); setAppliedFields({ title: false, kind: false, date: false }); @@ -475,9 +472,14 @@ export function DocumentDetailSheet({ </Button> <div className="flex items-center gap-2"> {aiEnabled ? ( + // v1.28.51 (Documents R3, Design A) — "Ask the Coach" opens + // the REAL coach view scoped to this document + // (`/coach?doc=<id>`), replacing the bespoke doc-chat drawer. + // Every doc turn there still runs on the hardened fenced + // document endpoint (never the coach tool route). <Button variant="outline" - onClick={() => setDocChatOpen(true)} + onClick={() => router.push(`/coach?doc=${doc.id}`)} data-slot="document-chat-open" aria-label={t("documents.chat.openAria")} > @@ -780,16 +782,6 @@ export function DocumentDetailSheet({ documents={[{ id: doc.id, title }]} /> ) : null} - - {doc && aiEnabled ? ( - <DocumentChatDrawer - open={docChatOpen} - onOpenChange={setDocChatOpen} - documentId={doc.id} - indexed={doc.hasContentIndex} - documentTitle={title} - /> - ) : null} </> ); } diff --git a/src/components/documents/use-document-chat.ts b/src/components/documents/use-document-chat.ts deleted file mode 100644 index 62668d6e5..000000000 --- a/src/components/documents/use-document-chat.ts +++ /dev/null @@ -1,303 +0,0 @@ -"use client"; - -/** - * v1.27.33 (Document vault P4) — client hooks for the scoped "chat about this - * document" panel: the prior-thread history (GET) and the SSE-streaming send - * (POST). Deliberately lean — one active thread per document, no tools, no - * snapshot; the whole feature is a single grounded completion per turn on the - * server, so the client only needs a message list + a live streaming tail. - * - * Streaming reuses the Coach's pure SSE frame parser (`parseSseChunk`) — the - * document route emits the same `token` / `done` / `error` wire frames (a subset - * of `CoachStreamEvent`). Assistant prose renders as PLAIN React text on the - * panel (no markdown library, the project's XSS posture); this hook only carries - * the raw string. - */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -import type { - CoachConversationDetailDTO, - CoachConversationsPage, -} from "@/lib/ai/coach/types"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; - -import { parseSseChunk } from "@/components/insights/coach-panel/use-coach"; - -/** One persisted turn in a document chat, trimmed to what the panel renders. */ -export interface DocumentChatMessage { - id: string; - role: "user" | "assistant"; - content: string; - createdAt: string; -} - -/** The document's active chat thread: the newest conversation + its messages. */ -export interface DocumentChatThread { - conversationId: string | null; - messages: DocumentChatMessage[]; -} - -const EMPTY_THREAD: DocumentChatThread = { - conversationId: null, - messages: [], -}; - -/** - * Load the document's active chat thread. The list endpoint returns the - * document's conversations newest-first; the panel keeps ONE active thread, so - * this resolves the newest conversation and fetches its decrypted messages in a - * single query. Returns an empty thread when the document has never been chatted - * about (no conversation yet) — the panel then starts a fresh one on first send. - */ -export function useDocumentChatThread( - documentId: string | null, - enabled: boolean, -) { - return useQuery<DocumentChatThread>({ - queryKey: queryKeys.inboundDocumentChat(documentId ?? "none"), - enabled: enabled && documentId !== null, - staleTime: 30_000, - queryFn: async () => { - if (!documentId) return EMPTY_THREAD; - const page = await apiGet<CoachConversationsPage>( - `/api/documents/inbound/${documentId}/chat`, - ); - const newest = page.conversations[0]; - if (!newest) return EMPTY_THREAD; - const detail = await apiGet<CoachConversationDetailDTO>( - `/api/documents/inbound/${documentId}/chat?conversationId=${encodeURIComponent( - newest.id, - )}`, - ); - return { - conversationId: detail.id, - messages: detail.messages.map((m) => ({ - id: m.id, - role: m.role === "assistant" ? "assistant" : "user", - content: m.content, - createdAt: m.createdAt, - })), - }; - }, - }); -} - -/** Live state of the assistant turn currently streaming into the panel. */ -export interface DocumentChatStreamingState { - /** Concatenated tokens received so far. */ - content: string; - /** True until the `done` frame closes the stream. */ - inProgress: boolean; - /** Server-side error code from an `error` frame / rejected POST, if any. */ - errorCode: string | null; -} - -const EMPTY_STREAMING: DocumentChatStreamingState = { - content: "", - inProgress: false, - errorCode: null, -}; - -export interface SendDocumentChatParams { - conversationId?: string; - message: string; - locale?: "en" | "de"; -} - -/** - * The streaming send hook. `send()` POSTs the turn to the document chat route, - * walks the SSE byte stream, and updates `streaming` after every frame. On the - * `done` frame the document's chat thread cache is invalidated so the persisted - * (encrypted-on-disk) history reloads with the canonical message ids. An - * `optimisticUser` bubble surfaces the just-sent message immediately, before the - * assistant's placeholder, and is cleared once the persisted twin lands. - */ -export function useSendDocumentChatMessage(documentId: string) { - const queryClient = useQueryClient(); - const [streaming, setStreaming] = - useState<DocumentChatStreamingState>(EMPTY_STREAMING); - const [optimisticUser, setOptimisticUser] = useState<string | null>(null); - const abortRef = useRef<AbortController | null>(null); - - const reset = useCallback(() => { - setStreaming(EMPTY_STREAMING); - setOptimisticUser(null); - }, []); - - useEffect(() => { - // Cancel any in-flight stream when the panel unmounts / document switches. - return () => abortRef.current?.abort(); - }, []); - - const send = useCallback( - async (params: SendDocumentChatParams): Promise<string | null> => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - - setOptimisticUser(params.message); - setStreaming({ content: "", inProgress: true, errorCode: null }); - - if (typeof navigator !== "undefined" && navigator.onLine === false) { - setStreaming({ - content: "", - inProgress: false, - errorCode: "documents.chat.network", - }); - return null; - } - - // apiFetchRaw: the POST streams SSE — the envelope helpers would buffer - // and unwrap a body that never carries the envelope. - let response: Response; - try { - response = await apiFetchRaw( - `/api/documents/inbound/${documentId}/chat`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body: JSON.stringify({ - conversationId: params.conversationId, - message: params.message, - locale: params.locale, - }), - signal: controller.signal, - }, - ); - } catch (err) { - if ((err as Error).name === "AbortError") return null; - setStreaming({ - content: "", - inProgress: false, - errorCode: "documents.chat.network", - }); - return null; - } - - const contentType = response.headers.get("Content-Type") ?? ""; - const isEventStream = contentType - .toLowerCase() - .startsWith("text/event-stream"); - - // A rejected turn (rate limit, budget, consent, not-indexed) is a JSON - // error envelope, not an SSE body. Surface its structured `error` code so - // the panel shows the right copy; keep the optimistic bubble standing next - // to it (no persisted twin is coming). - if (!response.ok && !isEventStream) { - let structured: string | null = null; - try { - const envelope = (await response.clone().json()) as { - error?: unknown; - meta?: { errorCode?: unknown }; - }; - if (envelope?.meta && typeof envelope.meta.errorCode === "string") { - structured = envelope.meta.errorCode; - } else if (typeof envelope?.error === "string") { - structured = envelope.error; - } - } catch { - // body was not JSON; fall through to the http-status fallback - } - setStreaming({ - content: "", - inProgress: false, - errorCode: structured ?? `documents.chat.http.${response.status}`, - }); - return null; - } - - if (!response.body) { - setStreaming({ - content: "", - inProgress: false, - errorCode: `documents.chat.http.${response.status}`, - }); - return null; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let collectedContent = ""; - let resolvedConversationId: string | null = null; - let lastError: string | null = null; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - const { events, rest } = parseSseChunk(buffer, text); - buffer = rest; - for (const evt of events) { - switch (evt.type) { - case "token": - collectedContent += evt.token; - setStreaming((prev) => ({ - ...prev, - content: prev.content + evt.token, - })); - break; - case "done": - resolvedConversationId = evt.conversationId; - break; - case "error": - lastError = evt.code; - break; - default: - // Additive wire frames (provenance / suggestion / …) never - // occur on the document route; ignore them defensively. - break; - } - } - } - const tail = parseSseChunk(buffer, "\n\n"); - for (const evt of tail.events) { - if (evt.type === "done") resolvedConversationId = evt.conversationId; - else if (evt.type === "error") lastError = evt.code; - } - } catch (err) { - if ((err as Error).name !== "AbortError") { - lastError = "documents.chat.stream"; - } - } - - setStreaming({ - content: collectedContent, - inProgress: false, - errorCode: lastError, - }); - - if (resolvedConversationId) { - // Reload the encrypted-on-disk thread (canonical ids + any outbound - // editorialisation), then clear the live tail + optimistic bubble so the - // persisted turns are the single source — the refetch is awaited, so the - // canonical messages are in cache before the streamed copy is dropped - // (no gap, no duplicate assistant bubble). - await queryClient.invalidateQueries({ - queryKey: queryKeys.inboundDocumentChat(documentId), - }); - setStreaming(EMPTY_STREAMING); - setOptimisticUser(null); - } - return resolvedConversationId; - }, - [documentId, queryClient], - ); - - const cancel = useCallback(() => abortRef.current?.abort(), []); - - return { - streaming, - isStreaming: streaming.inProgress, - optimisticUser, - send, - cancel, - reset, - }; -} diff --git a/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts b/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts new file mode 100644 index 000000000..003eaaa04 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; + +import { resolveCoachSendTarget } from "../use-coach"; + +/** + * v1.28.51 (Documents R3, Design A) — the prompt-injection fence rests on ONE + * decision: a document-scoped turn must go to the hardened fenced document + * endpoint and NEVER to the full Coach tool route (`/api/insights/chat`, which + * injects the health snapshot + runs the write-tool loop). These tests pin that + * branch so a regression that routes untrusted document text through the tool + * loop fails loudly here. + */ +describe("resolveCoachSendTarget", () => { + it("routes a doc-scoped turn to the fenced document endpoint, never the tool route", () => { + const target = resolveCoachSendTarget({ + conversationId: "conv-1", + message: "What does this lab say?", + documentId: "doc-abc", + // Coach-only fields that MUST NOT influence the fenced path. + scope: { sources: ["bp"] }, + guidedQuestion: "how are you sleeping?", + prefill: "seed", + }); + + expect(target.url).toBe("/api/documents/inbound/doc-abc/chat"); + // The acceptance criterion above all others: a doc turn never hits the + // coach tool route. + expect(target.url).not.toBe("/api/insights/chat"); + expect(target.url).not.toContain("/api/insights/chat"); + + const body = JSON.parse(target.body); + expect(body).toEqual({ + conversationId: "conv-1", + message: "What does this lab say?", + locale: undefined, + }); + // The tool-driving fields are stripped — the fenced endpoint has no tools, + // no snapshot, no guided flow. + expect(body).not.toHaveProperty("scope"); + expect(body).not.toHaveProperty("guidedQuestion"); + expect(body).not.toHaveProperty("prefill"); + }); + + it("URL-encodes the document id in the fenced path", () => { + const target = resolveCoachSendTarget({ + message: "hi", + documentId: "doc/../etc", + }); + expect(target.url).toBe("/api/documents/inbound/doc%2F..%2Fetc/chat"); + }); + + it("routes a normal health turn to the Coach tool route", () => { + const target = resolveCoachSendTarget({ + conversationId: "conv-2", + message: "How is my BP trending?", + scope: { sources: ["bp"] }, + guidedQuestion: "q", + }); + expect(target.url).toBe("/api/insights/chat"); + const body = JSON.parse(target.body); + expect(body.message).toBe("How is my BP trending?"); + expect(body.scope).toEqual({ sources: ["bp"] }); + expect(body.guidedQuestion).toBe("q"); + }); + + it.each([undefined, null, ""])( + "treats %p documentId as a normal health turn (tool route)", + (documentId) => { + const target = resolveCoachSendTarget({ + message: "hello", + documentId: documentId as string | null | undefined, + }); + expect(target.url).toBe("/api/insights/chat"); + }, + ); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0d07d2e25..35a9b55e5 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -4,7 +4,7 @@ import { useEffect, useReducer, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Plus, Settings, Sparkles } from "lucide-react"; +import { FileText, Plus, Settings, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -15,6 +15,7 @@ import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { CoachScope } from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route"; +import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; import { metricScopeLabelFallback, scopeSourceMetricLabelKey, @@ -191,6 +192,15 @@ export interface CoachConversationProps { * "new chat" or thread switch is never overridden. */ autoOpenMostRecent?: boolean; + /** + * v1.28.51 (Documents R3, Design A) — seed a fresh chat SCOPED to a stored + * document. The `/coach?doc=<id>` deep-link (the detail sheet's "Ask the + * Coach" action) passes it so the first turn is created + sent through the + * HARDENED fenced document endpoint, and the scope badge + not-indexed hint + * render before anything is typed. Once a thread exists the scope is read + * authoritatively from the loaded conversation's `documentId` instead. + */ + initialDocumentId?: string | null; } export function CoachConversation({ @@ -208,6 +218,7 @@ export function CoachConversation({ surface, initialConversationId, autoOpenMostRecent = false, + initialDocumentId, }: CoachConversationProps) { const { t } = useTranslations(); const router = useRouter(); @@ -259,12 +270,48 @@ export function CoachConversation({ }); const { data: conversation } = useCoachConversation(currentConversationId); + + // v1.28.51 (Documents R3, Design A) — document scope. `pendingDocumentId` + // seeds a fresh, not-yet-created chat from the `?doc=<id>` deep-link; once a + // thread exists the loaded conversation's own `documentId` is authoritative. + // `activeDocumentId` is what drives the send-path branch (fenced document + // endpoint vs. Coach tool route) and the scope badge. Cleared whenever the + // user starts a new chat or switches threads, so a stale doc scope can never + // leak onto a health thread. + const [pendingDocumentId, setPendingDocumentId] = useState<string | null>( + initialDocumentId ?? null, + ); + const activeDocumentId = + conversation?.documentId ?? pendingDocumentId ?? null; + const send = useSendCoachMessage({ onDone: (resolvedId) => { setCurrentConversationId(resolvedId); }, }); + // Resolve the document's badge title + indexed status. `documentTitle` on the + // loaded conversation covers a persisted thread; the detail fetch covers the + // fresh `?doc=` case (no thread yet) AND supplies `hasContentIndex` for the + // "read it with AI first" hint (the conversation DTO does not carry it). + const { data: docDetail } = useQuery({ + queryKey: queryKeys.inboundDocument(activeDocumentId ?? "none"), + enabled: activeDocumentId !== null, + staleTime: 60_000, + queryFn: async () => + apiGet<InboundDocumentDetailDto>( + `/api/documents/inbound/${activeDocumentId}`, + ), + }); + const docScopeTitle = + conversation?.documentTitle ?? + docDetail?.title ?? + docDetail?.filename ?? + t("documents.card.untitled"); + // Only warn when we positively know the document is NOT indexed. While the + // detail is still loading we say nothing rather than flash a false hint. + const docScopeNotIndexed = docDetail ? !docDetail.hasContentIndex : false; + // v1.18.11 (W11, #67) — auto-open the most-recent conversation when the // surface mounts with no explicit selection. Only fetches the rail list // when the behaviour is requested AND no deep-linked id was supplied, so @@ -325,11 +372,14 @@ export function CoachConversation({ : undefined; // v1.16.6 — hand the question to the turn so the Coach reaction is // contextual (the question bubble itself is never persisted). + // v1.28.51 — `documentId` routes a doc-scoped turn through the hardened + // fenced endpoint (see `resolveCoachSendTarget`); undefined on health chats. const resolvedId = await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, guidedQuestion: guidedQuestion ?? undefined, scope, + documentId: activeDocumentId ?? undefined, }); if (guidedQuestion !== null && guidedIndex !== null) { setPendingAdopt({ @@ -392,6 +442,7 @@ export function CoachConversation({ conversationId: currentConversationId ?? undefined, message: trimmed, scope, + documentId: activeDocumentId ?? undefined, }); } @@ -399,6 +450,8 @@ export function CoachConversation({ setCurrentConversationId(null); setInputValue(""); setPendingAdopt(null); + // v1.28.51 — a new chat is always a health thread; drop any document scope. + setPendingDocumentId(null); dispatchGuided({ type: "RESET" }); send.reset(); } @@ -657,6 +710,33 @@ export function CoachConversation({ </div> ); + // v1.28.51 (Documents R3, Design A) — the scope banner for a doc-scoped + // thread: a small "Document" badge + "Chatting about: <title>", plus the + // "read it with AI first" hint when the document is not yet indexed. Rendered + // in BOTH surfaces (page + drawer) above the thread so the fenced scope is + // always visible. Null on a normal health thread. + const docScopeBanner = activeDocumentId ? ( + <div + data-slot="coach-doc-scope" + className="border-border/70 bg-muted/20 flex shrink-0 flex-col gap-1 border-b px-4 py-2 sm:px-6" + > + <div className="flex min-w-0 items-center gap-2 text-xs"> + <span className="bg-primary/15 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium"> + <FileText className="size-3" aria-hidden="true" /> + {t("insights.coach.docScope.badge")} + </span> + <span className="text-muted-foreground truncate"> + {t("insights.coach.docScope.chattingAbout", { title: docScopeTitle })} + </span> + </div> + {docScopeNotIndexed ? ( + <p className="text-muted-foreground text-[11px] leading-snug"> + {t("insights.coach.docScope.notIndexedHint")} + </p> + ) : null} + </div> + ) : null; + // v1.18.11 (W11) — the PAGE surface drops the top header bar and the // rail-tray strip entirely. The composer is the single control hub; the // thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer @@ -671,6 +751,7 @@ export function CoachConversation({ data-variant={surface} className={cn("flex min-h-0 flex-1 flex-col", className)} > + {docScopeBanner} {/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the composer's `+` actions menu alongside New chat and Conversations, keeping the page chrome to the composer alone. */} @@ -777,6 +858,8 @@ export function CoachConversation({ {trailingHeaderActions} </header> + {docScopeBanner} + {/* Drawer surface keeps its existing body chrome: thread + rail-tray strip + docked composer, with the conversation list + sources as mobile trays. The "Conversations" affordance hands off to the @@ -807,6 +890,9 @@ export function CoachConversation({ setCurrentConversationId(id); setHistoryTrayOpen(false); setPendingAdopt(null); + // v1.28.51 — the selected thread's own `documentId` is now + // authoritative; drop any pending `?doc=` seed so it can't leak. + setPendingDocumentId(null); dispatchGuided({ type: "RESET" }); }} /> diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index d970706f6..4a9f7f744 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { MessagesSquare, Search, Trash2 } from "lucide-react"; +import { FileText, MessagesSquare, Search, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -161,7 +161,18 @@ export function HistoryRail({ className="min-w-0 flex-1 truncate text-left" data-slot="coach-history-select" > - <span className="block truncate font-medium">{c.title}</span> + <span className="flex min-w-0 items-center gap-1.5 font-medium"> + {/* v1.28.51 — a doc-scoped thread runs on the hardened + fenced endpoint; badge it so the two are visibly + distinct in the rail. */} + {c.documentId ? ( + <FileText + className="text-primary size-3.5 shrink-0" + aria-label={t("insights.coach.docScope.badge")} + /> + ) : null} + <span className="truncate">{c.title}</span> + </span> <span className="text-muted-foreground block text-[11px]"> {formatRelativeTime(c.updatedAt, t)} </span> diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 8fe50ecfb..e58b56d03 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -267,6 +267,57 @@ export interface SendCoachMessageParams { * persisted with the message. */ guidedQuestion?: string; + /** + * v1.28.51 (Documents R3, Design A) — when set, this turn belongs to a + * DOCUMENT-scoped conversation. It is sent to the HARDENED fenced document + * endpoint (`/api/documents/inbound/<id>/chat`) — no tools, no health + * snapshot, fenced doc text — and NEVER to the full Coach tool route. This is + * the single branch that keeps untrusted document text off the write tools; + * see `resolveCoachSendTarget`. + */ + documentId?: string | null; +} + +/** + * v1.28.51 (Documents R3, Design A) — the ONE branch point that decides which + * backend a Coach turn hits. When `documentId` is set the turn goes to the + * hardened, fenced document chat endpoint; otherwise the normal Coach route + * (tool loop + health snapshot). Extracted as a pure function so a unit test + * can prove a doc-scoped send never resolves to the tool route — the whole + * prompt-injection fence rests on this decision. + */ +export function resolveCoachSendTarget(params: SendCoachMessageParams): { + url: string; + body: string; +} { + const documentId = + typeof params.documentId === "string" && params.documentId.length > 0 + ? params.documentId + : null; + if (documentId) { + // Fenced document path. Only the three fields the document route accepts — + // NOT `scope` / `guidedQuestion` / `prefill`, which drive the coach's + // snapshot + tool behaviour and have no place on the hardened endpoint. + return { + url: `/api/documents/inbound/${encodeURIComponent(documentId)}/chat`, + body: JSON.stringify({ + conversationId: params.conversationId, + message: params.message, + locale: params.locale, + }), + }; + } + return { + url: "/api/insights/chat", + body: JSON.stringify({ + conversationId: params.conversationId, + message: params.message, + prefill: params.prefill, + locale: params.locale, + scope: params.scope, + guidedQuestion: params.guidedQuestion, + }), + }; } export interface UseSendCoachMessageOptions { @@ -362,24 +413,22 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { return null; } + // v1.28.51 — resolve the target BEFORE the fetch. A doc-scoped turn + // resolves to the hardened fenced document endpoint; everything else to + // the Coach tool route. This is the prompt-injection fence's client edge. + const target = resolveCoachSendTarget(params); + // apiFetchRaw: the chat POST streams SSE — the envelope helpers // would buffer and unwrap a body that never carries the envelope. let response: Response; try { - response = await apiFetchRaw("/api/insights/chat", { + response = await apiFetchRaw(target.url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, - body: JSON.stringify({ - conversationId: params.conversationId, - message: params.message, - prefill: params.prefill, - locale: params.locale, - scope: params.scope, - guidedQuestion: params.guidedQuestion, - }), + body: target.body, signal: controller.signal, }); } catch (err) { diff --git a/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts b/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts new file mode 100644 index 000000000..bccf7d9b9 --- /dev/null +++ b/src/lib/ai/coach/__tests__/persistence-document-scope.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +/** + * v1.28.51 (Documents R3, Design A) — the Coach rail + detail READERS now + * surface document-scoped threads alongside health threads (the DTO carries + * `documentId` + `documentTitle`). These tests pin two invariants of the + * filter relaxation: + * 1. Relaxing the `documentId` scope must NEVER relax the `userId` scope — + * ownership stays narrowed on every read. + * 2. Omitting the `documentId` option drops the scope filter (a union of both + * health + doc threads); passing it keeps the filter (the document route's + * own isolation is unchanged). + * The DTO now resolves `documentTitle` from the joined document. + */ + +const findMany = vi.fn(); +const findFirst = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + coachConversation: { + findMany: (...args: unknown[]) => findMany(...args), + findFirst: (...args: unknown[]) => findFirst(...args), + }, + }, +})); + +vi.mock("../bytes-codec", () => ({ + encryptToBytes: vi.fn(), + decryptFromBytes: vi.fn(() => "decrypted body"), +})); + +import { + fetchConversationWithMessages, + listConversations, +} from "../persistence"; + +const now = new Date("2026-07-16T10:00:00.000Z"); + +beforeEach(() => { + findMany.mockReset(); + findFirst.mockReset(); +}); + +describe("listConversations — filter relaxation + DTO", () => { + it("omits the documentId filter when no documentId is passed (union of both scopes), but keeps userId narrowed", async () => { + findMany.mockResolvedValue([ + { + id: "c1", + title: "Health thread", + createdAt: now, + updatedAt: now, + documentId: null, + _count: { messages: 3 }, + document: null, + }, + { + id: "c2", + title: "Doc thread", + createdAt: now, + updatedAt: now, + documentId: "doc-1", + _count: { messages: 2 }, + document: { title: "Blood panel", filename: "panel.pdf" }, + }, + ]); + + const page = await listConversations({ userId: "user-1" }); + + const where = findMany.mock.calls[0][0].where; + // userId stays narrowed — the relaxation never widens ownership. + expect(where.userId).toBe("user-1"); + // No documentId key at all → no scope filter → both scopes returned. + expect("documentId" in where).toBe(false); + + // The DTO now carries the scope discriminator + resolved title. + expect(page.conversations[0]).toMatchObject({ + id: "c1", + documentId: null, + documentTitle: null, + }); + expect(page.conversations[1]).toMatchObject({ + id: "c2", + documentId: "doc-1", + documentTitle: "Blood panel", + }); + }); + + it("still applies the documentId filter when the document route passes it (isolation unchanged)", async () => { + findMany.mockResolvedValue([]); + await listConversations({ userId: "user-1", documentId: "doc-9" }); + const where = findMany.mock.calls[0][0].where; + expect(where.userId).toBe("user-1"); + expect(where.documentId).toBe("doc-9"); + }); + + it("falls back to filename when the document has no title", async () => { + findMany.mockResolvedValue([ + { + id: "c3", + title: "t", + createdAt: now, + updatedAt: now, + documentId: "doc-3", + _count: { messages: 1 }, + document: { title: null, filename: "scan.pdf" }, + }, + ]); + const page = await listConversations({ userId: "user-1" }); + expect(page.conversations[0].documentTitle).toBe("scan.pdf"); + }); +}); + +describe("fetchConversationWithMessages — filter relaxation + DTO", () => { + it("reads across both scopes (no documentId filter) while keeping userId narrowed", async () => { + findFirst.mockResolvedValue({ + id: "c2", + title: "Doc thread", + createdAt: now, + updatedAt: now, + documentId: "doc-1", + summaryEncrypted: null, + document: { title: "Blood panel", filename: "panel.pdf" }, + messages: [ + { + id: "m1", + role: "user", + encryptedContent: new Uint8Array(), + metricSourceJson: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + createdAt: now, + }, + ], + }); + + const detail = await fetchConversationWithMessages("user-1", "c2"); + + const where = findFirst.mock.calls[0][0].where; + expect(where.id).toBe("c2"); + expect(where.userId).toBe("user-1"); + // No documentId opt → no scope filter (the relaxed coach detail reader). + expect("documentId" in where).toBe(false); + + expect(detail?.documentId).toBe("doc-1"); + expect(detail?.documentTitle).toBe("Blood panel"); + }); + + it("keeps the documentId filter when the document route passes it", async () => { + findFirst.mockResolvedValue(null); + await fetchConversationWithMessages("user-1", "c2", { + documentId: "doc-1", + }); + const where = findFirst.mock.calls[0][0].where; + expect(where.userId).toBe("user-1"); + expect(where.documentId).toBe("doc-1"); + }); + + it("keeps the strict null-scope filter when the coach SEND path passes documentId: null", async () => { + findFirst.mockResolvedValue(null); + await fetchConversationWithMessages("user-1", "c2", { documentId: null }); + const where = findFirst.mock.calls[0][0].where; + expect(where.userId).toBe("user-1"); + // The tool-route send path stays fenced to health threads. + expect(where.documentId).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 636ef294a..d85090c48 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -163,9 +163,28 @@ export async function createConversation( createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), messageCount: 0, + // v1.28.51 — carry the scope discriminator on the created DTO. The title + // is resolved lazily by the list/detail reads (which join the document); + // create does not join, so a fresh doc thread reports null until reloaded. + documentId: row.documentId ?? null, + documentTitle: null, }; } +/** + * v1.28.51 (Documents R3) — resolve a joined document's badge title: its + * user-given `title`, falling back to the `filename`, or null when neither is + * set (or the thread has no document). Both columns are plaintext already + * (see the schema note on `InboundDocument.title`), so this leaks no health + * values the row did not already carry in the clear. + */ +function resolveDocumentTitle( + document: { title: string | null; filename: string | null } | null, +): string | null { + if (!document) return null; + return document.title ?? document.filename ?? null; +} + /** * Append one message to an existing conversation. Bumps the parent * `updatedAt` so the history rail orders by most-recent-activity. @@ -302,6 +321,10 @@ export async function fetchConversationWithMessages( orderBy: { createdAt: "desc" }, take: CONVERSATION_MESSAGE_DETAIL_CAP, }, + // v1.28.51 — join the owning document (if any) so the detail DTO can + // badge a doc-scoped thread with its title. Only the two plaintext + // label columns are selected; the encrypted document body is untouched. + document: { select: { title: true, filename: true } }, }, }); if (!row) return null; @@ -339,6 +362,8 @@ export async function fetchConversationWithMessages( messageCount: messages.length, messages, summary, + documentId: row.documentId ?? null, + documentTitle: resolveDocumentTitle(row.document), }; } @@ -381,6 +406,9 @@ export async function listConversations( : {}), include: { _count: { select: { messages: true } }, + // v1.28.51 — join the owning document (label columns only) so the rail + // can badge a doc-scoped thread with its title. Null on Coach threads. + document: { select: { title: true, filename: true } }, }, }); @@ -394,6 +422,8 @@ export async function listConversations( createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), messageCount: r._count.messages, + documentId: r.documentId ?? null, + documentTitle: resolveDocumentTitle(r.document), })), nextCursor, }; diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 1618d5ceb..9a4c1d26c 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -369,6 +369,22 @@ export interface CoachConversationDTO { createdAt: string; updatedAt: string; messageCount: number; + /** + * v1.28.51 (Documents R3, Design A) — when set, this thread is a chat SCOPED + * to one stored document (the discriminator `coach_conversations.document_id`). + * Null / absent = a normal Coach thread (health-record surface). The Coach rail + * badges a doc-scoped thread and, crucially, routes its turns through the + * HARDENED fenced document endpoint — never the tool route. Server-derived from + * the row; never a client input. + */ + documentId?: string | null; + /** + * v1.28.51 — the owning document's resolved title (its `title`, falling back to + * `filename`), for the "Chatting about: <title>" badge. Null when the document + * has neither, or on a non-document thread. Plaintext (same posture as the + * document title column); no health values. + */ + documentTitle?: string | null; } /** diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index b33733c40..827cded4e 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -508,6 +508,18 @@ const coachConversationSchema = z .datetime({ offset: true }) .describe("Bumped on every appended message; the rail orders by this."), messageCount: z.number().int(), + documentId: z + .string() + .nullish() + .describe( + "When set, this thread is scoped to one stored document (a document chat, run on the hardened fenced endpoint). Null/absent for a normal health thread.", + ), + documentTitle: z + .string() + .nullish() + .describe( + "The owning document's resolved title (title, falling back to filename) for the scope badge. Null on a health thread or when the document has neither.", + ), }) .meta({ id: "CoachConversation", @@ -548,6 +560,18 @@ const coachConversationDetailSchema = z .describe( "Rolling summary of turns elided past the history window; null when none is on file.", ), + documentId: z + .string() + .nullish() + .describe( + "When set, this thread is scoped to one stored document; the client routes its turns through the hardened fenced document endpoint, never the tool route.", + ), + documentTitle: z + .string() + .nullish() + .describe( + "The owning document's resolved title for the scope badge. Null on a health thread.", + ), }) .meta({ id: "CoachConversationDetail", diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 40df66e96..193d89a4a 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -66,16 +66,6 @@ export const documentKeys = { */ inboundDocumentAiCapability: () => ["documents", "inbound", "ai-capability"] as const, - /** - * The scoped "chat about this document" thread - * (`GET /api/documents/inbound/{id}/chat`). Keyed on the document id so two - * open detail sheets never share a thread cache slot; a sent turn's SSE - * `done` frame invalidates exactly this key so the persisted history reloads. - * Stays under the `["documents", "inbound"]` prefix so a document delete / - * bulk write still evicts it. - */ - inboundDocumentChat: (documentId: string) => - ["documents", "inbound", documentId, "chat"] as const, /** * The per-user "read documents automatically with AI" opt-in * (`GET/PATCH /api/auth/me/documents-auto-ai-read`). Drives the AI-settings