mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
bb0ebb1dce
commit
11fd48cc0e
13 files changed
+431
-205
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user