mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(coach): document chats live in the Coach view and history
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: <title>" 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.
This commit is contained in:
1 parent
57539283c8
commit
5b24ca0fa1
28 files changed
+605
-1112
No files matched your search
+13
-1
@@ -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
|
||||
|
||||
+24
-1
@@ -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
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+6
-17
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+12
-1
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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" });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user