feat(documents): reframe indexing as automatic and make Read with AI prominent

Auto-index on upload means content search is silent-by-default, so the old
per-document index chore no longer reads as a task. Consolidate every AI
reading action into one inviting block on the detail sheet, led by a
prominent Read with AI action, and show each document's searchable status as
a pill that distinguishes an AI-read document from a locally-indexed one.

- New Read-with-AI block: prominent read/re-read action plus suggest and
  session-only summary, gated on provider availability; the calm settings
  pointer replaces the actions (never an error) when no provider is set up.
- Searchable status pill reflects the auto-index: Read by AI (provider read
  the original, highlighted), Searchable (local), or a quiet not-yet state.
- Card searchable marker distinguishes an AI-read document with a Sparkles
  glyph from a locally-indexed one; both carry an accessible label.
- Drop the manual index/re-index chore copy; keep the corpus baseline
  affordance in the filter bar for older un-indexed documents.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 21:25:15 +02:00
1 parent bb0ebb1dce
commit 11fd48cc0e
13 files changed
+431 -205

No files matched your search

+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Nur Download",
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"searchableBadge": "Inhalt durchsuchbar"
"searchableBadge": "Inhalt durchsuchbar",
"aiReadBadge": "Von KI gelesen"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
@@ -8206,15 +8207,21 @@
"notSaved": "Nicht gespeichert · keine Diagnose. Nur einmalig für diese Ansicht angezeigt.",
"close": "Ausblenden"
},
"ai": {
"blockTitle": "Mit KI auslesen",
"available": "Lass die KI dieses Dokument lesen — Titel, Art und Datum vorschlagen, zusammenfassen oder den ganzen Text durchsuchbar machen.",
"read": "Mit KI auslesen",
"reread": "Erneut lesen",
"reading": "Liest…",
"statusAiRead": "Von KI gelesen",
"statusSearchable": "Durchsuchbar",
"statusIndexing": "Wird durchsuchbar gemacht…",
"statusNotYet": "Noch nicht durchsuchbar",
"readDone": "Die KI hat dein Dokument gelesen."
},
"contentIndex": {
"searchHint": "Die Suche durchsucht jetzt auch den Inhalt deiner Dokumente (ganze Wörter).",
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
"searchable": "Inhalt durchsuchbar",
"notSearchable": "Inhalt noch nicht durchsuchbar",
"indexAction": "Für Suche indizieren",
"reindexAction": "Neu indizieren",
"indexing": "Wird indiziert…",
"indexed": "Für die Suche indiziert.",
"indexAll": "Alle für Suche indizieren",
"indexAllPending": "Wird gestartet…",
"indexAllQueued": "{count} Dokument(e) werden im Hintergrund indiziert.",
+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Download only",
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload",
"searchableBadge": "Contents searchable"
"searchableBadge": "Contents searchable",
"aiReadBadge": "Read by AI"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
@@ -8206,15 +8207,21 @@
"notSaved": "Not saved · not a diagnosis. Shown once for this view only.",
"close": "Hide"
},
"ai": {
"blockTitle": "Read with AI",
"available": "Let the AI read this document — suggest a title, type and date, summarise it, or make its full text searchable.",
"read": "Read with AI",
"reread": "Read again",
"reading": "Reading…",
"statusAiRead": "Read by AI",
"statusSearchable": "Searchable",
"statusIndexing": "Making searchable…",
"statusNotYet": "Not searchable yet",
"readDone": "The AI read your document."
},
"contentIndex": {
"searchHint": "Search now looks inside your documents too (whole words).",
"indexPrompt": "Make document contents searchable.",
"searchable": "Contents searchable",
"notSearchable": "Contents not searchable yet",
"indexAction": "Index for search",
"reindexAction": "Re-index",
"indexing": "Indexing…",
"indexed": "Indexed for search.",
"indexAll": "Index all for search",
"indexAllPending": "Starting…",
"indexAllQueued": "Indexing {count} document(s) in the background.",
+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Solo descarga",
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida",
"searchableBadge": "Contenido buscable"
"searchableBadge": "Contenido buscable",
"aiReadBadge": "Leído por IA"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
@@ -8206,15 +8207,21 @@
"notSaved": "No se guarda · no es un diagnóstico. Se muestra solo una vez en esta vista.",
"close": "Ocultar"
},
"ai": {
"blockTitle": "Leer con IA",
"available": "Deja que la IA lea este documento: sugerir un título, tipo y fecha, resumirlo o hacer que todo su texto sea buscable.",
"read": "Leer con IA",
"reread": "Leer de nuevo",
"reading": "Leyendo…",
"statusAiRead": "Leído por IA",
"statusSearchable": "Buscable",
"statusIndexing": "Haciéndolo buscable…",
"statusNotYet": "Aún no buscable",
"readDone": "La IA leyó tu documento."
},
"contentIndex": {
"searchHint": "La búsqueda ahora también mira dentro de tus documentos (palabras completas).",
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
"searchable": "Contenido buscable",
"notSearchable": "Contenido aún no buscable",
"indexAction": "Indexar para búsqueda",
"reindexAction": "Reindexar",
"indexing": "Indexando…",
"indexed": "Indexado para búsqueda.",
"indexAll": "Indexar todo para búsqueda",
"indexAllPending": "Iniciando…",
"indexAllQueued": "Indexando {count} documento(s) en segundo plano.",
+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Téléchargement uniquement",
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué",
"searchableBadge": "Contenu consultable"
"searchableBadge": "Contenu consultable",
"aiReadBadge": "Lu par l’IA"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
@@ -8206,15 +8207,21 @@
"notSaved": "Non enregistré · pas un diagnostic. Affiché une seule fois pour cette vue.",
"close": "Masquer"
},
"ai": {
"blockTitle": "Lire avec l’IA",
"available": "Laissez l’IA lire ce document — suggérer un titre, un type et une date, le résumer ou rendre tout son texte consultable.",
"read": "Lire avec l’IA",
"reread": "Relire",
"reading": "Lecture…",
"statusAiRead": "Lu par l’IA",
"statusSearchable": "Consultable",
"statusIndexing": "Rendu consultable…",
"statusNotYet": "Pas encore consultable",
"readDone": "L’IA a lu votre document."
},
"contentIndex": {
"searchHint": "La recherche explore désormais aussi le contenu de vos documents (mots entiers).",
"indexPrompt": "Rendre le contenu des documents consultable.",
"searchable": "Contenu consultable",
"notSearchable": "Contenu pas encore consultable",
"indexAction": "Indexer pour la recherche",
"reindexAction": "Réindexer",
"indexing": "Indexation…",
"indexed": "Indexé pour la recherche.",
"indexAll": "Tout indexer pour la recherche",
"indexAllPending": "Démarrage…",
"indexAllQueued": "Indexation de {count} document(s) en arrière-plan.",
+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Solo download",
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito",
"searchableBadge": "Contenuto ricercabile"
"searchableBadge": "Contenuto ricercabile",
"aiReadBadge": "Letto dall’IA"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
@@ -8206,15 +8207,21 @@
"notSaved": "Non salvato · non è una diagnosi. Mostrato una sola volta per questa vista.",
"close": "Nascondi"
},
"ai": {
"blockTitle": "Leggi con l’IA",
"available": "Lascia che l’IA legga questo documento: suggerire un titolo, un tipo e una data, riassumerlo o rendere ricercabile tutto il testo.",
"read": "Leggi con l’IA",
"reread": "Leggi di nuovo",
"reading": "Lettura…",
"statusAiRead": "Letto dall’IA",
"statusSearchable": "Ricercabile",
"statusIndexing": "Rendo ricercabile…",
"statusNotYet": "Non ancora ricercabile",
"readDone": "L’IA ha letto il tuo documento."
},
"contentIndex": {
"searchHint": "La ricerca ora esamina anche il contenuto dei tuoi documenti (parole intere).",
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
"searchable": "Contenuto ricercabile",
"notSearchable": "Contenuto non ancora ricercabile",
"indexAction": "Indicizza per la ricerca",
"reindexAction": "Reindicizza",
"indexing": "Indicizzazione…",
"indexed": "Indicizzato per la ricerca.",
"indexAll": "Indicizza tutto per la ricerca",
"indexAllPending": "Avvio…",
"indexAllQueued": "Indicizzazione di {count} documento/i in background.",
+14 -7
View File
@@ -8085,7 +8085,8 @@
"attachmentBadge": "Tylko do pobrania",
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie",
"searchableBadge": "Treść przeszukiwalna"
"searchableBadge": "Treść przeszukiwalna",
"aiReadBadge": "Odczytane przez AI"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
@@ -8206,15 +8207,21 @@
"notSaved": "Nie zapisano · to nie diagnoza. Wyświetlane tylko raz w tym widoku.",
"close": "Ukryj"
},
"ai": {
"blockTitle": "Odczytaj z AI",
"available": "Pozwól AI odczytać ten dokument — zaproponować tytuł, typ i datę, streścić go lub udostępnić cały tekst do wyszukiwania.",
"read": "Odczytaj z AI",
"reread": "Odczytaj ponownie",
"reading": "Odczytywanie…",
"statusAiRead": "Odczytane przez AI",
"statusSearchable": "Wyszukiwalne",
"statusIndexing": "Udostępnianie do wyszukiwania…",
"statusNotYet": "Jeszcze nie do wyszukania",
"readDone": "AI odczytało Twój dokument."
},
"contentIndex": {
"searchHint": "Wyszukiwanie przeszukuje teraz także treść Twoich dokumentów (całe słowa).",
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
"searchable": "Treść przeszukiwalna",
"notSearchable": "Treść jeszcze nieprzeszukiwalna",
"indexAction": "Zindeksuj do wyszukiwania",
"reindexAction": "Zindeksuj ponownie",
"indexing": "Indeksowanie…",
"indexed": "Zindeksowano do wyszukiwania.",
"indexAll": "Zindeksuj wszystko do wyszukiwania",
"indexAllPending": "Uruchamianie…",
"indexAllQueued": "Indeksowanie {count} dokumentu/ów w tle.",
@@ -6,7 +6,7 @@ import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents"
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentIndexStatus,
ContentSearchStatus,
DocumentSummaryPanel,
} from "../document-ai-panels";
@@ -175,25 +175,47 @@ describe("<DocumentSummaryPanel>", () => {
});
});
describe("<ContentIndexStatus>", () => {
it("offers indexing when the document is not yet searchable", () => {
describe("<ContentSearchStatus>", () => {
it("shows a calm not-searchable-yet pill before the auto-index lands", () => {
const html = render(
<ContentIndexStatus
<ContentSearchStatus
hasContentIndex={false}
source={null}
isPending={false}
onIndex={noop}
/>,
);
expect(html).toContain('data-slot="content-index-status"');
expect(html).toContain("Contents not searchable yet");
expect(html).toContain("Index for search");
expect(html).toContain('data-slot="content-search-status"');
expect(html).toContain('data-state="none"');
expect(html).toContain("Not searchable yet");
// A status, never a chore button.
expect(html).not.toContain("<button");
});
it("offers a re-index when the document is already searchable", () => {
const html = render(
<ContentIndexStatus hasContentIndex isPending={false} onIndex={noop} />,
it("highlights an AI-read document distinctly from a locally-indexed one", () => {
const aiRead = render(
<ContentSearchStatus hasContentIndex source="vision" isPending={false} />,
);
expect(html).toContain("Contents searchable");
expect(html).toContain("Re-index");
expect(aiRead).toContain('data-state="ai-read"');
expect(aiRead).toContain("Read by AI");
expect(aiRead).toContain("text-primary");
const local = render(
<ContentSearchStatus
hasContentIndex
source="local-pdf"
isPending={false}
/>,
);
expect(local).toContain('data-state="searchable"');
expect(local).toContain("Searchable");
expect(local).not.toContain("Read by AI");
});
it("reflects a running read as an indexing state", () => {
const html = render(
<ContentSearchStatus hasContentIndex={false} source={null} isPending />,
);
expect(html).toContain('data-state="indexing"');
expect(html).toContain("Making searchable…");
});
});
@@ -6,10 +6,11 @@ import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents"
import { DocumentAiSection } from "../document-ai-section";
/**
* The detail sheet's AI area is availability-gated: with a provider it shows the
* assist / summary toolbar; without one it shows ONLY the calm settings pointer
* (never an action the endpoint would 422). The summary panel, when open, keeps
* the session-only note visible.
* The detail sheet's "Read with AI" block is availability-gated: with a provider
* it offers the prominent "Read with AI" action alongside suggest / summary;
* without one the actions collapse to the calm settings pointer (never an action
* the endpoint would 422). The searchable status pill reflects the auto-index in
* both states. The summary panel, when open, keeps the session-only note visible.
*/
function render(node: React.ReactNode) {
@@ -52,6 +53,7 @@ function base(
summaryErrorKey: null,
onCloseSummary: noop,
hasContentIndex: false,
contentIndexSource: null,
indexPending: false,
onIndex: noop,
...overrides,
@@ -60,25 +62,59 @@ function base(
}
describe("<DocumentAiSection>", () => {
it("offers the assist + summary toolbar when a provider is available", () => {
it("leads with the prominent Read-with-AI action when a provider is available", () => {
const html = render(base({ aiEnabled: true }));
expect(html).toContain('data-slot="document-read-ai"');
expect(html).toContain("Read with AI");
expect(html).toContain('data-slot="assist-suggest"');
expect(html).toContain("Suggest details");
expect(html).toContain("Summarise");
expect(html).toContain("Show extracted text");
// No unavailable pointer when the affordance is offered.
// No unavailable pointer when the actions are offered.
expect(html).not.toContain('data-slot="assist-unavailable"');
});
it("shows ONLY the calm settings pointer when no provider is available", () => {
it("labels the action as a re-read once a provider has already read it", () => {
const html = render(
base({ aiEnabled: false, unavailableReason: "no-provider" }),
base({ hasContentIndex: true, contentIndexSource: "vision" }),
);
expect(html).toContain('data-slot="document-read-ai"');
expect(html).toContain("Read again");
// The status pill reflects the AI-read provenance.
expect(html).toContain('data-state="ai-read"');
expect(html).toContain("Read by AI");
});
it("shows the calm settings pointer (no 422 actions) when no provider is available", () => {
const html = render(
base({
aiEnabled: false,
indexEnabled: false,
unavailableReason: "no-provider",
}),
);
expect(html).toContain('data-slot="assist-unavailable"');
expect(html).toContain('href="/settings/ai"');
// The assist / index actions must be absent — never a 422-guaranteed tap.
// Every AI action must be absent — never a 422-guaranteed tap.
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="content-index-status"');
expect(html).not.toContain('data-slot="document-read-ai"');
// But the searchable status pill still renders — auto-index is honest here.
expect(html).toContain('data-slot="content-search-status"');
});
it("keeps a locally-indexed document honestly searchable without a provider", () => {
const html = render(
base({
aiEnabled: false,
indexEnabled: false,
unavailableReason: "no-provider",
hasContentIndex: true,
contentIndexSource: "local-pdf",
}),
);
expect(html).toContain('data-state="searchable"');
expect(html).toContain("Searchable");
expect(html).not.toContain("Read by AI");
});
it("renders the reviewed suggestion drafts when present", () => {
@@ -106,9 +142,9 @@ describe("<DocumentAiSection>", () => {
expect(html).toContain("Not saved");
});
it("hides the content-index row when indexing is unavailable", () => {
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
const html = render(base({ aiEnabled: true, indexEnabled: false }));
expect(html).toContain('data-slot="assist-suggest"');
expect(html).not.toContain('data-slot="content-index-status"');
expect(html).not.toContain('data-slot="document-read-ai"');
});
});
@@ -96,9 +96,25 @@ describe("<DocumentCard>", () => {
/>,
);
expect(html).toContain('data-slot="document-searchable"');
expect(html).toContain('data-source="local"');
expect(html).toContain("Contents searchable");
});
it("marks an AI-read document with the highlighted read-by-AI badge", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-source="ai-read"');
expect(html).toContain("Read by AI");
expect(html).toContain("text-primary");
});
it("shows no searchable marker when the document is not indexed", () => {
const html = render(
<DocumentCard
+75 -37
View File
@@ -6,29 +6,39 @@
* the session-only note are pinned by static-render tests.
*
* - `AiUnavailableHint` — the calm "set up an AI provider" pointer shown in
* place of the AI toolbar when no provider is configured (never an error).
* place of the AI actions when no provider is configured (never an error).
* - `AssistSuggestionReview` — the reviewed DRAFT card: suggested title / type
* / date, each applied only by an explicit tap. Nothing is written until the
* user applies it (and the title lands in the editable field, not on disk).
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
* the persistent "not saved · not a diagnosis" note.
* - `ContentIndexStatus` — the per-document searchable state + index action.
* - `ContentSearchStatus` — the per-document searchable pill reflecting the
* auto-index: "Read by AI" (provider read the original), "Searchable"
* (locally indexed), "Making searchable…" (a read is running), or the calm
* "not searchable yet". A STATUS, never a chore button.
*/
import { Check, FileText, ScanSearch, Sparkles, X } from "lucide-react";
import { Loader2, ScanSearch, Sparkles } from "lucide-react";
import { Check, FileText, X } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import type { DocumentDescribeResult } from "./use-document-assist";
/** Calm pointer to the AI settings, shown when assist is unavailable. */
/**
* Calm pointer to the AI settings, shown when assist is unavailable. When the
* document is already searchable (auto-indexed locally) the copy stays honest —
* it is searchable, an AI provider only adds a richer read.
*/
export function AiUnavailableHint({
reason,
}: {
@@ -280,44 +290,72 @@ export function DocumentSummaryPanel({
}
/**
* The per-document content-search state: whether the body is searchable, and an
* explicit index / re-index action. Shown only when indexing is available.
* The per-document searchable status pill. Reflects the auto-index (indexing is
* automatic on upload, so this is a state, never a to-do): a document is "Read
* by AI" when a provider read the original, "Searchable" when it is only locally
* indexed, "Making searchable…" while a read runs, and a calm "not searchable
* yet" otherwise. The AI-read pill is highlighted (primary) — that read is the
* richer one and worth surfacing.
*/
export function ContentIndexStatus({
export function ContentSearchStatus({
hasContentIndex,
source,
isPending,
onIndex,
}: {
hasContentIndex: boolean;
source: DocumentContentIndexSourceValue | null;
/** A read/index is running right now. */
isPending: boolean;
onIndex: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="content-index-status"
className="flex items-center justify-between gap-2"
>
<p className="text-muted-foreground inline-flex items-center gap-1.5 text-xs">
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{hasContentIndex
? t("documents.contentIndex.searchable")
: t("documents.contentIndex.notSearchable")}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={onIndex}
disabled={isPending}
if (isPending) {
return (
<span
data-slot="content-search-status"
data-state="indexing"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
{isPending
? t("documents.contentIndex.indexing")
: hasContentIndex
? t("documents.contentIndex.reindexAction")
: t("documents.contentIndex.indexAction")}
</Button>
</div>
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
{t("documents.ai.statusIndexing")}
</span>
);
}
if (!hasContentIndex) {
return (
<span
data-slot="content-search-status"
data-state="none"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusNotYet")}
</span>
);
}
if (isAiReadSource(source)) {
return (
<span
data-slot="content-search-status"
data-state="ai-read"
className="bg-primary/10 text-primary inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
>
<Sparkles className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusAiRead")}
</span>
);
}
return (
<span
data-slot="content-search-status"
data-state="searchable"
className="text-muted-foreground inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
>
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{t("documents.ai.statusSearchable")}
</span>
);
}
+138 -87
View File
@@ -1,29 +1,43 @@
"use client";
/**
* v1.27.22 (Document vault P2) — the AI area of the document detail sheet,
* lifted out of the sheet so the availability gate (toolbar vs. calm pointer)
* and the review-first wiring render without the sheet's dialog portal and are
* pinned by static-render tests.
* 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 whole area is the
* calm "set up an AI provider" pointer — never an error, and the manual form
* below stays fully usable.
* 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, Sparkles } from "lucide-react";
import { FileText, ScanText, WandSparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentIndexStatus,
ContentSearchStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
@@ -51,6 +65,7 @@ export function DocumentAiSection({
summaryErrorKey,
onCloseSummary,
hasContentIndex,
contentIndexSource,
indexPending,
onIndex,
}: {
@@ -77,92 +92,128 @@ export function DocumentAiSection({
summaryErrorKey: string | null;
onCloseSummary: () => void;
hasContentIndex: boolean;
contentIndexSource: DocumentContentIndexSourceValue | null;
indexPending: boolean;
onIndex: () => void;
}) {
const { t } = useTranslations();
if (!aiEnabled) {
return (
<div className="space-y-3" data-slot="document-ai-section">
<AiUnavailableHint reason={unavailableReason} />
</div>
);
}
const aiRead = hasContentIndex && isAiReadSource(contentIndexSource);
const readLabel = indexPending
? t("documents.ai.reading")
: aiRead
? t("documents.ai.reread")
: t("documents.ai.read");
return (
<div className="space-y-3" data-slot="document-ai-section">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
data-slot="assist-suggest"
onClick={onSuggest}
disabled={suggestPending || actionsDisabled}
>
<Sparkles 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
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>
{suggestErrorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(suggestErrorKey)}
</p>
) : null}
{aiEnabled ? (
<>
<p className="text-muted-foreground text-xs">
{t("documents.ai.available")}
</p>
{suggestion ? (
<AssistSuggestionReview
suggestion={suggestion}
kindLabel={suggestionKindLabel}
dateLabel={suggestionDateLabel}
applied={appliedFields}
onUseTitle={onUseTitle}
onUseKind={onUseKind}
onUseDate={onUseDate}
onDismiss={onDismissSuggestion}
/>
) : null}
<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")}
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>
{summaryOutput ? (
<DocumentSummaryPanel
output={summaryOutput}
result={summaryResult}
isPending={summaryPending}
errorKey={summaryErrorKey}
onClose={onCloseSummary}
/>
) : null}
{suggestErrorKey ? (
<p role="alert" className="text-destructive text-sm">
{t(suggestErrorKey)}
</p>
) : null}
{indexEnabled ? (
<ContentIndexStatus
hasContentIndex={hasContentIndex}
isPending={indexPending}
onIndex={onIndex}
/>
) : 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>
);
}
+33 -13
View File
@@ -13,7 +13,7 @@
* hover / focus / while selected. The whole card is clickable through an
* invisible overlay button; the checkbox floats above it.
*/
import { Download, ScanSearch, X } from "lucide-react";
import { Download, ScanSearch, Sparkles, X } from "lucide-react";
import { useRef } from "react";
import { Badge } from "@/components/ui/badge";
@@ -22,7 +22,10 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
isAiReadSource,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
@@ -140,18 +143,35 @@ export function DocumentCard({
</Badge>
) : null}
{document.hasContentIndex ? (
// Subtle "contents searchable" marker — icon-only to keep the
// row calm; the label rides an accessible name.
<span
data-slot="document-searchable"
className="text-muted-foreground inline-flex items-center"
title={t("documents.card.searchableBadge")}
>
<ScanSearch className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.searchableBadge")}
// Subtle searchable marker — icon-only to keep the row calm; the
// label rides an accessible name. An AI-read document gets the
// highlighted Sparkles (primary) so the richer read is legible at
// a glance; a locally-indexed one keeps the quiet muted glass.
isAiReadSource(document.contentIndexSource) ? (
<span
data-slot="document-searchable"
data-source="ai-read"
className="text-primary inline-flex items-center"
title={t("documents.card.aiReadBadge")}
>
<Sparkles className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.aiReadBadge")}
</span>
</span>
</span>
) : (
<span
data-slot="document-searchable"
data-source="local"
className="text-muted-foreground inline-flex items-center"
title={t("documents.card.searchableBadge")}
>
<ScanSearch className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.searchableBadge")}
</span>
</span>
)
) : null}
</div>
) : null}
@@ -365,7 +365,7 @@ export function DocumentDetailSheet({
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
onSuccess: () => toast.success(t("documents.contentIndex.indexed")),
onSuccess: () => toast.success(t("documents.ai.readDone")),
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
@@ -531,6 +531,7 @@ export function DocumentDetailSheet({
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>