feat(documents): hide manual AI actions under auto-read, trim suggestion chrome

When "read documents automatically with AI" is on, the detail sheet no longer
shows the manual per-document action row (Read / Suggest / Summarise) — reading
already happens on upload, so the buttons only nagged. The section header and
the searchable status pill stay, and content search keeps working. With the
setting off the manual controls render exactly as before.

Drop the "review before saving" heading and its grey sub-caption from the
suggestion panel; the reviewable fields and the apply / dismiss controls stay.
Remove the now-unused documents.assist.reviewTitle / reviewHint keys from every
locale.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 17:27:47 +02:00
1 parent 6f439b1572
commit e5de72cae2
15 files changed
+168 -86

No files matched your search

@@ -14,8 +14,8 @@ import {
* The AI panels' render contract:
* - the unavailable state is a CALM pointer to the AI settings, never an
* error, and adapts its copy to the actionable reason;
* - the suggestion card carries the review-first affordance ("review before
* saving") and applies nothing on its own — every value has its own tap;
* - the suggestion card is review-first: it applies nothing on its own —
* every value has its own explicit apply tap, and a dismiss control;
* - the summary panel ALWAYS carries the "not saved · not a diagnosis" note,
* even while loading, so the session-only contract is never off-screen.
*/
@@ -55,7 +55,7 @@ describe("<AiUnavailableHint>", () => {
});
describe("<AssistSuggestionReview>", () => {
it("presents drafts behind an explicit review-before-saving affordance", () => {
it("presents drafts as reviewable fields with explicit apply controls", () => {
const html = render(
<AssistSuggestionReview
suggestion={suggestion()}
@@ -69,8 +69,10 @@ describe("<AssistSuggestionReview>", () => {
/>,
);
expect(html).toContain('data-slot="assist-suggestion-review"');
expect(html).toContain("AI suggestion — review before saving");
expect(html).toContain("Nothing is saved until you apply it");
// The heading + grey sub-caption were retired — the panel leads straight
// with the reviewable draft fields.
expect(html).not.toContain("review before saving");
expect(html).not.toContain("Nothing is saved until you apply it");
// The suggested values are shown as reviewable drafts.
expect(html).toContain("Blood panel — March 2026");
expect(html).toContain("Lab result");
@@ -127,7 +127,6 @@ describe("<DocumentAiSection>", () => {
);
expect(html).toContain('data-slot="assist-suggestion-review"');
expect(html).toContain("MRI knee report");
expect(html).toContain("review before saving");
});
it("renders the transient summary panel", () => {
@@ -157,6 +156,29 @@ 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 }),
);
// 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.
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", () => {
const html = render(base({ aiEnabled: true, autoReadEnabled: false }));
expect(html).toContain('data-slot="document-read-ai"');
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 }));
@@ -148,21 +148,7 @@ export function AssistSuggestionReview({
data-slot="assist-suggestion-review"
className="border-primary/30 bg-primary/5 space-y-3 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<Sparkles
className="text-primary mt-0.5 size-4 shrink-0"
aria-hidden
/>
<div>
<p className="text-sm font-medium">
{t("documents.assist.reviewTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.assist.reviewHint")}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
@@ -45,6 +45,7 @@ import type { DocumentDescribeResult } from "./use-document-assist";
export function DocumentAiSection({
aiEnabled,
autoReadEnabled = false,
indexEnabled,
unavailableReason,
actionsDisabled,
@@ -72,6 +73,14 @@ export function DocumentAiSection({
chatSlot,
}: {
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. */
@@ -138,58 +147,61 @@ export function DocumentAiSection({
{aiEnabled ? (
<>
<div className="flex flex-wrap items-center gap-2">
{indexEnabled ? (
{!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="document-read-ai"
onClick={onIndex}
disabled={indexPending || actionsDisabled}
data-slot="assist-suggest"
onClick={onSuggest}
disabled={suggestPending || actionsDisabled}
>
<ScanText
className={cn(
"size-4",
indexPending && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden
/>
{readLabel}
<WandSparkles className="size-4" aria-hidden />
{suggestPending
? t("documents.assist.suggesting")
: t("documents.assist.suggest")}
</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>
<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">
@@ -67,6 +67,7 @@ import { useIndexDocument } from "./use-content-index";
import {
documentAiErrorKey,
useDocumentAiCapability,
useDocumentsAutoAiRead,
useDocumentSummary,
useSuggestDetails,
} from "./use-document-assist";
@@ -221,6 +222,9 @@ export function DocumentDetailSheet({
// The affordance itself is gated on `assistAvailable` (from usage); the
// probe only resolves the transport mode + the actionable unavailable reason.
const capability = useDocumentAiCapability(open && documentId !== null);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
const indexDoc = useIndexDocument();
@@ -335,6 +339,7 @@ export function DocumentDetailSheet({
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
const indexEnabled =
contentIndexEnabled ?? capability.data?.available ?? false;
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
? {
@@ -504,6 +509,7 @@ export function DocumentDetailSheet({
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
@@ -51,6 +51,27 @@ export function useDocumentAiCapability(enabled: boolean) {
});
}
/** The per-user "read documents automatically with AI" opt-in. */
export interface DocumentsAutoAiReadPref {
documentsAutoAiRead: boolean;
}
/**
* Read the per-user auto-AI-read flag (`GET /api/auth/me/documents-auto-ai-read`).
* Shares the cache key with the AI-settings toggle, so a flip there and this
* read stay in lockstep. When ON, reading happens automatically on upload and
* the detail sheet drops the manual per-document AI action row.
*/
export function useDocumentsAutoAiRead(enabled: boolean) {
return useQuery<DocumentsAutoAiReadPref>({
queryKey: queryKeys.documentsAutoAiRead(),
queryFn: () =>
apiGet<DocumentsAutoAiReadPref>("/api/auth/me/documents-auto-ai-read"),
enabled,
staleTime: 60_000,
});
}
/**
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
* detail sheet prefills its edit fields and the user saves; this call writes