@open-mercato/ui 0.10.1-develop.7657.1.036a08ee9f → 0.10.1-develop.7660.1.c3cfc9211c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/parts/ConfirmationCard.js +33 -11
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/MutationPreviewCard.js +2 -1
- package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +40 -26
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/parts/ConfirmationCard.tsx +37 -14
- package/src/ai/parts/MutationPreviewCard.tsx +9 -4
- package/src/ai/parts/MutationResultCard.tsx +24 -9
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +56 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +38 -0
|
@@ -93,7 +93,7 @@ function ConfirmationCard(props) {
|
|
|
93
93
|
/* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: summary })
|
|
94
94
|
] })
|
|
95
95
|
] }),
|
|
96
|
-
localError ? /* @__PURE__ */ jsx(ConfirmationErrorAlert, { error: localError }) : null,
|
|
96
|
+
localError ? /* @__PURE__ */ jsx(ConfirmationErrorAlert, { error: localError, records: effectiveAction?.records ?? null }) : null,
|
|
97
97
|
/* @__PURE__ */ jsx("div", { className: "mt-3 flex items-center justify-end gap-2", children: /* @__PURE__ */ jsxs(
|
|
98
98
|
Button,
|
|
99
99
|
{
|
|
@@ -115,13 +115,35 @@ function ConfirmationCard(props) {
|
|
|
115
115
|
}
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
|
+
function readStaleRecordIds(extra) {
|
|
119
|
+
if (!extra) return [];
|
|
120
|
+
const ids = [];
|
|
121
|
+
const push = (value) => {
|
|
122
|
+
if (typeof value === "string" && value.length > 0 && !ids.includes(value)) ids.push(value);
|
|
123
|
+
};
|
|
124
|
+
if (Array.isArray(extra.failedRecords)) {
|
|
125
|
+
for (const entry of extra.failedRecords) {
|
|
126
|
+
push(entry && typeof entry === "object" ? entry.recordId : null);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(extra.staleRecords)) {
|
|
130
|
+
for (const entry of extra.staleRecords) push(entry);
|
|
131
|
+
}
|
|
132
|
+
push(extra.recordId);
|
|
133
|
+
return ids;
|
|
134
|
+
}
|
|
118
135
|
function ConfirmationErrorAlert({
|
|
119
|
-
error
|
|
136
|
+
error,
|
|
137
|
+
records
|
|
120
138
|
}) {
|
|
121
139
|
const t = useT();
|
|
122
140
|
const code = error.code ?? "unknown";
|
|
123
141
|
if (code === "stale_version") {
|
|
124
|
-
const
|
|
142
|
+
const labelById = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const record of records ?? []) {
|
|
144
|
+
if (record?.recordId && record.label) labelById.set(record.recordId, record.label);
|
|
145
|
+
}
|
|
146
|
+
const staleRecordIds = readStaleRecordIds(error.extra);
|
|
125
147
|
return /* @__PURE__ */ jsxs(
|
|
126
148
|
Alert,
|
|
127
149
|
{
|
|
@@ -138,14 +160,13 @@ function ConfirmationErrorAlert({
|
|
|
138
160
|
"ai_assistant.chat.mutation_cards.confirmation.staleVersionBody",
|
|
139
161
|
"One or more records changed since this preview was generated. Ask the assistant to re-propose the change."
|
|
140
162
|
) }),
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
)) }) : null
|
|
163
|
+
staleRecordIds.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "mt-2 list-disc pl-5 text-xs", children: staleRecordIds.map((recordId) => {
|
|
164
|
+
const label = labelById.get(recordId);
|
|
165
|
+
return /* @__PURE__ */ jsxs("li", { "data-ai-confirmation-stale-record": recordId, children: [
|
|
166
|
+
label ? /* @__PURE__ */ jsx("span", { className: "mr-1", children: label }) : null,
|
|
167
|
+
/* @__PURE__ */ jsx("span", { className: "font-mono", children: recordId })
|
|
168
|
+
] }, recordId);
|
|
169
|
+
}) }) : null
|
|
149
170
|
] })
|
|
150
171
|
]
|
|
151
172
|
}
|
|
@@ -202,6 +223,7 @@ function ConfirmationErrorAlert({
|
|
|
202
223
|
var ConfirmationCard_default = ConfirmationCard;
|
|
203
224
|
export {
|
|
204
225
|
ConfirmationCard,
|
|
226
|
+
ConfirmationErrorAlert,
|
|
205
227
|
ConfirmationCard_default as default
|
|
206
228
|
};
|
|
207
229
|
//# sourceMappingURL=ConfirmationCard.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/ai/parts/ConfirmationCard.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Loader2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport { Spinner } from '../../primitives/spinner'\nimport { useAiShortcuts } from '../useAiShortcuts'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { cancelPendingAction } from './pending-action-api'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport { MutationResultCard } from './MutationResultCard'\nimport type { AiPendingActionCardAction } from './types'\n\n/**\n * Confirmation / in-flight card rendered after the user clicks `Confirm` on\n * the preview card. Shows a spinner + the side-effects summary while the\n * server runs the re-check contract and executes the wrapped tool.\n *\n * The user can race the confirm by clicking Cancel \u2014 but only while the\n * server has not yet flipped the row to `executing`. The polling hook\n * drives the disable logic.\n *\n * Surfaces structured error envelopes from the confirm route: 412\n * `stale_version` (records changed since preview) and 412 `schema_drift`\n * (tool input schema changed) render targeted alerts with the specific\n * recovery copy. Keyboard: `Escape` triggers Cancel; `Cmd/Ctrl+Enter` is\n * intentionally inert (the user already confirmed).\n *\n * Terminal states flip this card into a {@link MutationResultCard} render\n * so the chat transcript does not need two separate cards for confirmed\n * vs pending.\n */\nexport interface ConfirmationCardPayload {\n sideEffectsSummary?: string | null\n pendingAction?: AiPendingActionCardAction\n confirmError?: {\n status: number\n code?: string\n message: string\n extra?: Record<string, unknown>\n }\n}\n\nexport interface ConfirmationCardProps extends AiUiPartProps {\n /** Optional override for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Endpoint override (tests). */\n endpoint?: string\n /** Optional cancel handler override (tests). */\n onCancel?: () => Promise<void> | void\n}\n\nexport function ConfirmationCard(props: ConfirmationCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as ConfirmationCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action, status, refresh } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId,\n })\n\n const effectiveAction = action ?? injected ?? null\n const effectiveStatus = effectiveAction?.status ?? status\n\n const [isCancelling, setIsCancelling] = React.useState(false)\n const [localError, setLocalError] = React.useState<{\n code?: string\n message: string\n extra?: Record<string, unknown>\n } | null>(\n payload.confirmError\n ? { code: payload.confirmError.code, message: payload.confirmError.message, extra: payload.confirmError.extra }\n : null,\n )\n\n const canCancel =\n !isCancelling &&\n (effectiveStatus === 'pending' || effectiveStatus === 'confirmed' || effectiveStatus == null)\n\n const handleCancel = React.useCallback(async () => {\n if (!canCancel) return\n if (props.onCancel) {\n await props.onCancel()\n return\n }\n if (!pendingActionId) return\n setIsCancelling(true)\n try {\n const result = await cancelPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n setLocalError({\n code: result.error.code,\n message: result.error.message,\n extra: result.error.extra,\n })\n }\n await refresh()\n } finally {\n setIsCancelling(false)\n }\n }, [canCancel, pendingActionId, props, refresh])\n\n const { handleKeyDown } = useAiShortcuts({\n onCancel: () => {\n void handleCancel()\n },\n enabled: canCancel,\n })\n\n // Terminal states \u2014 hand off to the result card renderer. Also short-\n // circuit when the dispatcher has already populated an\n // `executionResult.error`: the row may not have transitioned out of\n // `executing` in the latest polling snapshot, but the handler error is\n // authoritative, and leaving the spinner up while a known error is\n // available is exactly the \"stalled at processing\" symptom the user\n // reported. Surface the error card immediately.\n const executionError = effectiveAction?.executionResult?.error\n if (\n effectiveStatus === 'confirmed' ||\n effectiveStatus === 'failed' ||\n effectiveStatus === 'cancelled' ||\n effectiveStatus === 'expired' ||\n executionError\n ) {\n return (\n <MutationResultCard\n componentId=\"mutation-result-card\"\n pendingActionId={pendingActionId}\n initialAction={effectiveAction ?? undefined}\n endpoint={props.endpoint}\n />\n )\n }\n\n const summary =\n effectiveAction?.sideEffectsSummary ??\n payload.sideEffectsSummary ??\n t(\n 'ai_assistant.chat.mutation_cards.confirmation.defaultSummary',\n 'Applying the requested changes...',\n )\n\n return (\n <section\n className=\"rounded-md border border-border bg-muted/30 p-4 text-sm outline-none\"\n tabIndex={0}\n onKeyDown={handleKeyDown}\n data-ai-confirmation-card\n data-ai-confirmation-status={effectiveStatus ?? 'pending'}\n aria-busy\n >\n <div className=\"flex items-start gap-3\">\n <Spinner size=\"sm\" className=\"mt-0.5\" />\n <div className=\"flex-1\">\n <h4 className=\"text-sm font-semibold\">\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.title',\n 'Applying action...',\n )}\n </h4>\n <p className=\"mt-1 text-sm text-muted-foreground\">{summary}</p>\n </div>\n </div>\n\n {localError ? (\n <ConfirmationErrorAlert error={localError} />\n ) : null}\n\n <div className=\"mt-3 flex items-center justify-end gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n void handleCancel()\n }}\n disabled={!canCancel}\n data-ai-confirmation-cancel\n >\n {isCancelling ? (\n <Loader2 className=\"size-4 animate-spin\" aria-hidden />\n ) : null}\n <span>\n {t('ai_assistant.chat.mutation_cards.confirmation.cancel', 'Cancel')}\n </span>\n </Button>\n </div>\n </section>\n )\n}\n\nfunction ConfirmationErrorAlert({\n error,\n}: {\n error: { code?: string; message: string; extra?: Record<string, unknown> }\n}) {\n const t = useT()\n const code = error.code ?? 'unknown'\n\n if (code === 'stale_version') {\n const failedRecords = Array.isArray(error.extra?.failedRecords)\n ? (error.extra?.failedRecords as Array<{ recordId?: string }>)\n : []\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"stale_version\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle',\n 'Re-propose required',\n )}\n </AlertTitle>\n <AlertDescription>\n <p>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody',\n 'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',\n )}\n </p>\n {failedRecords.length > 0 ? (\n <ul className=\"mt-2 list-disc pl-5 text-xs\">\n {failedRecords.map((record, idx) => (\n <li\n key={`${record.recordId ?? idx}`}\n data-ai-confirmation-stale-record={record.recordId ?? ''}\n >\n <span className=\"font-mono\">{record.recordId ?? '\u2014'}</span>\n </li>\n ))}\n </ul>\n ) : null}\n </AlertDescription>\n </Alert>\n )\n }\n\n if (code === 'schema_drift') {\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"schema_drift\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle',\n 'Schema changed',\n )}\n </AlertTitle>\n <AlertDescription>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftBody',\n 'The tool signature changed since this preview was generated. Ask the assistant to re-propose the change.',\n )}\n </AlertDescription>\n </Alert>\n )\n }\n\n if (code === 'invalid_status') {\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"invalid_status\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle',\n 'Action already resolved',\n )}\n </AlertTitle>\n <AlertDescription>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusBody',\n 'This action has already been confirmed, cancelled, or executed.',\n )}\n </AlertDescription>\n </Alert>\n )\n }\n\n return (\n <Alert status=\"error\" className=\"mt-3\" data-ai-confirmation-error={code}>\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}\n </AlertTitle>\n <AlertDescription>\n <span className=\"mr-2 font-mono text-xs\">{code}</span>\n <span>{error.message}</span>\n </AlertDescription>\n </Alert>\n )\n}\n\nexport default ConfirmationCard\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Loader2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport { Spinner } from '../../primitives/spinner'\nimport { useAiShortcuts } from '../useAiShortcuts'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { cancelPendingAction } from './pending-action-api'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport { MutationResultCard } from './MutationResultCard'\nimport type { AiPendingActionCardAction } from './types'\n\n/**\n * Confirmation / in-flight card rendered after the user clicks `Confirm` on\n * the preview card. Shows a spinner + the side-effects summary while the\n * server runs the re-check contract and executes the wrapped tool.\n *\n * The user can race the confirm by clicking Cancel \u2014 but only while the\n * server has not yet flipped the row to `executing`. The polling hook\n * drives the disable logic.\n *\n * Surfaces structured error envelopes from the confirm route: 412\n * `stale_version` (records changed since preview) and 412 `schema_drift`\n * (tool input schema changed) render targeted alerts with the specific\n * recovery copy. Keyboard: `Escape` triggers Cancel; `Cmd/Ctrl+Enter` is\n * intentionally inert (the user already confirmed).\n *\n * Terminal states flip this card into a {@link MutationResultCard} render\n * so the chat transcript does not need two separate cards for confirmed\n * vs pending.\n */\nexport interface ConfirmationCardPayload {\n sideEffectsSummary?: string | null\n pendingAction?: AiPendingActionCardAction\n confirmError?: {\n status: number\n code?: string\n message: string\n extra?: Record<string, unknown>\n }\n}\n\nexport interface ConfirmationCardProps extends AiUiPartProps {\n /** Optional override for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Endpoint override (tests). */\n endpoint?: string\n /** Optional cancel handler override (tests). */\n onCancel?: () => Promise<void> | void\n}\n\nexport function ConfirmationCard(props: ConfirmationCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as ConfirmationCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action, status, refresh } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId,\n })\n\n const effectiveAction = action ?? injected ?? null\n const effectiveStatus = effectiveAction?.status ?? status\n\n const [isCancelling, setIsCancelling] = React.useState(false)\n const [localError, setLocalError] = React.useState<{\n code?: string\n message: string\n extra?: Record<string, unknown>\n } | null>(\n payload.confirmError\n ? { code: payload.confirmError.code, message: payload.confirmError.message, extra: payload.confirmError.extra }\n : null,\n )\n\n const canCancel =\n !isCancelling &&\n (effectiveStatus === 'pending' || effectiveStatus === 'confirmed' || effectiveStatus == null)\n\n const handleCancel = React.useCallback(async () => {\n if (!canCancel) return\n if (props.onCancel) {\n await props.onCancel()\n return\n }\n if (!pendingActionId) return\n setIsCancelling(true)\n try {\n const result = await cancelPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n setLocalError({\n code: result.error.code,\n message: result.error.message,\n extra: result.error.extra,\n })\n }\n await refresh()\n } finally {\n setIsCancelling(false)\n }\n }, [canCancel, pendingActionId, props, refresh])\n\n const { handleKeyDown } = useAiShortcuts({\n onCancel: () => {\n void handleCancel()\n },\n enabled: canCancel,\n })\n\n // Terminal states \u2014 hand off to the result card renderer. Also short-\n // circuit when the dispatcher has already populated an\n // `executionResult.error`: the row may not have transitioned out of\n // `executing` in the latest polling snapshot, but the handler error is\n // authoritative, and leaving the spinner up while a known error is\n // available is exactly the \"stalled at processing\" symptom the user\n // reported. Surface the error card immediately.\n const executionError = effectiveAction?.executionResult?.error\n if (\n effectiveStatus === 'confirmed' ||\n effectiveStatus === 'failed' ||\n effectiveStatus === 'cancelled' ||\n effectiveStatus === 'expired' ||\n executionError\n ) {\n return (\n <MutationResultCard\n componentId=\"mutation-result-card\"\n pendingActionId={pendingActionId}\n initialAction={effectiveAction ?? undefined}\n endpoint={props.endpoint}\n />\n )\n }\n\n const summary =\n effectiveAction?.sideEffectsSummary ??\n payload.sideEffectsSummary ??\n t(\n 'ai_assistant.chat.mutation_cards.confirmation.defaultSummary',\n 'Applying the requested changes...',\n )\n\n return (\n <section\n className=\"rounded-md border border-border bg-muted/30 p-4 text-sm outline-none\"\n tabIndex={0}\n onKeyDown={handleKeyDown}\n data-ai-confirmation-card\n data-ai-confirmation-status={effectiveStatus ?? 'pending'}\n aria-busy\n >\n <div className=\"flex items-start gap-3\">\n <Spinner size=\"sm\" className=\"mt-0.5\" />\n <div className=\"flex-1\">\n <h4 className=\"text-sm font-semibold\">\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.title',\n 'Applying action...',\n )}\n </h4>\n <p className=\"mt-1 text-sm text-muted-foreground\">{summary}</p>\n </div>\n </div>\n\n {localError ? (\n <ConfirmationErrorAlert error={localError} records={effectiveAction?.records ?? null} />\n ) : null}\n\n <div className=\"mt-3 flex items-center justify-end gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n void handleCancel()\n }}\n disabled={!canCancel}\n data-ai-confirmation-cancel\n >\n {isCancelling ? (\n <Loader2 className=\"size-4 animate-spin\" aria-hidden />\n ) : null}\n <span>\n {t('ai_assistant.chat.mutation_cards.confirmation.cancel', 'Cancel')}\n </span>\n </Button>\n </div>\n </section>\n )\n}\n\nfunction readStaleRecordIds(extra: Record<string, unknown> | undefined): string[] {\n if (!extra) return []\n const ids: string[] = []\n const push = (value: unknown) => {\n if (typeof value === 'string' && value.length > 0 && !ids.includes(value)) ids.push(value)\n }\n if (Array.isArray(extra.failedRecords)) {\n for (const entry of extra.failedRecords) {\n push(entry && typeof entry === 'object' ? (entry as { recordId?: unknown }).recordId : null)\n }\n }\n if (Array.isArray(extra.staleRecords)) {\n for (const entry of extra.staleRecords) push(entry)\n }\n push(extra.recordId)\n return ids\n}\n\nexport function ConfirmationErrorAlert({\n error,\n records,\n}: {\n error: { code?: string; message: string; extra?: Record<string, unknown> }\n records?: AiPendingActionCardAction['records']\n}) {\n const t = useT()\n const code = error.code ?? 'unknown'\n\n if (code === 'stale_version') {\n const labelById = new Map<string, string>()\n for (const record of records ?? []) {\n if (record?.recordId && record.label) labelById.set(record.recordId, record.label)\n }\n const staleRecordIds = readStaleRecordIds(error.extra)\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"stale_version\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle',\n 'Re-propose required',\n )}\n </AlertTitle>\n <AlertDescription>\n <p>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody',\n 'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',\n )}\n </p>\n {staleRecordIds.length > 0 ? (\n <ul className=\"mt-2 list-disc pl-5 text-xs\">\n {staleRecordIds.map((recordId) => {\n const label = labelById.get(recordId)\n return (\n <li key={recordId} data-ai-confirmation-stale-record={recordId}>\n {label ? <span className=\"mr-1\">{label}</span> : null}\n <span className=\"font-mono\">{recordId}</span>\n </li>\n )\n })}\n </ul>\n ) : null}\n </AlertDescription>\n </Alert>\n )\n }\n\n if (code === 'schema_drift') {\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"schema_drift\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle',\n 'Schema changed',\n )}\n </AlertTitle>\n <AlertDescription>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftBody',\n 'The tool signature changed since this preview was generated. Ask the assistant to re-propose the change.',\n )}\n </AlertDescription>\n </Alert>\n )\n }\n\n if (code === 'invalid_status') {\n return (\n <Alert\n status=\"warning\"\n className=\"mt-3\"\n data-ai-confirmation-error=\"invalid_status\"\n >\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle',\n 'Action already resolved',\n )}\n </AlertTitle>\n <AlertDescription>\n {t(\n 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusBody',\n 'This action has already been confirmed, cancelled, or executed.',\n )}\n </AlertDescription>\n </Alert>\n )\n }\n\n return (\n <Alert status=\"error\" className=\"mt-3\" data-ai-confirmation-error={code}>\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}\n </AlertTitle>\n <AlertDescription>\n <span className=\"mr-2 font-mono text-xs\">{code}</span>\n <span>{error.message}</span>\n </AlertDescription>\n </Alert>\n )\n}\n\nexport default ConfirmationCard\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,7 +8,7 @@ import { useAiShortcuts } from "../useAiShortcuts.js";
|
|
|
8
8
|
import { confirmPendingAction, cancelPendingAction } from "./pending-action-api.js";
|
|
9
9
|
import { useAiPendingActionPolling } from "./useAiPendingActionPolling.js";
|
|
10
10
|
import { FieldDiffCard } from "./FieldDiffCard.js";
|
|
11
|
-
import { ConfirmationCard } from "./ConfirmationCard.js";
|
|
11
|
+
import { ConfirmationCard, ConfirmationErrorAlert } from "./ConfirmationCard.js";
|
|
12
12
|
import { MutationResultCard } from "./MutationResultCard.js";
|
|
13
13
|
function summarizeBatch(records) {
|
|
14
14
|
const labels = records.slice(0, 3).map((record) => record.label);
|
|
@@ -160,6 +160,7 @@ function MutationPreviewCard(props) {
|
|
|
160
160
|
records: action?.records ?? null
|
|
161
161
|
}
|
|
162
162
|
) }) : null,
|
|
163
|
+
confirmError ? /* @__PURE__ */ jsx(ConfirmationErrorAlert, { error: confirmError, records: action?.records ?? null }) : null,
|
|
163
164
|
/* @__PURE__ */ jsxs("div", { className: "mt-3 flex items-center justify-between gap-2", children: [
|
|
164
165
|
hasReviewDetails ? /* @__PURE__ */ jsxs(
|
|
165
166
|
Button,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/ai/parts/MutationPreviewCard.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { ChevronDown, Eye, ShieldAlert } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { useAiShortcuts } from '../useAiShortcuts'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { confirmPendingAction, cancelPendingAction } from './pending-action-api'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport { FieldDiffCard } from './FieldDiffCard'\nimport { ConfirmationCard } from './ConfirmationCard'\nimport { MutationResultCard } from './MutationResultCard'\nimport type { AiPendingActionCardAction } from './types'\n\n/**\n * Mutation-approval preview card. Rendered by the server-emitted\n * `mutation-preview-card` UI part (spec \u00A79.2).\n *\n * Responsibilities:\n * - Fetch the current pending-action row via the shared polling hook so the\n * card recovers state on page reload (reconnect behavior).\n * - Render the top-level `fieldDiff` OR a per-record `records[]` summary\n * with a drill-in link.\n * - Provide `Confirm` / `Cancel` / `Review Details` actions with shared\n * keyboard shortcuts (`Cmd/Ctrl+Enter` \u2192 confirm, `Escape` \u2192 cancel).\n * - Flip to the {@link ConfirmationCard} once the user confirms, and\n * further to the {@link MutationResultCard} once the row becomes\n * terminal.\n */\nexport interface MutationPreviewCardPayload {\n /** Optional server-serialized pending action snapshot for the initial render. */\n pendingAction?: AiPendingActionCardAction\n}\n\nexport interface MutationPreviewCardProps extends AiUiPartProps {\n /** Optional injected action for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Endpoint base override for tests. */\n endpoint?: string\n}\n\nfunction summarizeBatch(\n records: NonNullable<AiPendingActionCardAction['records']>,\n): { count: number; labels: string[] } {\n const labels = records.slice(0, 3).map((record) => record.label)\n return { count: records.length, labels }\n}\n\nexport function MutationPreviewCard(props: MutationPreviewCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as MutationPreviewCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action: polled, refresh } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId,\n })\n const action = polled ?? injected\n\n const [expanded, setExpanded] = React.useState(false)\n const [phase, setPhase] = React.useState<'preview' | 'confirming'>('preview')\n const [confirmError, setConfirmError] = React.useState<{\n status: number\n code?: string\n message: string\n extra?: Record<string, unknown>\n } | null>(null)\n\n const handleConfirm = React.useCallback(async () => {\n if (!pendingActionId) return\n if (phase !== 'preview') return\n setPhase('confirming')\n setConfirmError(null)\n const result = await confirmPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n // Network / timeout / 4xx / 5xx \u2014 surface the envelope and rewind the\n // card to the preview phase so the operator can read the error,\n // edit the proposal upstream, or retry.\n setConfirmError(result.error)\n setPhase('preview')\n await refresh()\n return\n }\n // HTTP 200 path. The dispatcher returns `ok: false` AND a populated\n // `mutationResult.error` when the wrapped tool handler failed inside\n // the confirm route \u2014 the row is already in a terminal state but the\n // overall HTTP call succeeded. Treat that as a confirm error too so\n // the alert renders inline instead of leaving the card on the\n // generic \"applying\u2026\" spinner forever.\n const handlerError = result.data?.mutationResult?.error\n if (result.data?.ok === false || handlerError) {\n const mappedCode =\n typeof handlerError?.code === 'string' && handlerError.code.length > 0\n ? handlerError.code\n : 'execution_failed'\n setConfirmError({\n status: 200,\n code: mappedCode,\n message:\n handlerError?.message ??\n t(\n 'ai_assistant.chat.mutation_cards.preview.handlerError',\n 'The mutation handler reported an error. Review the details and re-propose if needed.',\n ),\n })\n }\n await refresh()\n }, [pendingActionId, phase, props.endpoint, refresh, t])\n\n const handleCancel = React.useCallback(async () => {\n if (!pendingActionId) return\n const result = await cancelPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n setConfirmError(result.error)\n }\n await refresh()\n }, [pendingActionId, props.endpoint, refresh])\n\n const currentStatus = action?.status ?? null\n const executionError = action?.executionResult?.error\n // Treat any captured handler error as terminal too \u2014 the dispatcher\n // sometimes returns the action before the row has fully transitioned\n // out of `executing`, and we must never leave the spinner masking a\n // real failure. The MutationResultCard's failure path renders the\n // error envelope as long as `executionResult.error` is set.\n const isTerminal =\n currentStatus === 'confirmed' ||\n currentStatus === 'failed' ||\n currentStatus === 'cancelled' ||\n currentStatus === 'expired' ||\n Boolean(executionError)\n\n const { handleKeyDown } = useAiShortcuts({\n onSubmit: () => {\n void handleConfirm()\n },\n onCancel: () => {\n void handleCancel()\n },\n enabled: phase === 'preview' && !isTerminal,\n })\n\n // Terminal \u2014 short-circuit into the result card.\n if (isTerminal && action) {\n return (\n <MutationResultCard\n componentId=\"mutation-result-card\"\n pendingActionId={pendingActionId}\n initialAction={action}\n endpoint={props.endpoint}\n />\n )\n }\n\n // Confirming \u2014 flip to the spinner card. Propagate the confirmError so\n // the user sees the structured envelope even though the confirm call has\n // already resolved.\n if (phase === 'confirming') {\n return (\n <ConfirmationCard\n componentId=\"confirmation-card\"\n pendingActionId={pendingActionId}\n initialAction={action ?? undefined}\n endpoint={props.endpoint}\n payload={{\n sideEffectsSummary: action?.sideEffectsSummary ?? null,\n pendingAction: action ?? undefined,\n confirmError: confirmError ?? undefined,\n }}\n />\n )\n }\n\n const batch = Array.isArray(action?.records) && action!.records!.length > 0 ? action!.records! : null\n const summary = batch ? summarizeBatch(batch) : null\n const hasReviewDetails = Boolean(batch)\n\n return (\n <section\n className=\"rounded-md border border-border bg-background p-4 text-sm outline-none\"\n tabIndex={0}\n onKeyDown={handleKeyDown}\n data-ai-mutation-preview\n data-ai-mutation-preview-mode={batch ? 'batch' : 'single'}\n >\n <header className=\"flex items-start justify-between gap-3\">\n <div className=\"flex items-start gap-2\">\n <ShieldAlert className=\"mt-0.5 size-4 text-status-warning-icon\" aria-hidden />\n <div>\n <h4 className=\"text-sm font-semibold\">\n {t(\n 'ai_assistant.chat.mutation_cards.preview.title',\n 'Review proposed changes',\n )}\n </h4>\n {action?.sideEffectsSummary ? (\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {action.sideEffectsSummary}\n </p>\n ) : null}\n </div>\n </div>\n </header>\n\n <div className=\"mt-3\" data-ai-mutation-preview-body>\n {summary ? (\n <div\n className=\"rounded-md border border-border bg-muted/30 p-3 text-sm\"\n data-ai-mutation-preview-batch-summary\n >\n <p className=\"font-medium\">\n {t(\n 'ai_assistant.chat.mutation_cards.preview.batchSummary',\n 'Batch update',\n )}\n {': '}\n <span data-ai-mutation-preview-count>{summary.count}</span>{' '}\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.preview.batchRecords',\n 'records',\n )}\n </span>\n </p>\n {summary.labels.length > 0 ? (\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {summary.labels.join(', ')}\n {summary.count > summary.labels.length\n ? ` +${summary.count - summary.labels.length}`\n : ''}\n </p>\n ) : null}\n </div>\n ) : (\n <FieldDiffCard fieldDiff={action?.fieldDiff ?? null} />\n )}\n </div>\n\n {expanded && hasReviewDetails ? (\n <div className=\"mt-3 rounded-md border border-border bg-muted/20 p-3\" data-ai-mutation-preview-details>\n <FieldDiffCard\n fieldDiff={action?.fieldDiff ?? null}\n records={action?.records ?? null}\n />\n </div>\n ) : null}\n\n <div className=\"mt-3 flex items-center justify-between gap-2\">\n {hasReviewDetails ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setExpanded((value) => !value)}\n data-ai-mutation-preview-review\n >\n <Eye className=\"size-4\" aria-hidden />\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.preview.reviewDetails',\n 'Review details',\n )}\n </span>\n <ChevronDown\n className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}\n aria-hidden\n />\n </Button>\n ) : (\n <span aria-hidden />\n )}\n <div className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n void handleCancel()\n }}\n data-ai-mutation-preview-cancel\n >\n {t('ai_assistant.chat.mutation_cards.preview.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => {\n void handleConfirm()\n }}\n data-ai-mutation-preview-confirm\n >\n {t('ai_assistant.chat.mutation_cards.preview.confirm', 'Confirm')}\n </Button>\n </div>\n </div>\n </section>\n )\n}\n\nexport default MutationPreviewCard\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { ChevronDown, Eye, ShieldAlert } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { useAiShortcuts } from '../useAiShortcuts'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { confirmPendingAction, cancelPendingAction } from './pending-action-api'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport { FieldDiffCard } from './FieldDiffCard'\nimport { ConfirmationCard, ConfirmationErrorAlert } from './ConfirmationCard'\nimport { MutationResultCard } from './MutationResultCard'\nimport type { AiPendingActionCardAction } from './types'\n\n/**\n * Mutation-approval preview card. Rendered by the server-emitted\n * `mutation-preview-card` UI part (spec \u00A79.2).\n *\n * Responsibilities:\n * - Fetch the current pending-action row via the shared polling hook so the\n * card recovers state on page reload (reconnect behavior).\n * - Render the top-level `fieldDiff` OR a per-record `records[]` summary\n * with a drill-in link.\n * - Provide `Confirm` / `Cancel` / `Review Details` actions with shared\n * keyboard shortcuts (`Cmd/Ctrl+Enter` \u2192 confirm, `Escape` \u2192 cancel).\n * - Flip to the {@link ConfirmationCard} once the user confirms, and\n * further to the {@link MutationResultCard} once the row becomes\n * terminal.\n */\nexport interface MutationPreviewCardPayload {\n /** Optional server-serialized pending action snapshot for the initial render. */\n pendingAction?: AiPendingActionCardAction\n}\n\nexport interface MutationPreviewCardProps extends AiUiPartProps {\n /** Optional injected action for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Endpoint base override for tests. */\n endpoint?: string\n}\n\nfunction summarizeBatch(\n records: NonNullable<AiPendingActionCardAction['records']>,\n): { count: number; labels: string[] } {\n const labels = records.slice(0, 3).map((record) => record.label)\n return { count: records.length, labels }\n}\n\nexport function MutationPreviewCard(props: MutationPreviewCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as MutationPreviewCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action: polled, refresh } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId,\n })\n const action = polled ?? injected\n\n const [expanded, setExpanded] = React.useState(false)\n const [phase, setPhase] = React.useState<'preview' | 'confirming'>('preview')\n const [confirmError, setConfirmError] = React.useState<{\n status: number\n code?: string\n message: string\n extra?: Record<string, unknown>\n } | null>(null)\n\n const handleConfirm = React.useCallback(async () => {\n if (!pendingActionId) return\n if (phase !== 'preview') return\n setPhase('confirming')\n setConfirmError(null)\n const result = await confirmPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n // Network / timeout / 4xx / 5xx \u2014 rewind the card to the preview\n // phase, which renders the envelope inline (e.g. the 412\n // `stale_version` refusal with the stale rows) so the operator can\n // read the error, ask for a re-proposal, or retry.\n setConfirmError(result.error)\n setPhase('preview')\n await refresh()\n return\n }\n // HTTP 200 path. The dispatcher returns `ok: false` AND a populated\n // `mutationResult.error` when the wrapped tool handler failed inside\n // the confirm route \u2014 the row is already in a terminal state but the\n // overall HTTP call succeeded. Treat that as a confirm error too so\n // the alert renders inline instead of leaving the card on the\n // generic \"applying\u2026\" spinner forever.\n const handlerError = result.data?.mutationResult?.error\n if (result.data?.ok === false || handlerError) {\n const mappedCode =\n typeof handlerError?.code === 'string' && handlerError.code.length > 0\n ? handlerError.code\n : 'execution_failed'\n setConfirmError({\n status: 200,\n code: mappedCode,\n message:\n handlerError?.message ??\n t(\n 'ai_assistant.chat.mutation_cards.preview.handlerError',\n 'The mutation handler reported an error. Review the details and re-propose if needed.',\n ),\n })\n }\n await refresh()\n }, [pendingActionId, phase, props.endpoint, refresh, t])\n\n const handleCancel = React.useCallback(async () => {\n if (!pendingActionId) return\n const result = await cancelPendingAction(pendingActionId, {\n endpoint: props.endpoint,\n })\n if (!result.ok) {\n setConfirmError(result.error)\n }\n await refresh()\n }, [pendingActionId, props.endpoint, refresh])\n\n const currentStatus = action?.status ?? null\n const executionError = action?.executionResult?.error\n // Treat any captured handler error as terminal too \u2014 the dispatcher\n // sometimes returns the action before the row has fully transitioned\n // out of `executing`, and we must never leave the spinner masking a\n // real failure. The MutationResultCard's failure path renders the\n // error envelope as long as `executionResult.error` is set.\n const isTerminal =\n currentStatus === 'confirmed' ||\n currentStatus === 'failed' ||\n currentStatus === 'cancelled' ||\n currentStatus === 'expired' ||\n Boolean(executionError)\n\n const { handleKeyDown } = useAiShortcuts({\n onSubmit: () => {\n void handleConfirm()\n },\n onCancel: () => {\n void handleCancel()\n },\n enabled: phase === 'preview' && !isTerminal,\n })\n\n // Terminal \u2014 short-circuit into the result card.\n if (isTerminal && action) {\n return (\n <MutationResultCard\n componentId=\"mutation-result-card\"\n pendingActionId={pendingActionId}\n initialAction={action}\n endpoint={props.endpoint}\n />\n )\n }\n\n // Confirming \u2014 flip to the spinner card. Propagate the confirmError so\n // the user sees the structured envelope even though the confirm call has\n // already resolved.\n if (phase === 'confirming') {\n return (\n <ConfirmationCard\n componentId=\"confirmation-card\"\n pendingActionId={pendingActionId}\n initialAction={action ?? undefined}\n endpoint={props.endpoint}\n payload={{\n sideEffectsSummary: action?.sideEffectsSummary ?? null,\n pendingAction: action ?? undefined,\n confirmError: confirmError ?? undefined,\n }}\n />\n )\n }\n\n const batch = Array.isArray(action?.records) && action!.records!.length > 0 ? action!.records! : null\n const summary = batch ? summarizeBatch(batch) : null\n const hasReviewDetails = Boolean(batch)\n\n return (\n <section\n className=\"rounded-md border border-border bg-background p-4 text-sm outline-none\"\n tabIndex={0}\n onKeyDown={handleKeyDown}\n data-ai-mutation-preview\n data-ai-mutation-preview-mode={batch ? 'batch' : 'single'}\n >\n <header className=\"flex items-start justify-between gap-3\">\n <div className=\"flex items-start gap-2\">\n <ShieldAlert className=\"mt-0.5 size-4 text-status-warning-icon\" aria-hidden />\n <div>\n <h4 className=\"text-sm font-semibold\">\n {t(\n 'ai_assistant.chat.mutation_cards.preview.title',\n 'Review proposed changes',\n )}\n </h4>\n {action?.sideEffectsSummary ? (\n <p className=\"mt-1 text-sm text-muted-foreground\">\n {action.sideEffectsSummary}\n </p>\n ) : null}\n </div>\n </div>\n </header>\n\n <div className=\"mt-3\" data-ai-mutation-preview-body>\n {summary ? (\n <div\n className=\"rounded-md border border-border bg-muted/30 p-3 text-sm\"\n data-ai-mutation-preview-batch-summary\n >\n <p className=\"font-medium\">\n {t(\n 'ai_assistant.chat.mutation_cards.preview.batchSummary',\n 'Batch update',\n )}\n {': '}\n <span data-ai-mutation-preview-count>{summary.count}</span>{' '}\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.preview.batchRecords',\n 'records',\n )}\n </span>\n </p>\n {summary.labels.length > 0 ? (\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {summary.labels.join(', ')}\n {summary.count > summary.labels.length\n ? ` +${summary.count - summary.labels.length}`\n : ''}\n </p>\n ) : null}\n </div>\n ) : (\n <FieldDiffCard fieldDiff={action?.fieldDiff ?? null} />\n )}\n </div>\n\n {expanded && hasReviewDetails ? (\n <div className=\"mt-3 rounded-md border border-border bg-muted/20 p-3\" data-ai-mutation-preview-details>\n <FieldDiffCard\n fieldDiff={action?.fieldDiff ?? null}\n records={action?.records ?? null}\n />\n </div>\n ) : null}\n\n {confirmError ? (\n <ConfirmationErrorAlert error={confirmError} records={action?.records ?? null} />\n ) : null}\n\n <div className=\"mt-3 flex items-center justify-between gap-2\">\n {hasReviewDetails ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setExpanded((value) => !value)}\n data-ai-mutation-preview-review\n >\n <Eye className=\"size-4\" aria-hidden />\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.preview.reviewDetails',\n 'Review details',\n )}\n </span>\n <ChevronDown\n className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}\n aria-hidden\n />\n </Button>\n ) : (\n <span aria-hidden />\n )}\n <div className=\"flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => {\n void handleCancel()\n }}\n data-ai-mutation-preview-cancel\n >\n {t('ai_assistant.chat.mutation_cards.preview.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => {\n void handleConfirm()\n }}\n data-ai-mutation-preview-confirm\n >\n {t('ai_assistant.chat.mutation_cards.preview.confirm', 'Confirm')}\n </Button>\n </div>\n </div>\n </section>\n )\n}\n\nexport default MutationPreviewCard\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -10,6 +10,7 @@ function dispatchFixRequest(detail) {
|
|
|
10
10
|
if (typeof window === "undefined") return;
|
|
11
11
|
window.dispatchEvent(new CustomEvent(AI_CHAT_FIX_REQUEST_EVENT, { detail }));
|
|
12
12
|
}
|
|
13
|
+
const INFORMATIONAL_RECORD_CODES = /* @__PURE__ */ new Set(["record_duplicate_dropped"]);
|
|
13
14
|
function isSuccessStatus(status) {
|
|
14
15
|
return status === "confirmed";
|
|
15
16
|
}
|
|
@@ -37,32 +38,45 @@ function MutationResultCard(props) {
|
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
41
|
+
const onlyIgnoredEntries = failedRecords.every(
|
|
42
|
+
(record) => INFORMATIONAL_RECORD_CODES.has(record.error?.code)
|
|
43
|
+
);
|
|
44
|
+
return /* @__PURE__ */ jsxs(
|
|
45
|
+
Alert,
|
|
46
|
+
{
|
|
47
|
+
status: onlyIgnoredEntries ? "success" : "warning",
|
|
48
|
+
"data-ai-mutation-result": onlyIgnoredEntries ? "success-with-notes" : "partial",
|
|
49
|
+
children: [
|
|
50
|
+
/* @__PURE__ */ jsx(AlertTitle, { children: onlyIgnoredEntries ? t("ai_assistant.chat.mutation_cards.result.successTitle", "Action applied") : t(
|
|
51
|
+
"ai_assistant.chat.mutation_cards.result.partialTitle",
|
|
52
|
+
"Action applied with failures"
|
|
53
|
+
) }),
|
|
54
|
+
/* @__PURE__ */ jsxs("div", { className: "text-sm leading-relaxed", children: [
|
|
55
|
+
/* @__PURE__ */ jsx("p", { children: onlyIgnoredEntries ? t(
|
|
56
|
+
"ai_assistant.chat.mutation_cards.result.ignoredEntriesBody",
|
|
57
|
+
"The change was saved. Some repeated entries in the request were ignored:"
|
|
58
|
+
) : t(
|
|
59
|
+
"ai_assistant.chat.mutation_cards.result.partialBody",
|
|
60
|
+
"Some records could not be updated."
|
|
61
|
+
) }),
|
|
62
|
+
/* @__PURE__ */ jsx(
|
|
63
|
+
"ul",
|
|
64
|
+
{
|
|
65
|
+
className: "mt-2 list-disc space-y-1 pl-5 text-xs",
|
|
66
|
+
"data-ai-mutation-failed-records": true,
|
|
67
|
+
children: failedRecords.map((record) => /* @__PURE__ */ jsxs("li", { "data-ai-mutation-failed-record": record.recordId, children: [
|
|
68
|
+
/* @__PURE__ */ jsx("span", { className: "font-mono", children: record.recordId }),
|
|
69
|
+
/* @__PURE__ */ jsx("span", { className: "mx-1 text-muted-foreground", children: "\u2022" }),
|
|
70
|
+
/* @__PURE__ */ jsx("span", { className: "font-mono", children: record.error.code }),
|
|
71
|
+
/* @__PURE__ */ jsx("span", { className: "mx-1 text-muted-foreground", children: "\u2014" }),
|
|
72
|
+
/* @__PURE__ */ jsx("span", { children: record.error.message })
|
|
73
|
+
] }, record.recordId))
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
] })
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
);
|
|
66
80
|
}
|
|
67
81
|
if (isSuccessStatus(status)) {
|
|
68
82
|
const recordId = result?.recordId ?? action.targetRecordId ?? null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/ai/parts/MutationResultCard.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Wand2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\n/** Custom DOM event the failure card dispatches when the operator clicks\n * \"Fix with AI\". `<AiChat>` listens for this and sends a follow-up user\n * message asking the agent to diagnose and retry the failed call. */\nexport const AI_CHAT_FIX_REQUEST_EVENT = 'om-ai-chat-fix-request'\n\nexport interface AiChatFixRequestDetail {\n message: string\n toolName?: string\n pendingActionId?: string\n}\n\nfunction dispatchFixRequest(detail: AiChatFixRequestDetail): void {\n if (typeof window === 'undefined') return\n window.dispatchEvent(new CustomEvent<AiChatFixRequestDetail>(AI_CHAT_FIX_REQUEST_EVENT, { detail }))\n}\n\n/**\n * Terminal-state card that renders the `executionResult` of a pending\n * action. Success \u2192 `Alert variant=\"success\"` with a record link; partial\n * success (batch `failedRecords[]`) \u2192 `variant=\"warning\"` with the list;\n * failure \u2192 `variant=\"destructive\"` with the error code + message.\n *\n * Reads the pending action via the shared polling hook so page reloads\n * still recover state. The hook short-circuits once the row is terminal\n * (spec's reconnect behavior), which is always the case for this card.\n */\nexport interface MutationResultCardPayload {\n /** Server-serialized pending action snapshot (optional \u2014 the hook refetches). */\n pendingAction?: AiPendingActionCardAction\n /** Optional link target for the success record. */\n recordHref?: string\n}\n\nexport interface MutationResultCardProps extends AiUiPartProps {\n /** Optional injected action for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Poll endpoint override for tests. */\n endpoint?: string\n}\n\nfunction isSuccessStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'confirmed'\n}\n\nfunction isFailureStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'failed' || status === 'expired'\n}\n\nfunction isCancelledStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'cancelled'\n}\n\nexport function MutationResultCard(props: MutationResultCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as MutationResultCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action: polled } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId || Boolean(injected),\n })\n const action = injected ?? polled\n const status = action?.status ?? null\n const failedRecords = action?.failedRecords ?? null\n const result = action?.executionResult ?? null\n\n if (!action) {\n return null\n }\n\n if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {\n return (\n <Alert status=\"warning\" data-ai-mutation-result=\"partial\">\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.result.partialTitle',\n 'Action applied with failures',\n )}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {t(\n 'ai_assistant.chat.mutation_cards.result.partialBody',\n 'Some records could not be updated.',\n )}\n </p>\n <ul\n className=\"mt-2 list-disc space-y-1 pl-5 text-xs\"\n data-ai-mutation-failed-records\n >\n {failedRecords.map((record) => (\n <li key={record.recordId} data-ai-mutation-failed-record={record.recordId}>\n <span className=\"font-mono\">{record.recordId}</span>\n <span className=\"mx-1 text-muted-foreground\">\u2022</span>\n <span className=\"font-mono\">{record.error.code}</span>\n <span className=\"mx-1 text-muted-foreground\">\u2014</span>\n <span>{record.error.message}</span>\n </li>\n ))}\n </ul>\n </div>\n </Alert>\n )\n }\n\n if (isSuccessStatus(status)) {\n const recordId = result?.recordId ?? action.targetRecordId ?? null\n const href = payload.recordHref ?? null\n return (\n <Alert status=\"success\" data-ai-mutation-result=\"success\">\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {t(\n 'ai_assistant.chat.mutation_cards.result.successBody',\n 'The requested change was saved successfully.',\n )}\n </p>\n {recordId && href ? (\n <p className=\"mt-1 text-xs\">\n <a\n className=\"text-primary underline\"\n href={href}\n data-ai-mutation-result-link\n >\n {t(\n 'ai_assistant.chat.mutation_cards.result.viewRecord',\n 'View record',\n )}\n </a>\n </p>\n ) : null}\n </div>\n </Alert>\n )\n }\n\n // A deliberate cancellation is not a failure: no destructive styling, no\n // internal error code, and no \"Fix with AI\" (which would just re-propose\n // the change the operator just rejected). Show the operator's own\n // cancellation reason verbatim when they gave one, otherwise a neutral\n // localized default.\n if (isCancelledStatus(status)) {\n const reason = result?.error?.message?.trim() || null\n return (\n <Alert status=\"information\" data-ai-mutation-result=\"cancelled\">\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.result.cancelledTitle', 'Proposal cancelled')}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {reason ??\n t(\n 'ai_assistant.chat.mutation_cards.result.cancelledBody',\n 'You cancelled this proposed change. No changes were made.',\n )}\n </p>\n </div>\n </Alert>\n )\n }\n\n // Render the failure alert when the action is in a failure status OR when\n // the dispatcher already captured an `executionResult.error` (the row may\n // not yet have transitioned out of `executing` in the polling snapshot,\n // but the handler error is authoritative \u2014 surface it immediately so the\n // operator never sees a stuck \"applying\u2026\" state silently masking a real\n // failure).\n if (isFailureStatus(status) || result?.error) {\n const code = result?.error?.code ?? status ?? 'failed'\n const message =\n result?.error?.message ??\n t(\n 'ai_assistant.chat.mutation_cards.result.failureBody',\n 'The mutation could not be applied.',\n )\n const errorObj = result?.error\n const errorDetails = errorObj?.details\n const errorInput = errorObj?.input\n const errorName = errorObj?.name\n const onFixWithAi = () => {\n // Build a structured prompt that gives the agent enough context to\n // diagnose and retry without copy/paste from the operator. Keeping\n // it explicit (\"retry with corrected arguments\") nudges the model\n // away from re-issuing the same args (which would just hit the\n // same error). The repository's idempotency check only dedupes\n // active `pending` rows, so a fresh prepareMutation call after a\n // terminal failure always produces a new pending action \u2014 the\n // retry is never silently collapsed.\n //\n // The prompt now embeds the full structured failure context the\n // server captured (Zod issues / fieldErrors, original arguments,\n // failedRecords for batch tools, error name + cause). Without this\n // the operator routinely saw \"Invalid input\" with no field path \u2014\n // the model literally could not fix what it could not see.\n const promptLines: string[] = [\n `The previous call to tool \"${action.toolName}\" failed.`,\n `Error: ${code} \u2014 ${message}.`,\n ]\n if (errorName && errorName !== 'Error') {\n promptLines.push(`Error class: ${errorName}.`)\n }\n if (action.targetEntityType || action.targetRecordId) {\n promptLines.push(\n `Target: ${action.targetEntityType ?? '?'}${action.targetRecordId ? ' / ' + action.targetRecordId : ''}.`,\n )\n }\n\n // Field-level validation issues (Zod, custom). Render as a bulleted\n // list of `path: message` so the model can locate the offender by\n // schema path instead of guessing from a generic message.\n const issues = errorDetails?.issues ?? []\n if (Array.isArray(issues) && issues.length > 0) {\n promptLines.push('', 'Validation issues:')\n for (const issue of issues) {\n const path = Array.isArray(issue?.path) && issue.path.length > 0\n ? issue.path.join('.')\n : '(root)'\n const msg = issue?.message ?? '(no message)'\n const codeHint = issue?.code ? ` [${issue.code}]` : ''\n const expHint =\n issue?.expected || issue?.received\n ? ` (expected ${issue?.expected ?? '?'}, got ${issue?.received ?? '?'})`\n : ''\n promptLines.push(`- ${path}: ${msg}${codeHint}${expHint}`)\n }\n } else if (errorDetails?.fieldErrors && typeof errorDetails.fieldErrors === 'object') {\n const entries = Object.entries(errorDetails.fieldErrors)\n if (entries.length > 0) {\n promptLines.push('', 'Field errors:')\n for (const [path, msgs] of entries) {\n const list = Array.isArray(msgs) ? msgs.join('; ') : String(msgs)\n promptLines.push(`- ${path}: ${list}`)\n }\n }\n }\n\n // Echo the arguments the handler was invoked with so the model can\n // see exactly what it sent and change at least one parameter on\n // retry. JSON-stringified inline for compactness; non-serializable\n // values are dropped on the server side already.\n if (errorInput !== undefined) {\n try {\n const json = JSON.stringify(errorInput, null, 2)\n if (json && json !== '{}' && json.length <= 4000) {\n promptLines.push('', 'Arguments you sent:', '```json', json, '```')\n } else if (json && json.length > 4000) {\n promptLines.push(\n '',\n 'Arguments you sent (truncated):',\n '```json',\n json.slice(0, 4000) + '\\n\u2026 [truncated]',\n '```',\n )\n }\n } catch {\n // ignore\n }\n }\n\n // Surface root-cause when the handler nested another error inside.\n if (errorDetails?.cause !== undefined) {\n try {\n const causeJson = JSON.stringify(errorDetails.cause, null, 2)\n if (causeJson && causeJson !== '{}' && causeJson.length <= 1500) {\n promptLines.push('', 'Underlying cause:', '```json', causeJson, '```')\n }\n } catch {\n // ignore\n }\n }\n\n // Per-record failures from batch tools (Step 5.14). These usually\n // carry the most actionable information for partial-success cases.\n if (failedRecords && failedRecords.length > 0) {\n promptLines.push('', 'Records that failed:')\n for (const rec of failedRecords) {\n promptLines.push(\n `- ${rec.recordId} \u2192 ${rec.error.code}: ${rec.error.message}`,\n )\n }\n }\n\n promptLines.push(\n '',\n 'Diagnose what went wrong using the validation issues / cause / arguments above, correct the arguments, and call the tool again. If the failure indicates missing prerequisites (e.g. a deal needs a linked person/company before commenting), tell me what to fix on the platform side instead of retrying blindly. Do not repeat the exact same arguments \u2014 you must change at least one parameter or stop and explain.',\n )\n dispatchFixRequest({\n message: promptLines.join('\\n'),\n toolName: action.toolName,\n pendingActionId: action.id,\n })\n }\n const visibleIssues = Array.isArray(errorDetails?.issues)\n ? errorDetails!.issues!.filter((entry) => entry && (entry.message || entry.path))\n : []\n return (\n <Alert status=\"error\" data-ai-mutation-result=\"failure\">\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.result.failureTitle',\n 'Action failed',\n )}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <div>\n <span className=\"mr-2 font-mono text-xs\" data-ai-mutation-result-code>\n {code}\n </span>\n <span>{message}</span>\n </div>\n {visibleIssues.length > 0 ? (\n <ul\n className=\"mt-2 list-disc space-y-0.5 pl-5 text-xs\"\n data-ai-mutation-result-issues\n >\n {visibleIssues.map((issue, index) => {\n const path =\n Array.isArray(issue?.path) && issue.path.length > 0\n ? issue.path.join('.')\n : null\n return (\n <li key={index}>\n {path ? (\n <span className=\"font-mono\">{path}</span>\n ) : null}\n {path && issue?.message ? <span className=\"mx-1\">\u2014</span> : null}\n {issue?.message ? <span>{issue.message}</span> : null}\n </li>\n )\n })}\n </ul>\n ) : null}\n <div className=\"mt-2 flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={onFixWithAi}\n data-ai-mutation-result-fix\n >\n <Wand2 className=\"size-4\" aria-hidden />\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.result.fixWithAi',\n 'Fix with AI',\n )}\n </span>\n </Button>\n </div>\n </div>\n </Alert>\n )\n }\n\n return null\n}\n\nexport default MutationResultCard\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Wand2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport type { AiUiPartProps } from '../ui-part-registry'\nimport { useAiPendingActionPolling } from './useAiPendingActionPolling'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\n/** Custom DOM event the failure card dispatches when the operator clicks\n * \"Fix with AI\". `<AiChat>` listens for this and sends a follow-up user\n * message asking the agent to diagnose and retry the failed call. */\nexport const AI_CHAT_FIX_REQUEST_EVENT = 'om-ai-chat-fix-request'\n\nexport interface AiChatFixRequestDetail {\n message: string\n toolName?: string\n pendingActionId?: string\n}\n\nfunction dispatchFixRequest(detail: AiChatFixRequestDetail): void {\n if (typeof window === 'undefined') return\n window.dispatchEvent(new CustomEvent<AiChatFixRequestDetail>(AI_CHAT_FIX_REQUEST_EVENT, { detail }))\n}\n\n/**\n * Terminal-state card that renders the `executionResult` of a pending\n * action. Success \u2192 `Alert variant=\"success\"` with a record link; partial\n * success (batch `failedRecords[]`) \u2192 `variant=\"warning\"` with the list;\n * failure \u2192 `variant=\"destructive\"` with the error code + message.\n *\n * Reads the pending action via the shared polling hook so page reloads\n * still recover state. The hook short-circuits once the row is terminal\n * (spec's reconnect behavior), which is always the case for this card.\n */\nexport interface MutationResultCardPayload {\n /** Server-serialized pending action snapshot (optional \u2014 the hook refetches). */\n pendingAction?: AiPendingActionCardAction\n /** Optional link target for the success record. */\n recordHref?: string\n}\n\nexport interface MutationResultCardProps extends AiUiPartProps {\n /** Optional injected action for tests \u2014 bypasses the polling fetch. */\n initialAction?: AiPendingActionCardAction\n /** Poll endpoint override for tests. */\n endpoint?: string\n}\n\nconst INFORMATIONAL_RECORD_CODES: ReadonlySet<string | undefined> = new Set(['record_duplicate_dropped'])\n\nfunction isSuccessStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'confirmed'\n}\n\nfunction isFailureStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'failed' || status === 'expired'\n}\n\nfunction isCancelledStatus(status: AiPendingActionCardStatus | null): boolean {\n return status === 'cancelled'\n}\n\nexport function MutationResultCard(props: MutationResultCardProps) {\n const t = useT()\n const pendingActionId = props.pendingActionId ?? ''\n const payload = (props.payload as MutationResultCardPayload | undefined) ?? {}\n const injected = props.initialAction ?? payload.pendingAction ?? null\n\n const { action: polled } = useAiPendingActionPolling({\n pendingActionId,\n endpoint: props.endpoint,\n disabled: !pendingActionId || Boolean(injected),\n })\n const action = injected ?? polled\n const status = action?.status ?? null\n const failedRecords = action?.failedRecords ?? null\n const result = action?.executionResult ?? null\n\n if (!action) {\n return null\n }\n\n if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {\n const onlyIgnoredEntries = failedRecords.every((record) =>\n INFORMATIONAL_RECORD_CODES.has(record.error?.code),\n )\n return (\n <Alert\n status={onlyIgnoredEntries ? 'success' : 'warning'}\n data-ai-mutation-result={onlyIgnoredEntries ? 'success-with-notes' : 'partial'}\n >\n <AlertTitle>\n {onlyIgnoredEntries\n ? t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')\n : t(\n 'ai_assistant.chat.mutation_cards.result.partialTitle',\n 'Action applied with failures',\n )}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {onlyIgnoredEntries\n ? t(\n 'ai_assistant.chat.mutation_cards.result.ignoredEntriesBody',\n 'The change was saved. Some repeated entries in the request were ignored:',\n )\n : t(\n 'ai_assistant.chat.mutation_cards.result.partialBody',\n 'Some records could not be updated.',\n )}\n </p>\n <ul\n className=\"mt-2 list-disc space-y-1 pl-5 text-xs\"\n data-ai-mutation-failed-records\n >\n {failedRecords.map((record) => (\n <li key={record.recordId} data-ai-mutation-failed-record={record.recordId}>\n <span className=\"font-mono\">{record.recordId}</span>\n <span className=\"mx-1 text-muted-foreground\">\u2022</span>\n <span className=\"font-mono\">{record.error.code}</span>\n <span className=\"mx-1 text-muted-foreground\">\u2014</span>\n <span>{record.error.message}</span>\n </li>\n ))}\n </ul>\n </div>\n </Alert>\n )\n }\n\n if (isSuccessStatus(status)) {\n const recordId = result?.recordId ?? action.targetRecordId ?? null\n const href = payload.recordHref ?? null\n return (\n <Alert status=\"success\" data-ai-mutation-result=\"success\">\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {t(\n 'ai_assistant.chat.mutation_cards.result.successBody',\n 'The requested change was saved successfully.',\n )}\n </p>\n {recordId && href ? (\n <p className=\"mt-1 text-xs\">\n <a\n className=\"text-primary underline\"\n href={href}\n data-ai-mutation-result-link\n >\n {t(\n 'ai_assistant.chat.mutation_cards.result.viewRecord',\n 'View record',\n )}\n </a>\n </p>\n ) : null}\n </div>\n </Alert>\n )\n }\n\n // A deliberate cancellation is not a failure: no destructive styling, no\n // internal error code, and no \"Fix with AI\" (which would just re-propose\n // the change the operator just rejected). Show the operator's own\n // cancellation reason verbatim when they gave one, otherwise a neutral\n // localized default.\n if (isCancelledStatus(status)) {\n const reason = result?.error?.message?.trim() || null\n return (\n <Alert status=\"information\" data-ai-mutation-result=\"cancelled\">\n <AlertTitle>\n {t('ai_assistant.chat.mutation_cards.result.cancelledTitle', 'Proposal cancelled')}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <p>\n {reason ??\n t(\n 'ai_assistant.chat.mutation_cards.result.cancelledBody',\n 'You cancelled this proposed change. No changes were made.',\n )}\n </p>\n </div>\n </Alert>\n )\n }\n\n // Render the failure alert when the action is in a failure status OR when\n // the dispatcher already captured an `executionResult.error` (the row may\n // not yet have transitioned out of `executing` in the polling snapshot,\n // but the handler error is authoritative \u2014 surface it immediately so the\n // operator never sees a stuck \"applying\u2026\" state silently masking a real\n // failure).\n if (isFailureStatus(status) || result?.error) {\n const code = result?.error?.code ?? status ?? 'failed'\n const message =\n result?.error?.message ??\n t(\n 'ai_assistant.chat.mutation_cards.result.failureBody',\n 'The mutation could not be applied.',\n )\n const errorObj = result?.error\n const errorDetails = errorObj?.details\n const errorInput = errorObj?.input\n const errorName = errorObj?.name\n const onFixWithAi = () => {\n // Build a structured prompt that gives the agent enough context to\n // diagnose and retry without copy/paste from the operator. Keeping\n // it explicit (\"retry with corrected arguments\") nudges the model\n // away from re-issuing the same args (which would just hit the\n // same error). The repository's idempotency check only dedupes\n // active `pending` rows, so a fresh prepareMutation call after a\n // terminal failure always produces a new pending action \u2014 the\n // retry is never silently collapsed.\n //\n // The prompt now embeds the full structured failure context the\n // server captured (Zod issues / fieldErrors, original arguments,\n // failedRecords for batch tools, error name + cause). Without this\n // the operator routinely saw \"Invalid input\" with no field path \u2014\n // the model literally could not fix what it could not see.\n const promptLines: string[] = [\n `The previous call to tool \"${action.toolName}\" failed.`,\n `Error: ${code} \u2014 ${message}.`,\n ]\n if (errorName && errorName !== 'Error') {\n promptLines.push(`Error class: ${errorName}.`)\n }\n if (action.targetEntityType || action.targetRecordId) {\n promptLines.push(\n `Target: ${action.targetEntityType ?? '?'}${action.targetRecordId ? ' / ' + action.targetRecordId : ''}.`,\n )\n }\n\n // Field-level validation issues (Zod, custom). Render as a bulleted\n // list of `path: message` so the model can locate the offender by\n // schema path instead of guessing from a generic message.\n const issues = errorDetails?.issues ?? []\n if (Array.isArray(issues) && issues.length > 0) {\n promptLines.push('', 'Validation issues:')\n for (const issue of issues) {\n const path = Array.isArray(issue?.path) && issue.path.length > 0\n ? issue.path.join('.')\n : '(root)'\n const msg = issue?.message ?? '(no message)'\n const codeHint = issue?.code ? ` [${issue.code}]` : ''\n const expHint =\n issue?.expected || issue?.received\n ? ` (expected ${issue?.expected ?? '?'}, got ${issue?.received ?? '?'})`\n : ''\n promptLines.push(`- ${path}: ${msg}${codeHint}${expHint}`)\n }\n } else if (errorDetails?.fieldErrors && typeof errorDetails.fieldErrors === 'object') {\n const entries = Object.entries(errorDetails.fieldErrors)\n if (entries.length > 0) {\n promptLines.push('', 'Field errors:')\n for (const [path, msgs] of entries) {\n const list = Array.isArray(msgs) ? msgs.join('; ') : String(msgs)\n promptLines.push(`- ${path}: ${list}`)\n }\n }\n }\n\n // Echo the arguments the handler was invoked with so the model can\n // see exactly what it sent and change at least one parameter on\n // retry. JSON-stringified inline for compactness; non-serializable\n // values are dropped on the server side already.\n if (errorInput !== undefined) {\n try {\n const json = JSON.stringify(errorInput, null, 2)\n if (json && json !== '{}' && json.length <= 4000) {\n promptLines.push('', 'Arguments you sent:', '```json', json, '```')\n } else if (json && json.length > 4000) {\n promptLines.push(\n '',\n 'Arguments you sent (truncated):',\n '```json',\n json.slice(0, 4000) + '\\n\u2026 [truncated]',\n '```',\n )\n }\n } catch {\n // ignore\n }\n }\n\n // Surface root-cause when the handler nested another error inside.\n if (errorDetails?.cause !== undefined) {\n try {\n const causeJson = JSON.stringify(errorDetails.cause, null, 2)\n if (causeJson && causeJson !== '{}' && causeJson.length <= 1500) {\n promptLines.push('', 'Underlying cause:', '```json', causeJson, '```')\n }\n } catch {\n // ignore\n }\n }\n\n // Per-record failures from batch tools (Step 5.14). These usually\n // carry the most actionable information for partial-success cases.\n if (failedRecords && failedRecords.length > 0) {\n promptLines.push('', 'Records that failed:')\n for (const rec of failedRecords) {\n promptLines.push(\n `- ${rec.recordId} \u2192 ${rec.error.code}: ${rec.error.message}`,\n )\n }\n }\n\n promptLines.push(\n '',\n 'Diagnose what went wrong using the validation issues / cause / arguments above, correct the arguments, and call the tool again. If the failure indicates missing prerequisites (e.g. a deal needs a linked person/company before commenting), tell me what to fix on the platform side instead of retrying blindly. Do not repeat the exact same arguments \u2014 you must change at least one parameter or stop and explain.',\n )\n dispatchFixRequest({\n message: promptLines.join('\\n'),\n toolName: action.toolName,\n pendingActionId: action.id,\n })\n }\n const visibleIssues = Array.isArray(errorDetails?.issues)\n ? errorDetails!.issues!.filter((entry) => entry && (entry.message || entry.path))\n : []\n return (\n <Alert status=\"error\" data-ai-mutation-result=\"failure\">\n <AlertTitle>\n {t(\n 'ai_assistant.chat.mutation_cards.result.failureTitle',\n 'Action failed',\n )}\n </AlertTitle>\n <div className=\"text-sm leading-relaxed\">\n <div>\n <span className=\"mr-2 font-mono text-xs\" data-ai-mutation-result-code>\n {code}\n </span>\n <span>{message}</span>\n </div>\n {visibleIssues.length > 0 ? (\n <ul\n className=\"mt-2 list-disc space-y-0.5 pl-5 text-xs\"\n data-ai-mutation-result-issues\n >\n {visibleIssues.map((issue, index) => {\n const path =\n Array.isArray(issue?.path) && issue.path.length > 0\n ? issue.path.join('.')\n : null\n return (\n <li key={index}>\n {path ? (\n <span className=\"font-mono\">{path}</span>\n ) : null}\n {path && issue?.message ? <span className=\"mx-1\">\u2014</span> : null}\n {issue?.message ? <span>{issue.message}</span> : null}\n </li>\n )\n })}\n </ul>\n ) : null}\n <div className=\"mt-2 flex items-center gap-2\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={onFixWithAi}\n data-ai-mutation-result-fix\n >\n <Wand2 className=\"size-4\" aria-hidden />\n <span>\n {t(\n 'ai_assistant.chat.mutation_cards.result.fixWithAi',\n 'Fix with AI',\n )}\n </span>\n </Button>\n </div>\n </div>\n </Alert>\n )\n }\n\n return null\n}\n\nexport default MutationResultCard\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7660.1.c3cfc9211c",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.10.1-develop.7660.1.c3cfc9211c",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.10.1-develop.7660.1.c3cfc9211c",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -170,7 +170,7 @@ export function ConfirmationCard(props: ConfirmationCardProps) {
|
|
|
170
170
|
</div>
|
|
171
171
|
|
|
172
172
|
{localError ? (
|
|
173
|
-
<ConfirmationErrorAlert error={localError} />
|
|
173
|
+
<ConfirmationErrorAlert error={localError} records={effectiveAction?.records ?? null} />
|
|
174
174
|
) : null}
|
|
175
175
|
|
|
176
176
|
<div className="mt-3 flex items-center justify-end gap-2">
|
|
@@ -196,18 +196,40 @@ export function ConfirmationCard(props: ConfirmationCardProps) {
|
|
|
196
196
|
)
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
function
|
|
199
|
+
function readStaleRecordIds(extra: Record<string, unknown> | undefined): string[] {
|
|
200
|
+
if (!extra) return []
|
|
201
|
+
const ids: string[] = []
|
|
202
|
+
const push = (value: unknown) => {
|
|
203
|
+
if (typeof value === 'string' && value.length > 0 && !ids.includes(value)) ids.push(value)
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(extra.failedRecords)) {
|
|
206
|
+
for (const entry of extra.failedRecords) {
|
|
207
|
+
push(entry && typeof entry === 'object' ? (entry as { recordId?: unknown }).recordId : null)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(extra.staleRecords)) {
|
|
211
|
+
for (const entry of extra.staleRecords) push(entry)
|
|
212
|
+
}
|
|
213
|
+
push(extra.recordId)
|
|
214
|
+
return ids
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function ConfirmationErrorAlert({
|
|
200
218
|
error,
|
|
219
|
+
records,
|
|
201
220
|
}: {
|
|
202
221
|
error: { code?: string; message: string; extra?: Record<string, unknown> }
|
|
222
|
+
records?: AiPendingActionCardAction['records']
|
|
203
223
|
}) {
|
|
204
224
|
const t = useT()
|
|
205
225
|
const code = error.code ?? 'unknown'
|
|
206
226
|
|
|
207
227
|
if (code === 'stale_version') {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
const labelById = new Map<string, string>()
|
|
229
|
+
for (const record of records ?? []) {
|
|
230
|
+
if (record?.recordId && record.label) labelById.set(record.recordId, record.label)
|
|
231
|
+
}
|
|
232
|
+
const staleRecordIds = readStaleRecordIds(error.extra)
|
|
211
233
|
return (
|
|
212
234
|
<Alert
|
|
213
235
|
status="warning"
|
|
@@ -227,16 +249,17 @@ function ConfirmationErrorAlert({
|
|
|
227
249
|
'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',
|
|
228
250
|
)}
|
|
229
251
|
</p>
|
|
230
|
-
{
|
|
252
|
+
{staleRecordIds.length > 0 ? (
|
|
231
253
|
<ul className="mt-2 list-disc pl-5 text-xs">
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
data-ai-confirmation-stale-record={
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
{staleRecordIds.map((recordId) => {
|
|
255
|
+
const label = labelById.get(recordId)
|
|
256
|
+
return (
|
|
257
|
+
<li key={recordId} data-ai-confirmation-stale-record={recordId}>
|
|
258
|
+
{label ? <span className="mr-1">{label}</span> : null}
|
|
259
|
+
<span className="font-mono">{recordId}</span>
|
|
260
|
+
</li>
|
|
261
|
+
)
|
|
262
|
+
})}
|
|
240
263
|
</ul>
|
|
241
264
|
) : null}
|
|
242
265
|
</AlertDescription>
|
|
@@ -9,7 +9,7 @@ import type { AiUiPartProps } from '../ui-part-registry'
|
|
|
9
9
|
import { confirmPendingAction, cancelPendingAction } from './pending-action-api'
|
|
10
10
|
import { useAiPendingActionPolling } from './useAiPendingActionPolling'
|
|
11
11
|
import { FieldDiffCard } from './FieldDiffCard'
|
|
12
|
-
import { ConfirmationCard } from './ConfirmationCard'
|
|
12
|
+
import { ConfirmationCard, ConfirmationErrorAlert } from './ConfirmationCard'
|
|
13
13
|
import { MutationResultCard } from './MutationResultCard'
|
|
14
14
|
import type { AiPendingActionCardAction } from './types'
|
|
15
15
|
|
|
@@ -78,9 +78,10 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
|
|
|
78
78
|
endpoint: props.endpoint,
|
|
79
79
|
})
|
|
80
80
|
if (!result.ok) {
|
|
81
|
-
// Network / timeout / 4xx / 5xx —
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// Network / timeout / 4xx / 5xx — rewind the card to the preview
|
|
82
|
+
// phase, which renders the envelope inline (e.g. the 412
|
|
83
|
+
// `stale_version` refusal with the stale rows) so the operator can
|
|
84
|
+
// read the error, ask for a re-proposal, or retry.
|
|
84
85
|
setConfirmError(result.error)
|
|
85
86
|
setPhase('preview')
|
|
86
87
|
await refresh()
|
|
@@ -252,6 +253,10 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
|
|
|
252
253
|
</div>
|
|
253
254
|
) : null}
|
|
254
255
|
|
|
256
|
+
{confirmError ? (
|
|
257
|
+
<ConfirmationErrorAlert error={confirmError} records={action?.records ?? null} />
|
|
258
|
+
) : null}
|
|
259
|
+
|
|
255
260
|
<div className="mt-3 flex items-center justify-between gap-2">
|
|
256
261
|
{hasReviewDetails ? (
|
|
257
262
|
<Button
|
|
@@ -49,6 +49,8 @@ export interface MutationResultCardProps extends AiUiPartProps {
|
|
|
49
49
|
endpoint?: string
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const INFORMATIONAL_RECORD_CODES: ReadonlySet<string | undefined> = new Set(['record_duplicate_dropped'])
|
|
53
|
+
|
|
52
54
|
function isSuccessStatus(status: AiPendingActionCardStatus | null): boolean {
|
|
53
55
|
return status === 'confirmed'
|
|
54
56
|
}
|
|
@@ -82,20 +84,33 @@ export function MutationResultCard(props: MutationResultCardProps) {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {
|
|
87
|
+
const onlyIgnoredEntries = failedRecords.every((record) =>
|
|
88
|
+
INFORMATIONAL_RECORD_CODES.has(record.error?.code),
|
|
89
|
+
)
|
|
85
90
|
return (
|
|
86
|
-
<Alert
|
|
91
|
+
<Alert
|
|
92
|
+
status={onlyIgnoredEntries ? 'success' : 'warning'}
|
|
93
|
+
data-ai-mutation-result={onlyIgnoredEntries ? 'success-with-notes' : 'partial'}
|
|
94
|
+
>
|
|
87
95
|
<AlertTitle>
|
|
88
|
-
{
|
|
89
|
-
'ai_assistant.chat.mutation_cards.result.
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
{onlyIgnoredEntries
|
|
97
|
+
? t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')
|
|
98
|
+
: t(
|
|
99
|
+
'ai_assistant.chat.mutation_cards.result.partialTitle',
|
|
100
|
+
'Action applied with failures',
|
|
101
|
+
)}
|
|
92
102
|
</AlertTitle>
|
|
93
103
|
<div className="text-sm leading-relaxed">
|
|
94
104
|
<p>
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
{onlyIgnoredEntries
|
|
106
|
+
? t(
|
|
107
|
+
'ai_assistant.chat.mutation_cards.result.ignoredEntriesBody',
|
|
108
|
+
'The change was saved. Some repeated entries in the request were ignored:',
|
|
109
|
+
)
|
|
110
|
+
: t(
|
|
111
|
+
'ai_assistant.chat.mutation_cards.result.partialBody',
|
|
112
|
+
'Some records could not be updated.',
|
|
113
|
+
)}
|
|
99
114
|
</p>
|
|
100
115
|
<ul
|
|
101
116
|
className="mt-2 list-disc space-y-1 pl-5 text-xs"
|
|
@@ -34,6 +34,9 @@ const dict = {
|
|
|
34
34
|
'ai_assistant.chat.mutation_cards.diff.beforeHeader': 'Before',
|
|
35
35
|
'ai_assistant.chat.mutation_cards.diff.afterHeader': 'After',
|
|
36
36
|
'ai_assistant.chat.mutation_cards.diff.empty': 'No field changes for this record.',
|
|
37
|
+
'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle': 'Re-propose required',
|
|
38
|
+
'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody': 'One or more records changed since this preview was generated.',
|
|
39
|
+
'ai_assistant.chat.mutation_cards.confirmation.errorTitle': 'Confirm failed',
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function makeAction(
|
|
@@ -199,4 +202,57 @@ describe('MutationPreviewCard', () => {
|
|
|
199
202
|
expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
|
|
200
203
|
})
|
|
201
204
|
})
|
|
205
|
+
|
|
206
|
+
it('shows the 412 stale_version refusal with the stale rows when every previewed row changed', async () => {
|
|
207
|
+
installPollingMock(
|
|
208
|
+
makeAction({
|
|
209
|
+
fieldDiff: [],
|
|
210
|
+
records: [
|
|
211
|
+
{ recordId: 'prod-a', entityType: 'catalog.product', label: 'Product A', fieldDiff: [] },
|
|
212
|
+
{ recordId: 'prod-b', entityType: 'catalog.product', label: 'Product B', fieldDiff: [] },
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
;(confirmPendingAction as jest.Mock).mockResolvedValue({
|
|
217
|
+
ok: false,
|
|
218
|
+
error: {
|
|
219
|
+
status: 412,
|
|
220
|
+
code: 'stale_version',
|
|
221
|
+
message: 'All pending records are stale; the model must re-propose the batch.',
|
|
222
|
+
extra: { staleRecords: ['prod-a', 'prod-b'] },
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
renderWithProviders(
|
|
226
|
+
<MutationPreviewCard componentId="mutation-preview-card" pendingActionId="pa-1" />,
|
|
227
|
+
{ dict },
|
|
228
|
+
)
|
|
229
|
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(document.querySelector('[data-ai-confirmation-error="stale_version"]')).not.toBeNull()
|
|
232
|
+
})
|
|
233
|
+
expect(document.querySelector('[data-ai-mutation-preview]')).not.toBeNull()
|
|
234
|
+
expect(screen.getByText('Re-propose required')).toBeInTheDocument()
|
|
235
|
+
const staleRows = Array.from(document.querySelectorAll('[data-ai-confirmation-stale-record]'))
|
|
236
|
+
expect(staleRows.map((row) => row.getAttribute('data-ai-confirmation-stale-record'))).toEqual(['prod-a', 'prod-b'])
|
|
237
|
+
expect(screen.getByText('Product A')).toBeInTheDocument()
|
|
238
|
+
expect(screen.getByText('Product B')).toBeInTheDocument()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('shows any other confirm error envelope on the preview card', async () => {
|
|
242
|
+
installPollingMock(makeAction())
|
|
243
|
+
;(confirmPendingAction as jest.Mock).mockResolvedValue({
|
|
244
|
+
ok: false,
|
|
245
|
+
error: { status: 403, code: 'agent_features_denied', message: 'Caller lacks a required feature.' },
|
|
246
|
+
})
|
|
247
|
+
renderWithProviders(
|
|
248
|
+
<MutationPreviewCard componentId="mutation-preview-card" pendingActionId="pa-1" />,
|
|
249
|
+
{ dict },
|
|
250
|
+
)
|
|
251
|
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }))
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect(document.querySelector('[data-ai-confirmation-error="agent_features_denied"]')).not.toBeNull()
|
|
254
|
+
})
|
|
255
|
+
expect(screen.getByText('Confirm failed')).toBeInTheDocument()
|
|
256
|
+
expect(screen.getByText('Caller lacks a required feature.')).toBeInTheDocument()
|
|
257
|
+
})
|
|
202
258
|
})
|
|
@@ -21,6 +21,7 @@ const dict = {
|
|
|
21
21
|
'ai_assistant.chat.mutation_cards.result.viewRecord': 'View record',
|
|
22
22
|
'ai_assistant.chat.mutation_cards.result.partialTitle': 'Action applied with failures',
|
|
23
23
|
'ai_assistant.chat.mutation_cards.result.partialBody': 'Some records could not be updated.',
|
|
24
|
+
'ai_assistant.chat.mutation_cards.result.ignoredEntriesBody': 'The change was saved. Some repeated entries were ignored:',
|
|
24
25
|
'ai_assistant.chat.mutation_cards.result.failureTitle': 'Action failed',
|
|
25
26
|
'ai_assistant.chat.mutation_cards.result.failureBody': 'The mutation could not be applied.',
|
|
26
27
|
'ai_assistant.chat.mutation_cards.result.cancelledTitle': 'Proposal cancelled',
|
|
@@ -147,6 +148,43 @@ describe('MutationResultCard', () => {
|
|
|
147
148
|
expect(code?.textContent).toBe('tool_execution_failed')
|
|
148
149
|
})
|
|
149
150
|
|
|
151
|
+
it('reads "Action applied" when the only reported rows are dropped duplicates', () => {
|
|
152
|
+
installPollingMock(
|
|
153
|
+
baseAction({
|
|
154
|
+
failedRecords: [
|
|
155
|
+
{ recordId: 'r-1', error: { code: 'record_duplicate_dropped', message: 'listed more than once' } },
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
renderWithProviders(
|
|
160
|
+
<MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
|
|
161
|
+
{ dict },
|
|
162
|
+
)
|
|
163
|
+
expect(document.querySelector('[data-ai-mutation-result="success-with-notes"]')).not.toBeNull()
|
|
164
|
+
expect(document.querySelector('[data-ai-mutation-result="partial"]')).toBeNull()
|
|
165
|
+
expect(screen.getByText('Action applied')).toBeInTheDocument()
|
|
166
|
+
expect(screen.getByText('The change was saved. Some repeated entries were ignored:')).toBeInTheDocument()
|
|
167
|
+
expect(document.querySelectorAll('[data-ai-mutation-failed-record]').length).toBe(1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps "applied with failures" when a dropped duplicate comes with a real failure', () => {
|
|
171
|
+
installPollingMock(
|
|
172
|
+
baseAction({
|
|
173
|
+
failedRecords: [
|
|
174
|
+
{ recordId: 'r-1', error: { code: 'record_duplicate_dropped', message: 'listed more than once' } },
|
|
175
|
+
{ recordId: 'r-2', error: { code: 'stale_version', message: 'changed' } },
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
renderWithProviders(
|
|
180
|
+
<MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
|
|
181
|
+
{ dict },
|
|
182
|
+
)
|
|
183
|
+
expect(document.querySelector('[data-ai-mutation-result="partial"]')).not.toBeNull()
|
|
184
|
+
expect(screen.getByText('Action applied with failures')).toBeInTheDocument()
|
|
185
|
+
expect(document.querySelectorAll('[data-ai-mutation-failed-record]').length).toBe(2)
|
|
186
|
+
})
|
|
187
|
+
|
|
150
188
|
it('renders a neutral cancelled variant without an error code or Fix with AI button', () => {
|
|
151
189
|
installPollingMock(
|
|
152
190
|
baseAction({
|