feat(documents): surface content search in the vault list

The list search already unions matches from the encrypted content index
server-side; surface it so it is legible and reachable:

- a subtle "search now looks inside your documents too (whole words)" hint
  under the search box once any document is indexed;
- a per-row "searchable" marker on indexed documents;
- an "index all for search" affordance that fires the corpus backfill when
  some documents are not yet indexed, driven off the usage coverage gauge.

All of it is gated on the same provider/consent precondition as the assist
layer — with no provider, search degrades to title and filename exactly as
before.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 19:14:59 +02:00
1 parent afeb693562
commit 2319988a96
4 files changed
+119 -3

No files matched your search

@@ -84,6 +84,33 @@ describe("<DocumentCard>", () => {
expect(html).toContain("befund.docx");
});
it("marks a content-indexed document as searchable", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-slot="document-searchable"');
expect(html).toContain("Contents searchable");
});
it("shows no searchable marker when the document is not indexed", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: false, conditionLinks: [] })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-searchable"');
});
it("falls back to the untitled label and rings when highlighted", () => {
const html = render(
<DocumentCard
+17 -2
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, X } from "lucide-react";
import { Download, ScanSearch, X } from "lucide-react";
import { useRef } from "react";
import { Badge } from "@/components/ui/badge";
@@ -119,7 +119,8 @@ export function DocumentCard({
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
document.servingClass === "attachment" ||
document.hasContentIndex ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
@@ -138,6 +139,20 @@ export function DocumentCard({
{t("documents.card.attachmentBadge")}
</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")}
</span>
</span>
) : null}
</div>
) : null}
</CardContent>
@@ -11,7 +11,7 @@
* sticks below the top edge inside the shell's scroll container (sticky,
* translucent background, border-b — no viewport-height tricks).
*/
import { Search, X } from "lucide-react";
import { ScanSearch, Search, X } from "lucide-react";
import type { RefObject } from "react";
import { Button } from "@/components/ui/button";
@@ -45,6 +45,10 @@ export function DocumentFilterBar({
onToggleYear,
activeCount,
onClearAll,
contentSearchActive = false,
showIndexAll = false,
indexAllPending = false,
onIndexAll,
}: {
searchValue: string;
onSearchChange: (value: string) => void;
@@ -60,6 +64,12 @@ export function DocumentFilterBar({
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
/** Search already matches indexed document CONTENT (whole words). */
contentSearchActive?: boolean;
/** Some documents are not yet indexed — offer the corpus backfill. */
showIndexAll?: boolean;
indexAllPending?: boolean;
onIndexAll?: () => void;
}) {
const { t } = useTranslations();
@@ -205,6 +215,35 @@ export function DocumentFilterBar({
</Button>
) : null}
</div>
{contentSearchActive || showIndexAll ? (
<div
data-slot="content-search-hint"
className="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
>
<span className="inline-flex items-center gap-1.5">
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{contentSearchActive
? t("documents.contentIndex.searchHint")
: t("documents.contentIndex.indexPrompt")}
</span>
{showIndexAll ? (
<Button
type="button"
variant="link"
size="sm"
data-slot="content-index-all"
onClick={onIndexAll}
disabled={indexAllPending}
className="text-primary h-auto p-0 text-xs"
>
{indexAllPending
? t("documents.contentIndex.indexAllPending")
: t("documents.contentIndex.indexAll")}
</Button>
) : null}
</div>
) : null}
</div>
);
}
@@ -56,6 +56,7 @@ import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { useReindexAll } from "./use-content-index";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
@@ -244,6 +245,34 @@ export function DocumentsView() {
const episodes = useIllnessEpisodes(true);
// Content-search coverage: fire the corpus backfill when indexing is
// available and some documents are not yet searchable. The list search
// already unions content matches server-side — this only surfaces the hint
// and the "index all" affordance from the usage gauge.
const reindexAll = useReindexAll();
const contentIndex = usage.data?.contentIndex;
const canIndexContent = contentIndex?.enabled ?? false;
const showIndexAll =
canIndexContent &&
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
contentIndex.indexedCount < contentIndex.totalCount;
const contentSearchActive =
canIndexContent && (contentIndex?.indexedCount ?? 0) > 0;
const handleIndexAll = useCallback(() => {
reindexAll.mutate(undefined, {
onSuccess: (result) =>
toast.success(
result.enqueued > 0
? t("documents.contentIndex.indexAllQueued", {
count: result.enqueued,
})
: t("documents.contentIndex.indexAllNothing"),
),
onError: () => toast.error(t("documents.contentIndex.indexAllError")),
});
}, [reindexAll, t]);
// Condition chips: every episode carrying at least one live document
// link, served by the usage endpoint (NOT derived from the loaded corpus
// — an old linked document pages deep must still surface its chip), plus
@@ -557,6 +586,10 @@ export function DocumentsView() {
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
contentSearchActive={contentSearchActive}
showIndexAll={showIndexAll}
indexAllPending={reindexAll.isPending}
onIndexAll={handleIndexAll}
/>
{list.isPending ? (
@@ -618,6 +651,8 @@ export function DocumentsView() {
documentId={detailId}
open={detailOpen}
onOpenChange={handleDetailOpenChange}
assistAvailable={usage.data?.assistAvailable}
contentIndexEnabled={usage.data?.contentIndex.enabled}
/>
{selectedIds.size > 0 ? (