merge auto-read-buttons

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 17:29:02 +02:00
commit 9e5d84ef66
15 files changed
+168 -86

No files matched your search

+7 -3
View File
@@ -110,6 +110,13 @@ async function mockAiEnabled(
error: null,
}),
);
// The detail sheet reads the auto-AI-read opt-in to decide whether to show
// the manual per-document action row. Default it OFF here so the manual
// Read / Suggest / Summarise actions render for these assertions; an unmocked
// fetch would leave the sheet reading a live value.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
}
test.describe("document vault — AI assist + content search", () => {
@@ -172,9 +179,6 @@ test.describe("document vault — AI assist + content search", () => {
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
await expect(review).toBeVisible();
await expect(
review.getByText("AI suggestion — review before saving"),
).toBeVisible();
// Applying the title seeds the EDITABLE field only — no write yet.
await review.getByRole("button", { name: "Use title" }).click();
+39 -3
View File
@@ -44,9 +44,21 @@ async function fulfilJson(
*/
async function mockDocumentCapability(
page: Page,
opts: { egress: "local" | "external"; pdfSupported?: boolean },
opts: {
egress: "local" | "external";
pdfSupported?: boolean;
/** The per-user auto-AI-read opt-in. OFF keeps the manual action row. */
autoRead?: boolean;
},
): Promise<void> {
const pdfSupported = opts.pdfSupported ?? true;
const autoRead = opts.autoRead ?? false;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, {
data: { documentsAutoAiRead: autoRead },
error: null,
}),
);
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, {
data: {
@@ -184,8 +196,9 @@ test.describe("automatic AI reading — vault per-document contract", () => {
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The per-document read is the explicit path — the action is present and the
// user must tap it (auto-read never removes the affordance, only the tap).
// With auto-read OFF the per-document read is the explicit path — the action
// is present and the user taps it. (When auto-read is ON the row collapses;
// that is covered by its own test below.)
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
@@ -234,4 +247,27 @@ test.describe("automatic AI reading — vault per-document contract", () => {
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
test("with auto-read ON the sheet drops the manual AI action row", async ({
page,
}) => {
// Auto-read reads every upload with no per-document tap, so the manual
// Read / Suggest actions are redundant and collapse away. The section
// header and the searchable status pill stay.
await mockDocumentCapability(page, { egress: "external", autoRead: true });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const section = sheet.locator('[data-slot="document-ai-section"]');
await expect(section).toBeVisible();
await expect(
section.locator('[data-slot="content-search-status"]'),
).toBeVisible();
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
0,
);
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
});
+5
View File
@@ -83,6 +83,11 @@ async function mockAiEnabled(
error: null,
}),
);
// The detail sheet reads the auto-AI-read opt-in; default it OFF so the AI
// section renders its full set of controls alongside the chat entry.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
}
test.describe("document vault — chat about a document", () => {
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Details vorschlagen",
"suggesting": "Wird gelesen…",
"reviewTitle": "KI-Vorschlag — vor dem Speichern prüfen",
"reviewHint": "Es wird nichts gespeichert, bis du es übernimmst. Vorschläge können falsch sein — prüfe sie.",
"suggestedTitle": "Vorgeschlagener Titel",
"suggestedKind": "Vorgeschlagener Typ",
"suggestedDate": "Vorgeschlagenes Datum",
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Suggest details",
"suggesting": "Reading…",
"reviewTitle": "AI suggestion — review before saving",
"reviewHint": "Nothing is saved until you apply it. Suggestions can be wrong — check them.",
"suggestedTitle": "Suggested title",
"suggestedKind": "Suggested type",
"suggestedDate": "Suggested date",
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Sugerir detalles",
"suggesting": "Leyendo…",
"reviewTitle": "Sugerencia de IA: revísala antes de guardar",
"reviewHint": "No se guarda nada hasta que la apliques. Las sugerencias pueden fallar: compruébalas.",
"suggestedTitle": "Título sugerido",
"suggestedKind": "Tipo sugerido",
"suggestedDate": "Fecha sugerida",
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Suggérer des détails",
"suggesting": "Lecture…",
"reviewTitle": "Suggestion IA — à vérifier avant d'enregistrer",
"reviewHint": "Rien n'est enregistré tant que vous ne l'appliquez pas. Les suggestions peuvent être erronées — vérifiez-les.",
"suggestedTitle": "Titre suggéré",
"suggestedKind": "Type suggéré",
"suggestedDate": "Date suggérée",
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Suggerisci dettagli",
"suggesting": "Lettura…",
"reviewTitle": "Suggerimento IA — verifica prima di salvare",
"reviewHint": "Nulla viene salvato finché non lo applichi. I suggerimenti possono essere errati: controllali.",
"suggestedTitle": "Titolo suggerito",
"suggestedKind": "Tipo suggerito",
"suggestedDate": "Data suggerita",
-2
View File
@@ -8229,8 +8229,6 @@
"assist": {
"suggest": "Zaproponuj szczegóły",
"suggesting": "Odczytywanie…",
"reviewTitle": "Sugestia AI — sprawdź przed zapisaniem",
"reviewHint": "Nic nie zostanie zapisane, dopóki tego nie zastosujesz. Sugestie mogą być błędne — sprawdź je.",
"suggestedTitle": "Sugerowany tytuł",
"suggestedKind": "Sugerowany typ",
"suggestedDate": "Sugerowana data",
@@ -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