mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
c07ffe78c0
commit
dc75ae69ae
14 files changed
+414
-255
No files matched your search
+46
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user