mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31
This commit is contained in:
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"");
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user