chore(release): v1.28.22 — full sync survives dense histories; quieter document chrome

Google Health: the fullSync rollup-defer tracker collected its touched keys via
a single spread push — one argument PER READING, so a multi-year heart-rate
history (six figures of samples) overflowed the call stack and aborted the
metrics collection mid-cycle, exactly on the accounts with the most data. A
plain loop replaces the spread. Reported by a self-hoster; the v1.28.21 sync
diagnostics confirmed every other read on that account healthy in one log line.

Documents: the vault card's read-by-AI tag no longer takes its own line — a
small glyph rides inline on the date-and-size row. The detail view drops the
status bar under the preview entirely; with automatic reading on and nothing to
review the whole block collapses, so the content starts straight with the
document's fields. The provenance contract moves with it: the e2e specs now pin
the card marker instead of the sheet pill, and the unmounted status-pill
component is removed rather than left dead.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 23:02:52 +02:00
1 parent 94ad48527e
commit 869f5e561b
18 files changed
+95 -209

No files matched your search

@@ -6,7 +6,6 @@ import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents"
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "../document-ai-panels";
@@ -172,48 +171,3 @@ describe("<DocumentSummaryPanel>", () => {
expect(html).toContain("read the document");
});
});
describe("<ContentSearchStatus>", () => {
it("shows a calm not-searchable-yet pill before the auto-index lands", () => {
const html = render(
<ContentSearchStatus
hasContentIndex={false}
source={null}
isPending={false}
/>,
);
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("highlights an AI-read document distinctly from a locally-indexed one", () => {
const aiRead = render(
<ContentSearchStatus hasContentIndex source="vision" isPending={false} />,
);
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…");
});
});
@@ -80,9 +80,9 @@ describe("<DocumentAiSection>", () => {
);
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");
// v1.28.22 — no status pill any more; the re-read label alone carries the
// provenance in this block (the marker lives on the vault card).
expect(html).not.toContain('data-state="ai-read"');
});
it("shows the calm settings pointer (no 422 actions) when no provider is available", () => {
@@ -98,8 +98,9 @@ describe("<DocumentAiSection>", () => {
// 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="document-read-ai"');
// But the searchable status pill still renders — auto-index is honest here.
expect(html).toContain('data-slot="content-search-status"');
// v1.28.22 — the status pill is gone entirely (provenance lives on the
// vault card's meta row); only the calm hint renders here.
expect(html).not.toContain('data-slot="content-search-status"');
});
it("keeps a locally-indexed document honestly searchable without a provider", () => {
@@ -112,8 +113,10 @@ describe("<DocumentAiSection>", () => {
contentIndexSource: "local-pdf",
}),
);
expect(html).toContain('data-state="searchable"');
expect(html).toContain("Searchable");
// v1.28.22 — the searchable pill is gone; only the calm provider hint
// renders for a locally-indexed document without a provider.
expect(html).not.toContain('data-state="searchable"');
expect(html).toContain('data-slot="assist-unavailable"');
expect(html).not.toContain("Read by AI");
});
@@ -163,11 +166,10 @@ describe("<DocumentAiSection>", () => {
expect(html).not.toContain('data-slot="assist-suggest"');
expect(html).not.toContain("Summarise");
expect(html).not.toContain("Show extracted text");
// The header and the searchable status pill stay. The scoped chat entry
// now lives in the detail-sheet header (a neutral Coach icon), not in this
// section, so this block no longer owns a chat slot.
expect(html).toContain('data-slot="document-ai-section"');
expect(html).toContain('data-slot="content-search-status"');
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
// collapses: no chrome, no status pill; the sheet content starts straight
// with the document fields under the preview.
expect(html).toBe("");
});
it("shows the manual AI action row when auto-read is off", () => {
@@ -12,12 +12,8 @@
* 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.
* - `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 { Loader2, ScanSearch, Sparkles } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Check, FileText, X } from "lucide-react";
import Link from "next/link";
@@ -26,8 +22,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
@@ -270,77 +264,3 @@ export function DocumentSummaryPanel({
</div>
);
}
/**
* 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 ContentSearchStatus({
hasContentIndex,
source,
isPending,
}: {
hasContentIndex: boolean;
source: DocumentContentIndexSourceValue | null;
/** A read/index is running right now. */
isPending: boolean;
}) {
const { t } = useTranslations();
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"
>
<Loader2
className="size-3.5 shrink-0 animate-spin motion-reduce:animate-none"
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>
);
}
@@ -7,8 +7,9 @@
* 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:
* chore. It carries NO header or status chrome (the AI-read provenance marker
* lives on the vault card's meta row) 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
@@ -16,11 +17,13 @@
* - "Suggest details" drafts a title / type / date to review before saving.
* - "Summarise" / "Show text" surface a transient, session-only read-out.
*
* With auto-read ON and nothing pending to review the block collapses entirely —
* the sheet content starts straight with the document fields under the preview.
*
* Presentational: the sheet owns the mutations + capability probe and passes
* 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.
* the calm "set up an AI provider" pointer (never an error) and the manual form
* below stays fully usable.
*/
import { FileText, ScanText, WandSparkles } from "lucide-react";
@@ -37,7 +40,6 @@ import {
import {
AiUnavailableHint,
AssistSuggestionReview,
ContentSearchStatus,
DocumentSummaryPanel,
} from "./document-ai-panels";
import type { DocumentDescribeResult } from "./use-document-assist";
@@ -113,23 +115,26 @@ export function DocumentAiSection({
? t("documents.ai.reread")
: t("documents.ai.read");
// v1.28.22 — the block carries NO header/status chrome any more (the
// provenance pill moved to the vault card's meta row; auto-read makes an
// in-sheet "read by AI" banner redundant). With auto-read ON and nothing
// pending to review, nothing below would render — collapse the whole box so
// the content under the preview starts straight with the fields.
if (
aiEnabled &&
autoReadEnabled &&
!suggestErrorKey &&
!suggestion &&
!summaryOutput
) {
return null;
}
return (
<div
data-slot="document-ai-section"
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3 md:p-4"
>
{/* v1.28.19 — the "read with AI" block title + wand were redundant with
the read-provenance pill (auto-read already surfaces "read by AI"), so
only the provenance pill leads the block now. The pill itself stays —
it is the read-state contract the recipient/owner relies on. */}
<div className="flex items-center justify-end">
<ContentSearchStatus
hasContentIndex={hasContentIndex}
source={contentIndexSource}
isPending={indexPending}
/>
</div>
{aiEnabled ? (
<>
{!autoReadEnabled ? (
+15 -15
View File
@@ -133,22 +133,22 @@ export function DocumentCard({
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{aiRead ? (
// Discreet AI-read indicator — same visual as the detail sheet's
// ContentSearchStatus ai-read pill (bg-primary/10 text-primary,
// Sparkles). Kept compact for the dense grid card.
<span
data-slot="document-card-ai-read"
className="bg-primary/10 text-primary mt-1 inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
>
<Sparkles className="size-3 shrink-0" aria-hidden />
{t("documents.ai.statusAiRead")}
<p className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
<span className="truncate">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</span>
) : null}
{aiRead ? (
// AI-read marker rides INLINE on the meta row — a bare glyph,
// no pill line (the tag ate a whole row in the dense grid).
<Sparkles
data-slot="document-card-ai-read"
role="img"
aria-label={t("documents.ai.statusAiRead")}
className="text-primary size-3 shrink-0"
/>
) : null}
</p>
</div>
<Checkbox
checked={selected}