feat(documents): move document chat into the Coach drawer, neutral header entry

Replace the collapsed bottom-of-card "Chat about this document" outline
button with a neutral, ghost Coach icon pinned to the top-right of the
document detail-sheet header, mirroring the Insights ask-coach affordance.
The icon uses the muted-foreground token only — never the primary/FAB
gradient — and opens a document-scoped chat drawer.

Present the chat in the Coach chat drawer chrome (right-side sheet on
desktop, bottom sheet on phones; same width caps; close-only, no maximize
or conversations rail) with a quiet "Chatting about: <document>" scope
badge. The drawer reuses the existing pure conversation body and the
existing client hooks, so it still posts /api/documents/inbound/[id]/chat
verbatim: no tools, no health snapshot, fenced document, inbound/replay
refusal, outbound screen, numeric grounding, consent + budget + per-user
rate bucket, indexed-only, plain-text render — the route is untouched.
Thread isolation is unchanged (the general Coach filters documentId: null;
the document route always scopes { documentId: id }).

Add a small reusable headerAction slot to ResponsiveSheet for the header
icon. Retire the DocumentChatPanel container and the AI-section chatSlot;
keep the pure DocumentChatConversation body and documentChatErrorKey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPyLteNeTWwEC154X7vLWc
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-07-09 18:00:06 +02:00
1 parent c07ffe78c0
commit dc75ae69ae
14 files changed
+414 -255

No files matched your search

