mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(documents): split a pure chat body + pin its render states
Extract DocumentChatConversation (pure) from the chat panel so the open conversation surface is statically renderable, and pin: composer + always-on safety note, plain-text/XSS-safe assistant render (raw HTML escaped), the streaming tail vs thinking placeholder, mapped error keys, and the container's indexed vs not-indexed gating. Assert the AI section renders the chat slot only in the provider-available branch.
This commit is contained in:
1 parent
635c45011b
commit
2ede0886c3
3 files changed
+434
-158
No files matched your search
@@ -164,4 +164,23 @@ describe("<DocumentAiSection>", () => {
|
||||
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 = <div data-slot="chat-probe">chat here</div>;
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderContainer(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function conversation(
|
||||
overrides: Partial<
|
||||
React.ComponentProps<typeof DocumentChatConversation>
|
||||
> = {},
|
||||
) {
|
||||
const props: React.ComponentProps<typeof DocumentChatConversation> = {
|
||||
messages: [],
|
||||
optimisticContent: null,
|
||||
streamingContent: "",
|
||||
isStreaming: false,
|
||||
streamErrorKey: null,
|
||||
historyPending: false,
|
||||
historyError: false,
|
||||
draft: "",
|
||||
onDraftChange: noop,
|
||||
onSubmit: noop,
|
||||
onClose: noop,
|
||||
...overrides,
|
||||
};
|
||||
return <DocumentChatConversation {...props} />;
|
||||
}
|
||||
|
||||
const messages: DocumentChatMessage[] = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "What does the Impression say?",
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "Per the report's Impression, the findings are unremarkable.",
|
||||
createdAt: "2026-07-07T10:00:02.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("<DocumentChatConversation> (open body)", () => {
|
||||
it("renders the composer + always-on safety note", () => {
|
||||
const html = renderPure(conversation());
|
||||
expect(html).toContain('data-slot="document-chat"');
|
||||
expect(html).toContain('data-state="open"');
|
||||
expect(html).toContain('data-slot="document-chat-input"');
|
||||
expect(html).toContain('data-slot="document-chat-send"');
|
||||
// Safety-visible copy: describes the document, not medical advice.
|
||||
expect(html).toContain('data-slot="document-chat-safety"');
|
||||
expect(html).toContain("not medical advice");
|
||||
});
|
||||
|
||||
it("shows the empty prompt before any turn", () => {
|
||||
const html = renderPure(conversation());
|
||||
expect(html).toContain('data-slot="document-chat-empty"');
|
||||
});
|
||||
|
||||
it("renders a user + assistant turn, and the assistant text plainly", () => {
|
||||
const html = renderPure(conversation({ messages }));
|
||||
expect(html).toContain('data-role="user"');
|
||||
expect(html).toContain('data-role="assistant"');
|
||||
expect(html).toContain("What does the Impression say?");
|
||||
// A citation-style phrase is just text.
|
||||
expect(html).toContain("Per the report's Impression");
|
||||
// No empty prompt once turns exist.
|
||||
expect(html).not.toContain('data-slot="document-chat-empty"');
|
||||
});
|
||||
|
||||
it("renders assistant prose as XSS-safe text — raw HTML is escaped", () => {
|
||||
const html = renderPure(
|
||||
conversation({
|
||||
messages: [
|
||||
{
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "<img src=x onerror=alert(1)> the value is normal.",
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
// The markup is escaped, never injected as a live element.
|
||||
expect(html).not.toContain("<img src=x");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
|
||||
it("shows the streaming assistant tail once tokens arrive", () => {
|
||||
const html = renderPure(
|
||||
conversation({
|
||||
streamingContent: "Reading the values",
|
||||
isStreaming: true,
|
||||
}),
|
||||
);
|
||||
// The live tail streams word-by-word (each token in its own span), so
|
||||
// assert the words are present rather than a contiguous run.
|
||||
expect(html).toContain("Reading");
|
||||
expect(html).toContain("values");
|
||||
// The thinking placeholder is gone once content streams.
|
||||
expect(html).not.toContain('data-slot="document-chat-thinking"');
|
||||
});
|
||||
|
||||
it("shows the thinking placeholder while streaming with no tokens yet", () => {
|
||||
const html = renderPure(
|
||||
conversation({ streamingContent: "", isStreaming: true }),
|
||||
);
|
||||
expect(html).toContain('data-slot="document-chat-thinking"');
|
||||
});
|
||||
|
||||
it("surfaces a mapped error key, never a raw provider string", () => {
|
||||
const html = renderPure(
|
||||
conversation({ streamErrorKey: "documents.chat.errorRateLimited" }),
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Too many messages");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentChatPanel> (gating)", () => {
|
||||
it("offers the chat entry when the document is content-indexed", () => {
|
||||
const html = renderContainer(
|
||||
<DocumentChatPanel documentId="doc1" indexed />,
|
||||
);
|
||||
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(
|
||||
<DocumentChatPanel documentId="doc1" indexed={false} />,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement | null>(null);
|
||||
const logRef = useRef<HTMLDivElement | null>(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 (
|
||||
<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={listRef}
|
||||
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"
|
||||
>
|
||||
{thread.isPending ? (
|
||||
<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} />
|
||||
))}
|
||||
|
||||
{showOptimistic ? (
|
||||
<ChatTurn
|
||||
message={{
|
||||
id: "optimistic",
|
||||
role: "user",
|
||||
content: optimisticUser,
|
||||
createdAt: "",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isStreaming && streaming.content.length === 0 && !streamErrorKey ? (
|
||||
<p
|
||||
data-slot="document-chat-thinking"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.chat.thinking")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{streaming.content.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={streaming.content}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{streamErrorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(streamErrorKey)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{thread.isError ? (
|
||||
<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();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<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={() => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline",
|
||||
)}
|
||||
>
|
||||
{t("documents.chat.close")}
|
||||
</button>
|
||||
</div>
|
||||
<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={() => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
logRef={logRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user