fix: lock AI proposal cards after Add to prevent duplicate commits

Approval state lived only in each card's local React state, so after
committing (or after reloading a saved conversation) the Add / Review &
add / Import buttons reappeared active and the same records could be
added again. Persist the resolution onto the message data part via a
chat-panel handler (setMessages), and initialize each card's status from
data.resolved so committed/discarded proposals stay locked across
re-render and history reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-18 21:09:09 +03:00
parent f9615fa74e
commit 7bb1ee39ab
6 changed files with 126 additions and 23 deletions
@@ -214,9 +214,23 @@ export function RecordSummary({
// The human approval gate for an agent-proposed add. The agent drafts the record
// (dry run, nothing written); the clinician reviews it here, may edit it, and
// must approve before it is committed via the matching RBAC-gated create endpoint.
export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
export function ActionPreviewCard({
data,
onResolved,
}: {
data: ActionPreviewData;
// Called once committed/discarded so the parent can persist the resolution
// (prevents re-adding after re-render or conversation reload).
onResolved?: (resolution: "added" | "discarded") => void;
}) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>("pending");
const [status, setStatus] = useState<Status>(
data.resolved === "added"
? "done"
: data.resolved === "discarded"
? "rejected"
: "pending",
);
// Editable working copy of the proposed record (edits commit, not the draft).
const [record, setRecord] = useState<Record<string, unknown>>(
data.record as Record<string, unknown>,
@@ -230,6 +244,7 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
try {
await commitAction({ ...data, record });
setStatus("done");
onResolved?.("added");
notify.success(
t("chat.actionCard.addedTitle"),
t(`chat.actionCard.kind.${data.kind}`),
@@ -300,7 +315,10 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
</Button>
<Button
disabled={status === "committing"}
onClick={() => setStatus("rejected")}
onClick={() => {
setStatus("rejected");
onResolved?.("discarded");
}}
size="sm"
variant="outline"
>
@@ -32,11 +32,25 @@ type Status = "pending" | "committing" | "done" | "rejected";
// the full list in a dialog, removes any they don't want, and adds them all at
// once. Each commit goes through the same RBAC-gated create endpoint as the
// single-record card; appointments without a file number create a patient.
export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }) {
export function BatchActionPreviewCard({
items,
onResolved,
}: {
items: ActionPreviewData[];
// Called once committed/discarded so the parent can persist the resolution
// across re-render and conversation reload (prevents re-adding).
onResolved?: (resolution: "added" | "discarded") => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [removed, setRemoved] = useState<Set<string>>(new Set());
const [status, setStatus] = useState<Status>("pending");
const [status, setStatus] = useState<Status>(
items[0]?.resolved === "added"
? "done"
: items[0]?.resolved === "discarded"
? "rejected"
: "pending",
);
const [result, setResult] = useState<{ added: number; failed: number } | null>(
null,
);
@@ -74,6 +88,7 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
setResult({ added, failed });
setStatus("done");
setOpen(false);
onResolved?.("added");
if (added > 0) {
notify.success(
t("chat.actionCard.addedTitle"),
@@ -90,6 +105,7 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
const discardAll = () => {
setStatus("rejected");
setOpen(false);
onResolved?.("discarded");
};
return (
@@ -105,16 +121,19 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
</Badge>
</div>
{status === "done" && result ? (
{status === "done" ? (
<p className="flex items-center gap-1.5 text-foreground text-sm">
<Check className="size-4" />
{t("chat.actionCard.batch.done", {
added: result.added,
total: items.length,
})}
{result.failed > 0
? ` · ${t("chat.actionCard.batch.failedCount", { count: result.failed })}`
: ""}
{result
? `${t("chat.actionCard.batch.done", {
added: result.added,
total: items.length,
})}${
result.failed > 0
? ` · ${t("chat.actionCard.batch.failedCount", { count: result.failed })}`
: ""
}`
: t("chat.actionCard.batch.alreadyAdded")}
</p>
) : status === "rejected" ? (
<p className="text-muted-foreground text-sm">
+39 -2
View File
@@ -154,6 +154,29 @@ export function ChatPanel() {
const { messages, setMessages, sendMessage, status, stop, error } =
useChat<TemetroUIMessage>({ transport });
// Mark a proposal/import card as committed or discarded by stamping the data
// part, so it persists through re-render and conversation reload (and can't be
// submitted twice). `partIndex < 0` marks every action-preview part in the
// message (used by the batched card).
const resolveProposal = useCallback(
(messageId: string, partIndex: number, resolution: "added" | "discarded") => {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== messageId) return m;
const parts = m.parts.map((p, idx) => {
const isTarget =
partIndex < 0 ? p.type === "data-actionPreview" : idx === partIndex;
if (!isTarget) return p;
const data = (p as { data?: Record<string, unknown> }).data;
return data ? { ...p, data: { ...data, resolved: resolution } } : p;
}) as typeof m.parts;
return { ...m, parts };
}),
);
},
[setMessages],
);
// Seed the model + effort from the user's saved AI config so the chat uses the
// provider they actually configured (e.g. their Gemini default), not a stale
// hardcoded default.
@@ -577,7 +600,13 @@ export function ChatPanel() {
return <LabChartCard data={part.data} key={key} />;
}
if (part.type === "data-importPreview") {
return <ImportPreviewCard data={part.data} key={key} />;
return (
<ImportPreviewCard
data={part.data}
key={key}
onResolved={(r) => resolveProposal(message.id, i, r)}
/>
);
}
if (part.type === "data-actionPreview") {
if (actionPreviews.length >= 2) {
@@ -589,10 +618,18 @@ export function ChatPanel() {
(p) => (p as { data: ActionPreviewData }).data,
)}
key={key}
// -1 marks every action-preview part in this message.
onResolved={(r) => resolveProposal(message.id, -1, r)}
/>
);
}
return <ActionPreviewCard data={part.data} key={key} />;
return (
<ActionPreviewCard
data={part.data}
key={key}
onResolved={(r) => resolveProposal(message.id, i, r)}
/>
);
}
if (part.type === "data-appointmentList") {
return (
@@ -111,9 +111,23 @@ function toPatientDraft(rec: unknown): Patient {
// (dry run, nothing written); the clinician reviews counts, can open and edit
// any record (fixing skipped rows), and must approve before anything is
// inserted via POST /api/ai/import.
export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
export function ImportPreviewCard({
data,
onResolved,
}: {
data: ImportPreviewData;
// Called once imported/discarded so the parent can persist the resolution
// across re-render and conversation reload (prevents re-importing).
onResolved?: (resolution: "added" | "discarded") => void;
}) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>("pending");
const [status, setStatus] = useState<Status>(
data.resolved === "added"
? "done"
: data.resolved === "discarded"
? "rejected"
: "pending",
);
const [result, setResult] = useState<{ created: number; failed: number } | null>(
null,
);
@@ -161,6 +175,7 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
setResult({ created: res.created.length, failed: res.failed.length });
setStatus("done");
setReviewOpen(false);
onResolved?.("added");
notify.success(
t("chat.importCard.importedTitle"),
t("chat.importCard.importedBody", { count: res.created.length }),
@@ -216,13 +231,16 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
</p>
) : null}
{status === "done" && result ? (
{status === "done" ? (
<p className="flex items-center gap-1.5 text-sm text-foreground">
<Check className="size-4" />
{t("chat.importCard.importedBody", { count: result.created })}
{result.failed > 0
? ` · ${t("chat.importCard.failedCount", { count: result.failed })}`
: ""}
{result
? `${t("chat.importCard.importedBody", { count: result.created })}${
result.failed > 0
? ` · ${t("chat.importCard.failedCount", { count: result.failed })}`
: ""
}`
: t("chat.importCard.alreadyImported")}
</p>
) : status === "rejected" ? (
<p className="text-sm text-muted-foreground">
@@ -241,7 +259,10 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
</Button>
<Button
disabled={status === "committing"}
onClick={() => setStatus("rejected")}
onClick={() => {
setStatus("rejected");
onResolved?.("discarded");
}}
size="sm"
variant="outline"
>
+6
View File
@@ -26,6 +26,9 @@ export type ImportPreviewData = {
// Skipped rows, with their errors and the original record (for editing).
invalid: { index: number; errors: string[]; record: unknown }[];
total: number;
// Set by the client once imported/discarded so the card stays locked across
// re-render and conversation reload.
resolved?: "added" | "discarded";
};
export type VeilNoticeData = {
@@ -66,6 +69,9 @@ export type ActionPreviewData = {
kind: ActionPreviewKind;
record: unknown;
issues?: string[];
// Set by the client once committed/discarded so the card stays locked across
// re-render and conversation reload (prevents a duplicate add).
resolved?: "added" | "discarded";
};
// Maps each data part name → its payload. Part `type` strings are the key
@@ -1025,6 +1025,7 @@
"edited": "Edited",
"remove": "Remove",
"done": "Added {{added}} of {{total}}.",
"alreadyAdded": "Added.",
"failedCount": "{{count}} failed",
"discarded": "Discarded — nothing was saved."
}
@@ -1087,6 +1088,7 @@
"rejectedNote": "Import discarded. Nothing was written.",
"importedTitle": "Records imported",
"importedBody": "Imported {{count}} patient record(s).",
"alreadyImported": "Imported.",
"failedCount": "{{count}} failed",
"failedTitle": "Import failed",
"failedBody": "The records could not be imported. Please try again."