mirror of
https://github.com/temetro/temetro.git
synced 2026-07-28 04:38:57 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user