+46 -22
View File
@@ -123,25 +123,29 @@ test.describe("document vault — chat about a document", () => {
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
// The neutral Coach icon lives in the detail-sheet header; tapping it
// opens the doc-scoped chat drawer (a sibling overlay).
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
await expect(
sheet.locator('[data-slot="document-chat-log"]'),
drawer.locator('[data-slot="document-chat-log"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-chat-empty"]'),
drawer.locator('[data-slot="document-chat-empty"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-chat-input"]'),
drawer.locator('[data-slot="document-chat-input"]'),
).toBeVisible();
// The always-on safety note is present.
await expect(
sheet.locator('[data-slot="document-chat-safety"]'),
drawer.locator('[data-slot="document-chat-safety"]'),
).toContainText("not medical advice");
});
@@ -238,16 +242,18 @@ test.describe("document vault — chat about a document", () => {
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await sheet.locator('[data-slot="document-chat-open"]').click();
const input = sheet.locator('[data-slot="document-chat-input"]');
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
const input = drawer.locator('[data-slot="document-chat-input"]');
await input.fill(QUESTION);
await sheet.locator('[data-slot="document-chat-send"]').click();
await drawer.locator('[data-slot="document-chat-send"]').click();
// The user's question and the streamed/settled reply both land in the log.
const log = sheet.locator('[data-slot="document-chat-log"]');
const log = drawer.locator('[data-slot="document-chat-log"]');
await expect(log.getByText(QUESTION)).toBeVisible();
await expect(log.getByText(/findings are unremarkable/)).toBeVisible();
// The reply settled to a single assistant bubble (no duplicate).
@@ -354,15 +360,17 @@ test.describe("document vault — chat about a document", () => {
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await sheet.locator('[data-slot="document-chat-open"]').click();
const input = sheet.locator('[data-slot="document-chat-input"]');
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
const input = drawer.locator('[data-slot="document-chat-input"]');
await input.fill(QUESTION);
await sheet.locator('[data-slot="document-chat-send"]').click();
await drawer.locator('[data-slot="document-chat-send"]').click();
const log = sheet.locator('[data-slot="document-chat-log"]');
const log = drawer.locator('[data-slot="document-chat-log"]');
// The describe-only answer lands; the panel renders the model's <script>
// payload as literal text (proof it is a React text child, not HTML).
await expect(log.getByText(/treat it as document content/)).toBeVisible();
@@ -387,18 +395,27 @@ test.describe("document vault — chat about a document", () => {
// AI_PROBE_DOC_ID carries no content index (hasContentIndex === false).
await mockAiEnabled(page);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
// A provider is available, so the Coach entry still shows — it opens the
// drawer, whose body carries the calm read-it-first hint (never an error)
// and no composer, because there is nothing indexed to ground on yet.
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
await expect(
sheet.locator('[data-slot="document-chat-not-indexed"]'),
drawer.locator('[data-slot="document-chat-not-indexed"]'),
).toBeVisible();
// No chat entry, and no error styling.
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
0,
);
// No composer, and no error styling.
await expect(
sheet.locator('[data-slot="document-chat"] [role="alert"]'),
drawer.locator('[data-slot="document-chat-input"]'),
).toHaveCount(0);
await expect(
drawer.locator('[data-slot="document-chat"] [role="alert"]'),
).toHaveCount(0);
});
@@ -410,7 +427,7 @@ test.describe("document vault — chat about a document", () => {
// No mocks — the seeded demo user has no AI provider configured, so the AI
// area collapses to the calm pointer and the chat slot never renders.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await expect(
@@ -419,6 +436,13 @@ test.describe("document vault — chat about a document", () => {
await expect(
sheet.getByRole("link", { name: "Open AI settings" }),
).toBeVisible();
await expect(sheet.locator('[data-slot="document-chat"]')).toHaveCount(0);
// No provider → no Coach entry in the header and no chat drawer mounted.
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
0,
);
await expect(
page.locator('[data-slot="document-chat-drawer"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot="document-chat"]')).toHaveCount(0);
});
});
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.",
"openAria": "Über dieses Dokument chatten",
"scopeBadge": "Chat über: {title}"
}
},
"selfDescription": {
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "You appear to be offline. Check your connection and try again.",
"openAria": "Chat about this document",
"scopeBadge": "Chatting about: {title}"
}
},
"selfDescription": {
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.",
"openAria": "Chatea sobre este documento",
"scopeBadge": "Chateando sobre: {title}"
}
},
"selfDescription": {
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.",
"openAria": "Discuter de ce document",
"scopeBadge": "Discussion sur : {title}"
}
},
"selfDescription": {
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "Sembri offline. Controlla la connessione e riprova.",
"openAria": "Chatta su questo documento",
"scopeBadge": "Stai chattando su: {title}"
}
},
"selfDescription": {
+3 -2
View File
@@ -8283,7 +8283,6 @@
},
"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…",
@@ -8298,7 +8297,9 @@
"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."
"errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.",
"openAria": "Porozmawiaj o tym dokumencie",
"scopeBadge": "Rozmowa o: {title}"
}
},
"selfDescription": {
@@ -156,20 +156,18 @@ describe("<DocumentAiSection>", () => {
expect(html).not.toContain('data-slot="document-read-ai"');
});
it("hides the manual AI action row when auto-read is on, keeping status + chat", () => {
const chat = <div data-slot="chat-probe">chat here</div>;
const html = render(
base({ aiEnabled: true, autoReadEnabled: true, chatSlot: chat }),
);
it("hides the manual AI action row when auto-read is on, keeping status", () => {
const html = render(base({ aiEnabled: true, autoReadEnabled: true }));
// Auto-read reads on upload — the manual per-document actions are gone.
expect(html).not.toContain('data-slot="document-read-ai"');
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain("Summarise");
expect(html).not.toContain("Show extracted text");
// The header, the searchable status pill, and content-search chat stay.
// The header and the searchable status pill stay. The scoped chat entry
// now lives in the detail-sheet header (a neutral Coach icon), not in this
// section, so this block no longer owns a chat slot.
expect(html).toContain('data-slot="document-ai-section"');
expect(html).toContain('data-slot="content-search-status"');
expect(html).toContain('data-slot="chat-probe"');
});
it("shows the manual AI action row when auto-read is off", () => {
@@ -178,23 +176,4 @@ describe("<DocumentAiSection>", () => {
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain("Summarise");
});
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"');
});
});
@@ -1,24 +1,24 @@
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 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);
* - 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.
*
* 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) {
@@ -27,17 +27,6 @@ function renderPure(node: React.ReactNode) {
);
}
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(
@@ -154,27 +143,6 @@ describe("<DocumentChatConversation> (open body)", () => {
});
});
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(
@@ -23,7 +23,6 @@
* form below stays fully usable.
*/
import { FileText, ScanText, WandSparkles } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -70,7 +69,6 @@ export function DocumentAiSection({
contentIndexSource,
indexPending,
onIndex,
chatSlot,
}: {
aiEnabled: boolean;
/**
@@ -106,14 +104,6 @@ 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,8 +221,6 @@ export function DocumentAiSection({
onClose={onCloseSummary}
/>
) : null}
{chatSlot}
</>
) : (
<AiUnavailableHint reason={unavailableReason} />
@@ -0,0 +1,211 @@
"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>
);
}
@@ -20,13 +20,16 @@
* 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.
* 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 { useEffect, useRef, useState, type RefObject } from "react";
import { type RefObject } from "react";
import { StreamedProse } from "@/components/insights/coach-panel/streamed-prose";
import { ProseBlocks } from "@/components/insights/prose-blocks";
@@ -36,11 +39,7 @@ 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";
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 {
@@ -277,116 +276,3 @@ export function DocumentChatConversation({
</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,
}: {
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 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 });
};
// ── Not indexed: calm pointer to the read action above (never an error) ──
if (!indexed) {
return (
<div
data-slot="document-chat"
className="border-border/60 space-y-2 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>
<p
data-slot="document-chat-not-indexed"
className="text-muted-foreground text-xs"
>
{t("documents.chat.notIndexed")}
</p>
</div>
);
}
// ── Collapsed entry: available, opens the scoped chat on tap ──
if (!open) {
return (
<div
data-slot="document-chat"
className="border-border/60 space-y-2 border-t pt-3"
>
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-chat-open"
onClick={() => setOpen(true)}
>
<MessageSquare className="size-4" aria-hidden />
{t("documents.chat.open")}
</Button>
</div>
);
}
return (
<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}
/>
);
}
@@ -17,7 +17,15 @@
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Download, Pencil, Plus, Share2, Trash2 } from "lucide-react";
import {
Check,
Download,
Pencil,
Plus,
Share2,
Sparkles,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -41,6 +49,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
apiDelete,
ApiError,
@@ -59,7 +73,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentChatPanel } from "./document-chat-panel";
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";
@@ -231,6 +245,9 @@ 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>(
@@ -251,6 +268,7 @@ export function DocumentDetailSheet({
setLastDocumentId(documentId);
setEditingTitle(false);
setShareOpen(false);
setDocChatOpen(false);
setMutationError(null);
setSuggestion(null);
setAppliedFields({ title: false, kind: false, date: false });
@@ -416,6 +434,33 @@ export function DocumentDetailSheet({
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
headerAction={
doc && aiEnabled ? (
// Neutral, ghost Coach entry pinned top-right of the document —
// mirrors the Insights `AskCoachIconButton`. Deliberately NOT the
// purple FAB styling: `text-muted-foreground` only. Opens the
// doc-scoped chat drawer; the drawer body shows the calm
// read-it-first hint when the document is not yet indexed.
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="document-chat-open"
aria-label={t("documents.chat.openAria")}
onClick={() => setDocChatOpen(true)}
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-8"
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("documents.chat.openAria")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : undefined
}
footer={
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
@@ -541,12 +586,6 @@ export function DocumentDetailSheet({
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
chatSlot={
<DocumentChatPanel
documentId={doc.id}
indexed={doc.hasContentIndex}
/>
}
/>
{mutationError ? (
@@ -726,6 +765,16 @@ export function DocumentDetailSheet({
documentTitle={title}
/>
) : null}
{doc && aiEnabled ? (
<DocumentChatDrawer
open={docChatOpen}
onOpenChange={setDocChatOpen}
documentId={doc.id}
indexed={doc.hasContentIndex}
documentTitle={title}
/>
) : null}
</>
);
}
+62 -14
View File
@@ -34,6 +34,15 @@ export interface ResponsiveSheetProps {
title: React.ReactNode;
/** Optional short description rendered beneath the title. */
description?: React.ReactNode;
/**
* Optional action rendered `shrink-0` at the trailing edge of the visible
* header row (§11 inline-action-row: title `min-w-0 flex-1`, action
* `shrink-0`). Use for a single quiet header control — e.g. the neutral
* Coach icon on the document detail sheet. Ignored when `hideHeader` is set
* (there is no visible header to hang it on). Sits left of the primitive's
* absolute close button, whose space the header already reserves.
*/
headerAction?: React.ReactNode;
/**
* Hide the visual header (title + description). The title remains
* mounted in an `sr-only` block so the accessible name is still
@@ -119,6 +128,7 @@ export function ResponsiveSheet({
onOpenChange,
title,
description,
headerAction,
hideHeader = false,
footer,
className,
@@ -156,16 +166,37 @@ export function ResponsiveSheet({
) : (
<SheetHeader
data-slot="responsive-sheet-header"
className="border-border/70 gap-1.5 border-b p-4 pr-12"
className={cn(
"border-border/70 gap-1.5 border-b p-4 pr-12",
headerAction && "flex-row items-start justify-between gap-3",
)}
>
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
) : null}
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
) : null}
</div>
<div className="shrink-0">{headerAction}</div>
</>
) : (
<>
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
) : null}
</>
)}
</SheetHeader>
)}
<div
@@ -222,12 +253,29 @@ export function ResponsiveSheet({
) : (
<DialogHeader
data-slot="responsive-sheet-header"
className="shrink-0"
className={cn(
"shrink-0",
headerAction && "flex-row items-start justify-between gap-3 pr-9",
)}
>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</div>
<div className="shrink-0">{headerAction}</div>
</>
) : (
<>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</>
)}
</DialogHeader>
)}
<div