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:
Marc-André Bombeck committed 2026-07-16 11:01:59 +02:00
1 parent 57539283c8
commit 5b24ca0fa1
28 files changed
+605 -1112

No files matched your search

+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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/*`
+10 -5
View File
@@ -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);
}
+16 -5
View File
@@ -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({
+11 -3
View File
@@ -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
View File
@@ -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&#x27;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("&lt;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();
});
});
+30
View File
@@ -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,
};
+16
View File
@@ -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;
}
/**
+24
View File
@@ -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",
-10
View File
@@ -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