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

@@ -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(