fix: localize document summaries and refine mobile coach

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 22:34:56 +02:00
1 parent f1a1887a01
commit 3aa8ccd541
22 files changed
+434 -417

No files matched your search

@@ -55,11 +55,32 @@ describe("DocumentSummaryBlock — a stored summary", () => {
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.
// A stored result stays visible and can be explicitly regenerated.
expect(html).not.toContain("being prepared");
expect(html).toContain('data-slot="document-detail-summary-regenerate"');
expect(html).toContain("Generate again");
const header = html.match(
/<div[^>]*data-slot="document-detail-summary-header"[^>]*>/,
);
expect(header?.[0]).toMatch(/\bflex-col\b/);
expect(header?.[0]).toMatch(/\bsm:flex-row\b/);
expect(html).not.toContain("document-detail-summary-generate");
});
it("disables regeneration while a replacement is being generated", () => {
const html = base({
summary: "Stored prose.",
summaryState: "READY",
isGenerating: true,
});
const action = html.match(
/<button[^>]*data-slot="document-detail-summary-regenerate"[^>]*>/,
);
expect(action?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
expect(html).toContain("Generating");
});
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" });
@@ -260,6 +260,9 @@ export function DocumentDetailSheet({
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
// Stored generation has an independent mutation state so transient
// Summarise/Show text requests cannot change this block's spinner or result.
const storedSummary = useDocumentSummary();
const indexDoc = useIndexDocument();
const [mutationError, setMutationError] = useState<string | null>(null);
@@ -296,6 +299,7 @@ export function DocumentDetailSheet({
useEffect(() => {
suggest.reset();
summary.reset();
storedSummary.reset();
indexDoc.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
@@ -399,17 +403,29 @@ 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.
// Generate or regenerate the STORED summary from the detail block. Only the
// stored-summary branch requests replacement; the empty-state action keeps
// first-write-wins if another worker or tab finishes while AI is running.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
summary.reset();
summary.mutate(
{ mode: aiMode, target: aiTarget, output: "summary" },
storedSummary.reset();
storedSummary.mutate(
{
onSuccess: () => {
mode: aiMode,
target: aiTarget,
output: "summary",
persist: true,
replace: doc?.summary != null,
},
{
onSuccess: (result) => {
if ("summary" in result) {
if (result.persistence === "withheld") {
toast.error(result.summary);
} else if (result.persistence === "failed") {
toast.error(t("documents.detail.saveError"));
}
}
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
@@ -611,7 +627,7 @@ export function DocumentDetailSheet({
: null
}
aiEnabled={aiEnabled}
isGenerating={summary.isPending}
isGenerating={storedSummary.isPending}
actionsDisabled={capability.isPending}
onGenerate={generateStoredSummary}
/>
@@ -27,7 +27,7 @@
* 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 { FileText, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -63,13 +63,42 @@ export function DocumentSummaryBlock({
}) {
const { t } = useTranslations();
// A stored summary wins over every state: it is shown from storage.
// A stored summary wins over every state: it is shown from storage. The
// adjacent action is explicit, so opening the sheet never spends a provider
// call; a successful replacement is persisted by the summary route.
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>
<section className="space-y-2" data-slot="document-detail-summary">
<div
data-slot="document-detail-summary-header"
className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"
>
<p className="text-sm leading-none font-medium">
{t("documents.detail.summary.title")}
</p>
{aiEnabled ? (
<Button
type="button"
variant="ghost"
size="sm"
data-slot="document-detail-summary-regenerate"
onClick={onGenerate}
disabled={isGenerating || actionsDisabled}
>
<RefreshCw
className={
isGenerating
? "size-4 animate-spin motion-reduce:animate-none"
: "size-4"
}
aria-hidden
/>
{isGenerating
? t("documents.detail.summary.generating")
: t("documents.detail.summary.regenerate")}
</Button>
) : null}
</div>
<p className="text-foreground text-sm">{summary}</p>
{generatedAtLabel ? (
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
@@ -30,8 +30,13 @@ import {
type DocumentAiTarget,
} from "./document-ai-transport";
/** The session-only describe result — a summary XOR the raw extracted text. */
export type DocumentDescribeResult = { summary: string } | { text: string };
/** The describe result: raw text, or a summary plus its storage outcome. */
export type DocumentDescribeResult =
| {
summary: string;
persistence?: "stored" | "withheld" | "failed";
}
| { text: string };
/**
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
@@ -95,8 +100,8 @@ export function useSuggestDetails() {
}
/**
* On-demand, session-only summary or extracted text. The result is rendered
* once and never persisted (P2-D4); closing the panel discards it.
* On-demand summary or extracted text. Both stay transient unless the document
* summary block explicitly requests persistence or replacement.
*/
export function useDocumentSummary() {
return useMutation<
@@ -106,11 +111,13 @@ export function useDocumentSummary() {
mode: DocumentAiMode;
target: DocumentAiTarget;
output: DocumentSummaryMode;
persist?: boolean;
replace?: boolean;
}
>({
mutationFn: ({ mode, target, output }) =>
mutationFn: ({ mode, target, output, persist = false, replace = false }) =>
runDocumentAi<DocumentDescribeResult>({
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
mode,
target,
}),