Files
HealthLog/src/components/documents/document-ai-section.tsx
T
Marc-André BombeckandClaude Opus 4.8 dc75ae69ae 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
2026-07-09 18:00:06 +02:00

231 lines
7.4 KiB
TypeScript

"use client";
/**
* The "Read with AI" block of the document detail sheet — one coherent, inviting
* home for every AI reading action, lifted out of the sheet so the availability
* gate and the review-first wiring render without the sheet's dialog portal and
* are pinned by static-render tests.
*
* Indexing is AUTOMATIC on upload, so this block never nags a "please index"
* chore. It shows the document's searchable status (auto-indexed) as a pill, and
* offers AI reading as a capability the user reaches for when they want more:
*
* - "Read with AI" (prominent) runs a provider read of the document — the
* richer pass that also refreshes the searchable index. Labelled "Read
* again" once a provider has already read it.
* - "Suggest details" drafts a title / type / date to review before saving.
* - "Summarise" / "Show text" surface a transient, session-only read-out.
*
* Presentational: the sheet owns the mutations + capability probe and passes
* their state + handlers down. When `aiEnabled` is false the actions collapse to
* the calm "set up an AI provider" pointer (never an error) — the searchable
* status pill stays, honestly reflecting any local auto-index, and the manual
* form below stays fully usable.
*/
import { FileText, ScanText, WandSparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
export function DocumentAiSection({
aiEnabled,
autoReadEnabled = false,
indexEnabled,
unavailableReason,
actionsDisabled,
onSuggest,
suggestPending,
suggestErrorKey,
onSummarise,
summaryPending,
suggestion,
suggestionKindLabel,
suggestionDateLabel,
appliedFields,
onUseTitle,
onUseKind,
onUseDate,
onDismissSuggestion,
summaryOutput,
summaryResult,
summaryErrorKey,
onCloseSummary,
hasContentIndex,
contentIndexSource,
indexPending,
onIndex,
}: {
aiEnabled: boolean;
/**
* Whether "read documents automatically with AI" is ON. When true, reading +
* indexing happen on upload, so the manual per-document action row (Read /
* Suggest / Summarise) is hidden — only the header + searchable status +
* content-search chat remain. Defaults to false so an omitting caller keeps
* the manual controls.
*/
autoReadEnabled?: boolean;
indexEnabled: boolean;
unavailableReason: "no-provider" | "enable-local-ocr" | null;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onSuggest: () => void;
suggestPending: boolean;
suggestErrorKey: string | null;
onSummarise: (output: DocumentSummaryMode) => void;
summaryPending: boolean;
suggestion: DocumentSuggestionDto | null;
suggestionKindLabel: string | null;
suggestionDateLabel: string | null;
appliedFields: { title: boolean; kind: boolean; date: boolean };
onUseTitle: () => void;
onUseKind: () => void;
onUseDate: () => void;
onDismissSuggestion: () => void;
summaryOutput: DocumentSummaryMode | null;
summaryResult: DocumentDescribeResult | null;
summaryErrorKey: string | null;
onCloseSummary: () => void;
hasContentIndex: boolean;
contentIndexSource: DocumentContentIndexSourceValue | null;
indexPending: boolean;
onIndex: () => void;
}) {
const { t } = useTranslations();
const aiRead = hasContentIndex && isAiReadSource(contentIndexSource);
const readLabel = indexPending
? t("documents.ai.reading")
: aiRead
? t("documents.ai.reread")
: t("documents.ai.read");
return (
<div
data-slot="document-ai-section"
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3 md:p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<WandSparkles
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<p className="text-sm font-semibold">
{t("documents.ai.blockTitle")}
</p>
</div>
<ContentSearchStatus
hasContentIndex={hasContentIndex}
source={contentIndexSource}
isPending={indexPending}
/>
</div>
{aiEnabled ? (
<>
{!autoReadEnabled ? (
<div className="flex flex-wrap items-center gap-2">
{indexEnabled ? (
<Button
type="button"
size="sm"
data-slot="document-read-ai"
onClick={onIndex}
disabled={indexPending || actionsDisabled}
>
<ScanText
className={cn(
"size-4",
indexPending &&
"animate-pulse motion-reduce:animate-none",
)}
aria-hidden
/>
{readLabel}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
data-slot="assist-suggest"
onClick={onSuggest}
disabled={suggestPending || actionsDisabled}
>
<WandSparkles className="size-4" aria-hidden />
{suggestPending
? t("documents.assist.suggesting")
: t("documents.assist.suggest")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onSummarise("summary")}
disabled={summaryPending || actionsDisabled}
>
<FileText className="size-4" aria-hidden />
{t("documents.summary.summarise")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onSummarise("text")}
disabled={summaryPending || actionsDisabled}
>
{t("documents.summary.showText")}
</Button>
</div>
) : null}
{suggestErrorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(suggestErrorKey)}
</p>
) : null}
{suggestion ? (
<AssistSuggestionReview
suggestion={suggestion}
kindLabel={suggestionKindLabel}
dateLabel={suggestionDateLabel}
applied={appliedFields}
onUseTitle={onUseTitle}
onUseKind={onUseKind}
onUseDate={onUseDate}
onDismiss={onDismissSuggestion}
/>
) : null}
{summaryOutput ? (
<DocumentSummaryPanel
output={summaryOutput}
result={summaryResult}
isPending={summaryPending}
errorKey={summaryErrorKey}
onClose={onCloseSummary}
/>
) : null}
</>
) : (
<AiUnavailableHint reason={unavailableReason} />
)}
</div>
);
}