Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 10:07:42 +02:00
commit cd502d5aef
20 files changed
+818 -82

No files matched your search

@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
import { DocumentSummaryBlock } from "../document-summary-block";
/**
* The detail view used to read a null summary as "being generated" and say so
* forever — for a document nobody ever enqueued as readily as for one actually
* in flight. These pin the states apart: only PENDING may claim generation is
* running, a stored summary comes from storage, and every dead end offers a way
* out.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
function base(
overrides: Partial<React.ComponentProps<typeof DocumentSummaryBlock>> = {},
) {
const props: React.ComponentProps<typeof DocumentSummaryBlock> = {
summary: null,
summaryState: "NONE",
generatedAtLabel: null,
aiEnabled: true,
isGenerating: false,
actionsDisabled: false,
onGenerate: noop,
...overrides,
};
return render(<DocumentSummaryBlock {...props} />);
}
/** Every state that has no summary to show. */
const ABSENT_STATES: DocumentSummaryStateValue[] = [
"NONE",
"WITHHELD",
"UNAVAILABLE",
];
describe("DocumentSummaryBlock — a stored summary", () => {
it("renders the stored summary from storage, with its date", () => {
const html = base({
summary: "A discharge letter from a city hospital.",
summaryState: "READY",
generatedAtLabel: "14 Feb 2026",
});
expect(html).toContain("A discharge letter from a city hospital.");
expect(html).toContain("14 Feb 2026");
expect(html).toContain('data-slot="document-detail-summary"');
// Nothing claims it is being made — it already exists.
expect(html).not.toContain("being prepared");
expect(html).not.toContain("document-detail-summary-generate");
});
it("shows a stored summary even if the state disagrees", () => {
// The bytes are the truth. A stale state must never hide a real summary.
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
expect(html).toContain("Stored prose.");
expect(html).not.toContain("being prepared");
});
});
describe("DocumentSummaryBlock — honest absent states", () => {
it("claims generation is running ONLY for PENDING", () => {
const pending = base({ summaryState: "PENDING" });
expect(pending).toContain("being prepared");
expect(pending).toContain('data-slot="document-detail-summary-pending"');
// A job is running; offering a second one would double-spend.
expect(pending).not.toContain("document-detail-summary-generate");
});
it.each(ABSENT_STATES)(
"never says 'being generated' for %s",
(summaryState) => {
const html = base({ summaryState });
expect(html).not.toContain("being prepared");
expect(html).toContain('data-slot="document-detail-summary-absent"');
},
);
it("says a summary was never generated for NONE", () => {
expect(base({ summaryState: "NONE" })).toContain(
"No summary has been generated for this document yet.",
);
});
it("says the summary was withheld, and never shows blocked prose", () => {
const html = base({ summaryState: "WITHHELD" });
expect(html).toContain("did not pass the safety check");
// The withheld state carries no summary text — that is the whole contract.
expect(html).not.toContain("document-detail-summary&quot;");
});
it("says none could be produced for UNAVAILABLE", () => {
expect(base({ summaryState: "UNAVAILABLE" })).toContain(
"No summary could be generated for this document.",
);
});
it.each(ABSENT_STATES)("offers a way out of %s", (summaryState) => {
const html = base({ summaryState });
expect(html).toContain('data-slot="document-detail-summary-generate"');
expect(html).toContain("Generate summary");
});
});
describe("DocumentSummaryBlock — the action", () => {
it("states the outcome but offers no action without a provider", () => {
const html = base({ summaryState: "UNAVAILABLE", aiEnabled: false });
// The state is still honest; only the button that would 422 is gone.
expect(html).toContain("No summary could be generated for this document.");
expect(html).not.toContain("document-detail-summary-generate");
});
it("disables the action while generating, and says so", () => {
const html = base({ summaryState: "NONE", isGenerating: true });
expect(html).toContain("disabled");
expect(html).toContain("Generating");
});
it("disables the action while the capability probe resolves", () => {
expect(base({ summaryState: "NONE", actionsDisabled: true })).toContain(
"disabled",
);
});
});
@@ -66,13 +66,13 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
isAiReadSource,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
@@ -399,6 +399,26 @@ export function DocumentDetailSheet({
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
// Generate the STORED summary from the detail block. Same endpoint as the
// action row, but the result is not routed into the transient panel: the
// route persists a screened-clean summary, so refetching the document shows
// it in place. This runs only on a click — nothing here generates on open.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
summary.reset();
summary.mutate(
{ mode: aiMode, target: aiTarget, output: "summary" },
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
},
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
@@ -440,12 +460,6 @@ export function DocumentDetailSheet({
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
// AI-read provenance — surfaced here (the detail view) as calm muted meta
// since v1.28.38 dropped the vault-card glyph. Same condition the AI section
// uses: a content index exists AND its source is a provider vision/text read.
const aiRead = doc
? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
: false;
return (
<>
@@ -588,34 +602,19 @@ export function DocumentDetailSheet({
</div>
)}
{doc.summary ? (
// Persisted background summary — generated once at upload when the
// auto-read opt-in is on. AI narrative body → foreground content;
// the generation date is muted meta (UI-STANDARDS §3).
<section
className="space-y-1.5"
data-slot="document-detail-summary"
>
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{doc.summary}</p>
{doc.summaryGeneratedAt ? (
<p className="text-muted-foreground text-xs">
{format.date(doc.summaryGeneratedAt)}
</p>
) : null}
</section>
) : autoReadEnabled ? (
// Auto-read is on but no summary yet (still generating, or the
// provider could not read this file) — one calm muted hint.
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
) : null}
<DocumentSummaryBlock
summary={doc.summary}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
? format.date(doc.summaryGeneratedAt)
: null
}
aiEnabled={aiEnabled}
isGenerating={summary.isPending}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
<DocumentAiSection
aiEnabled={aiEnabled}
@@ -829,15 +828,6 @@ export function DocumentDetailSheet({
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{aiRead ? (
<p
data-slot="document-detail-ai-read"
className="text-muted-foreground text-xs"
>
{t("documents.ai.statusAiRead")}
</p>
) : null}
</div>
</div>
) : null}
@@ -0,0 +1,117 @@
"use client";
/**
* The document detail sheet's summary block, lifted out of the sheet so its
* state machine renders without the dialog portal and is pinned by static-render
* tests.
*
* The block exists because a null summary is ambiguous and used to be read as
* one thing: "being generated". It was the fallback for every outcome — a
* document nobody ever enqueued (no backfill reaches uploads that predate the
* auto-read opt-in), a missing vision provider, a spent budget, a withdrawn
* consent, an unreadable file, a provider error, and a summary the outbound
* safety screen withheld. All of them left the column null, so the view told the
* user a job was running that in most cases never existed.
*
* It now branches on the stored `summaryState`:
*
* READY → the stored summary, served from storage, never regenerated
* PENDING → "being generated" — the ONLY state allowed to claim that, and
* it is set when a job was actually enqueued
* WITHHELD → says the summary was blocked, offers another attempt. The
* blocked prose is never shown or stored
* UNAVAILABLE → says none could be produced, offers another attempt
* NONE → says none exists yet, offers to generate one
*
* The action is a click, never an effect: opening a document must not spend a
* provider call. With no AI provider configured the block renders the state
* without an action rather than an button that would 422.
*/
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents";
/** Copy key for a state with no summary to show. */
function absentCopyKey(state: DocumentSummaryStateValue): string {
if (state === "WITHHELD") return "documents.detail.summary.withheld";
if (state === "UNAVAILABLE") return "documents.detail.summary.unavailable";
return "documents.detail.summary.none";
}
export function DocumentSummaryBlock({
summary,
summaryState,
generatedAtLabel,
aiEnabled,
isGenerating,
actionsDisabled,
onGenerate,
}: {
/** The stored, decrypted summary, or null when there is none to show. */
summary: string | null;
summaryState: DocumentSummaryStateValue;
/** Pre-formatted generation date — the sheet owns locale formatting. */
generatedAtLabel: string | null;
/** Whether an AI provider is configured; false hides the action, not the state. */
aiEnabled: boolean;
isGenerating: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslations();
// A stored summary wins over every state: it is shown from storage.
if (summary) {
return (
<section className="space-y-1.5" data-slot="document-detail-summary">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-foreground text-sm">{summary}</p>
{generatedAtLabel ? (
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
) : null}
</section>
);
}
if (summaryState === "PENDING") {
return (
<p
className="text-muted-foreground text-xs"
data-slot="document-detail-summary-pending"
>
{t("documents.detail.summary.pending")}
</p>
);
}
return (
<section className="space-y-2" data-slot="document-detail-summary-absent">
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
<p className="text-muted-foreground text-xs">
{t(absentCopyKey(summaryState))}
</p>
{aiEnabled ? (
<Button
type="button"
variant="outline"
size="sm"
data-slot="document-detail-summary-generate"
onClick={onGenerate}
disabled={isGenerating || actionsDisabled}
>
<FileText className="size-4" aria-hidden />
{isGenerating
? t("documents.detail.summary.generating")
: t("documents.detail.summary.generate")}
</Button>
) : null}
</section>
);
}