diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index 1b147d6dc..6a718d754 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -164,4 +164,23 @@ describe("", () => { expect(html).toContain('data-slot="assist-suggest"'); expect(html).not.toContain('data-slot="document-read-ai"'); }); + + it("renders the chat slot inside the AI area only when a provider is available", () => { + const chat =
chat here
; + const enabled = render(base({ aiEnabled: true, chatSlot: chat })); + expect(enabled).toContain('data-slot="chat-probe"'); + + // No provider → the whole block collapses to the calm pointer; the chat + // slot (provider-only affordance) must not render. + const disabled = render( + base({ + aiEnabled: false, + indexEnabled: false, + unavailableReason: "no-provider", + chatSlot: chat, + }), + ); + expect(disabled).not.toContain('data-slot="chat-probe"'); + expect(disabled).toContain('data-slot="assist-unavailable"'); + }); }); diff --git a/src/components/documents/__tests__/document-chat-panel.test.tsx b/src/components/documents/__tests__/document-chat-panel.test.tsx new file mode 100644 index 000000000..c43ced04d --- /dev/null +++ b/src/components/documents/__tests__/document-chat-panel.test.tsx @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + DocumentChatConversation, + DocumentChatPanel, + documentChatErrorKey, +} from "../document-chat-panel"; +import type { DocumentChatMessage } from "../use-document-chat"; + +/** + * The scoped "chat about this document" panel: + * - 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); + * - the container gates on indexing: a content-indexed document offers the + * chat entry, a non-indexed one shows a calm read-it-first hint (never an + * error); + * - error codes map to calm translation keys, never a raw provider string. + */ + +function renderPure(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function renderContainer(node: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +const noop = () => {}; + +function conversation( + overrides: Partial< + React.ComponentProps + > = {}, +) { + const props: React.ComponentProps = { + messages: [], + optimisticContent: null, + streamingContent: "", + isStreaming: false, + streamErrorKey: null, + historyPending: false, + historyError: false, + draft: "", + onDraftChange: noop, + onSubmit: noop, + onClose: noop, + ...overrides, + }; + return ; +} + +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(" (open body)", () => { + it("renders the composer + always-on safety note", () => { + const html = renderPure(conversation()); + expect(html).toContain('data-slot="document-chat"'); + expect(html).toContain('data-state="open"'); + expect(html).toContain('data-slot="document-chat-input"'); + expect(html).toContain('data-slot="document-chat-send"'); + // Safety-visible copy: describes the document, not medical advice. + expect(html).toContain('data-slot="document-chat-safety"'); + expect(html).toContain("not medical advice"); + }); + + it("shows the empty prompt before any turn", () => { + const html = renderPure(conversation()); + expect(html).toContain('data-slot="document-chat-empty"'); + }); + + it("renders a user + assistant turn, and the assistant text plainly", () => { + const html = renderPure(conversation({ messages })); + expect(html).toContain('data-role="user"'); + expect(html).toContain('data-role="assistant"'); + expect(html).toContain("What does the Impression say?"); + // A citation-style phrase is just text. + expect(html).toContain("Per the report's Impression"); + // No empty prompt once turns exist. + expect(html).not.toContain('data-slot="document-chat-empty"'); + }); + + it("renders assistant prose as XSS-safe text — raw HTML is escaped", () => { + const html = renderPure( + conversation({ + messages: [ + { + id: "a2", + role: "assistant", + content: " 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(" { + 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(" (gating)", () => { + it("offers the chat entry when the document is content-indexed", () => { + const html = renderContainer( + , + ); + expect(html).toContain('data-slot="document-chat-open"'); + expect(html).toContain("Chat about this document"); + // Not the not-indexed hint. + expect(html).not.toContain('data-slot="document-chat-not-indexed"'); + }); + + it("shows a calm read-it-first hint (never an error) when not indexed", () => { + const html = renderContainer( + , + ); + expect(html).toContain('data-slot="document-chat-not-indexed"'); + expect(html).not.toContain('data-slot="document-chat-open"'); + expect(html).not.toContain('role="alert"'); + }); +}); + +describe("documentChatErrorKey", () => { + it("maps provider + client codes to calm keys, defaulting to generic", () => { + expect(documentChatErrorKey("documents.chat.budget.exceeded")).toBe( + "documents.chat.errorBudget", + ); + expect(documentChatErrorKey("documents.inbound.rateLimited")).toBe( + "documents.chat.errorRateLimited", + ); + expect(documentChatErrorKey("consent.ai.required")).toBe( + "documents.chat.errorConsent", + ); + expect(documentChatErrorKey("something.unknown")).toBe( + "documents.chat.errorGeneric", + ); + expect(documentChatErrorKey(null)).toBe("documents.chat.errorGeneric"); + }); +}); diff --git a/src/components/documents/document-chat-panel.tsx b/src/components/documents/document-chat-panel.tsx index 30907155f..afe59f7a0 100644 --- a/src/components/documents/document-chat-panel.tsx +++ b/src/components/documents/document-chat-panel.tsx @@ -19,12 +19,17 @@ * 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". + * + * Split: a stateful container (`DocumentChatPanel`) owns the hooks + open/gate + * state; the pure `DocumentChatConversation` renders the open body (log + input + * + safety note) from props, so the conversation surface is statically + * renderable and pinned by tests without a query client. */ import { MessageSquare, Send } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; -import { ProseBlocks } from "@/components/insights/prose-blocks"; 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"; @@ -38,7 +43,7 @@ import { } from "./use-document-chat"; /** Map a server / client error code to a calm translation key. */ -function documentChatErrorKey(code: string | null): string { +export function documentChatErrorKey(code: string | null): string { switch (code) { case "documents.chat.provider.rate_limited": case "documents.inbound.rateLimited": @@ -85,6 +90,198 @@ function ChatTurn({ message }: { message: DocumentChatMessage }) { ); } +/** + * 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; +}) { + const { t } = useTranslations(); + const isEmpty = + !historyPending && + messages.length === 0 && + optimisticContent === null && + !isStreaming && + streamingContent.length === 0; + + return ( +
+
+ +

{t("documents.chat.title")}

+
+ +
+ {historyPending ? ( +
+ + +
+ ) : null} + + {isEmpty ? ( +

+ {t("documents.chat.empty")} +

+ ) : null} + + {messages.map((m) => ( + + ))} + + {optimisticContent !== null ? ( + + ) : null} + + {isStreaming && streamingContent.length === 0 && !streamErrorKey ? ( +

+ {t("documents.chat.thinking")} +

+ ) : null} + + {streamingContent.length > 0 ? ( +
+
+ +
+
+ ) : null} +
+ + {streamErrorKey ? ( +

+ {t(streamErrorKey)} +

+ ) : null} + + {historyError ? ( +

+ {t("documents.chat.errorHistory")} +

+ ) : null} + +
{ + e.preventDefault(); + onSubmit(); + }} + > + 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" + /> + +
+ + {/* 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). */} + +
+ ); +} + +/** + * The stateful container: owns the open/gate state + the history / streaming + * hooks, and delegates the open body to `DocumentChatConversation`. + */ export function DocumentChatPanel({ documentId, indexed, @@ -96,7 +293,7 @@ export function DocumentChatPanel({ const { t, locale } = useTranslations(); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(""); - const listRef = useRef(null); + const logRef = useRef(null); const thread = useDocumentChatThread(documentId, open && indexed); const { streaming, isStreaming, optimisticUser, send, reset } = @@ -113,7 +310,7 @@ export function DocumentChatPanel({ // Keep the newest turn in view as messages / tokens land. useEffect(() => { - const el = listRef.current; + const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length, streaming.content, showOptimistic, isStreaming]); @@ -171,159 +368,25 @@ export function DocumentChatPanel({ ); } - const streamErrorKey = streaming.errorCode - ? documentChatErrorKey(streaming.errorCode) - : null; - const isEmpty = - !thread.isPending && - messages.length === 0 && - !showOptimistic && - !isStreaming && - streaming.content.length === 0; - return ( -
-
- -

{t("documents.chat.title")}

-
- -
- {thread.isPending ? ( -
- - -
- ) : null} - - {isEmpty ? ( -

- {t("documents.chat.empty")} -

- ) : null} - - {messages.map((m) => ( - - ))} - - {showOptimistic ? ( - - ) : null} - - {isStreaming && streaming.content.length === 0 && !streamErrorKey ? ( -

- {t("documents.chat.thinking")} -

- ) : null} - - {streaming.content.length > 0 ? ( -
-
- -
-
- ) : null} -
- - {streamErrorKey ? ( -

- {t(streamErrorKey)} -

- ) : null} - - {thread.isError ? ( -

- {t("documents.chat.errorHistory")} -

- ) : null} - -
{ - e.preventDefault(); - submit(); - }} - > - setDraft(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" - /> - -
- - {/* 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). */} - -
+ { + reset(); + setOpen(false); + }} + logRef={logRef} + /> ); }