diff --git a/messages/de.json b/messages/de.json
index 1891ebcc2..4955e2990 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Dieses Dokument teilen",
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
+ },
+ "chat": {
+ "title": "Über dieses Dokument chatten",
+ "open": "Ü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."
}
},
"selfDescription": {
diff --git a/messages/en.json b/messages/en.json
index 3fb5eaf38..6902df151 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Share this document",
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
+ },
+ "chat": {
+ "title": "Chat about this document",
+ "open": "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."
}
},
"selfDescription": {
diff --git a/messages/es.json b/messages/es.json
index a47925492..87f2950db 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Compartir este documento",
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
+ },
+ "chat": {
+ "title": "Chatea sobre este documento",
+ "open": "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."
}
},
"selfDescription": {
diff --git a/messages/fr.json b/messages/fr.json
index c271324c2..5eaf31839 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Partager ce document",
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
+ },
+ "chat": {
+ "title": "Discuter de ce document",
+ "open": "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."
}
},
"selfDescription": {
diff --git a/messages/it.json b/messages/it.json
index b983b8a06..8f2cd55ca 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Condividi questo documento",
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
+ },
+ "chat": {
+ "title": "Chatta su questo documento",
+ "open": "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."
}
},
"selfDescription": {
diff --git a/messages/pl.json b/messages/pl.json
index a41d97b3c..5feb87735 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -8243,6 +8243,25 @@
"share": {
"title": "Udostępnij ten dokument",
"description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz."
+ },
+ "chat": {
+ "title": "Porozmawiaj o tym dokumencie",
+ "open": "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."
}
},
"selfDescription": {
diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx
index 25d4c7f30..a6aad46f4 100644
--- a/src/components/documents/document-ai-section.tsx
+++ b/src/components/documents/document-ai-section.tsx
@@ -23,6 +23,7 @@
* form below stays fully usable.
*/
import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react";
+import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -69,6 +70,7 @@ export function DocumentAiSection({
contentIndexSource,
indexPending,
onIndex,
+ chatSlot,
}: {
aiEnabled: boolean;
indexEnabled: boolean;
@@ -102,6 +104,14 @@ export function DocumentAiSection({
contentIndexSource: DocumentContentIndexSourceValue | null;
indexPending: boolean;
onIndex: () => void;
+ /**
+ * The scoped "chat about this document" panel, rendered inside the AI area
+ * beneath the reading actions. Only supplied (and only meaningful) when a
+ * provider is available — the panel itself gates on whether the document is
+ * indexed. A pure slot so this section stays presentational + statically
+ * renderable (the stateful, query-driven panel lives in the sheet).
+ */
+ chatSlot?: ReactNode;
}) {
const { t } = useTranslations();
const aiRead = hasContentIndex && isAiReadSource(contentIndexSource);
@@ -231,6 +241,8 @@ export function DocumentAiSection({
onClose={onCloseSummary}
/>
) : null}
+
+ {chatSlot}
>
) : (
diff --git a/src/components/documents/document-chat-panel.tsx b/src/components/documents/document-chat-panel.tsx
new file mode 100644
index 000000000..30907155f
--- /dev/null
+++ b/src/components/documents/document-chat-panel.tsx
@@ -0,0 +1,329 @@
+"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".
+ */
+import { MessageSquare, Send } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import { ProseBlocks } from "@/components/insights/prose-blocks";
+import { StreamedProse } from "@/components/insights/coach-panel/streamed-prose";
+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 {
+ useDocumentChatThread,
+ useSendDocumentChatMessage,
+ type DocumentChatMessage,
+} from "./use-document-chat";
+
+/** Map a server / client error code to a calm translation key. */
+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 (
+
+
+ {/* User text is verbatim: no chart-token strip, no Learn linkify. */}
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+ );
+}
+
+export function DocumentChatPanel({
+ documentId,
+ indexed,
+}: {
+ documentId: string;
+ /** True when the document has a content index (its text grounds the chat). */
+ indexed: boolean;
+}) {
+ const { t, locale } = useTranslations();
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState("");
+ const listRef = useRef(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 = listRef.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 });
+ };
+
+ // ── Not indexed: calm pointer to the read action above (never an error) ──
+ if (!indexed) {
+ return (
+
+
+
+
{t("documents.chat.title")}
+
+
+ {t("documents.chat.notIndexed")}
+
+
+ );
+ }
+
+ // ── Collapsed entry: available, opens the scoped chat on tap ──
+ if (!open) {
+ return (
+
+ ) : null}
+
+
+
+ {/* Safety-visible: answers describe the document, not medical advice —
+ consistent with the summary panel's "not a diagnosis". */}
+
+ {t("documents.chat.safety")}
+
+
+ {/* Retiring the panel discards the live stream state (the persisted
+ history stays on disk and reloads next open). */}
+
+
+ );
+}
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
index cb901236b..f79630a6c 100644
--- a/src/components/documents/document-detail-sheet.tsx
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -59,6 +59,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
+import { DocumentChatPanel } from "./document-chat-panel";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
@@ -535,6 +536,12 @@ export function DocumentDetailSheet({
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
+ chatSlot={
+
+ }
/>
{mutationError ? (
diff --git a/src/components/documents/use-document-chat.ts b/src/components/documents/use-document-chat.ts
new file mode 100644
index 000000000..f5da98510
--- /dev/null
+++ b/src/components/documents/use-document-chat.ts
@@ -0,0 +1,299 @@
+"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({
+ queryKey: queryKeys.inboundDocumentChat(documentId ?? "none"),
+ enabled: enabled && documentId !== null,
+ staleTime: 30_000,
+ queryFn: async () => {
+ if (!documentId) return EMPTY_THREAD;
+ const page = await apiGet(
+ `/api/documents/inbound/${documentId}/chat`,
+ );
+ const newest = page.conversations[0];
+ if (!newest) return EMPTY_THREAD;
+ const detail = await apiGet(
+ `/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(EMPTY_STREAMING);
+ const [optimisticUser, setOptimisticUser] = useState(null);
+ const abortRef = useRef(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 => {
+ 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). The persisted twin replaces the optimistic bubble.
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.inboundDocumentChat(documentId),
+ });
+ setOptimisticUser(null);
+ }
+ return resolvedConversationId;
+ },
+ [documentId, queryClient],
+ );
+
+ const cancel = useCallback(() => abortRef.current?.abort(), []);
+
+ return {
+ streaming,
+ isStreaming: streaming.inProgress,
+ optimisticUser,
+ send,
+ cancel,
+ reset,
+ };
+}
diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts
index c49fb59a4..b69fb6660 100644
--- a/src/lib/query-keys/documents.ts
+++ b/src/lib/query-keys/documents.ts
@@ -66,4 +66,14 @@ 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,
};