@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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.
Files changed (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,360 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { AlertTriangle, CheckCircle2, Wand2, XCircle } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'
7
+ import { Button } from '../../primitives/button'
8
+ import type { AiUiPartProps } from '../ui-part-registry'
9
+ import { useAiPendingActionPolling } from './useAiPendingActionPolling'
10
+ import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
11
+
12
+ /** Custom DOM event the failure card dispatches when the operator clicks
13
+ * "Fix with AI". `<AiChat>` listens for this and sends a follow-up user
14
+ * message asking the agent to diagnose and retry the failed call. */
15
+ export const AI_CHAT_FIX_REQUEST_EVENT = 'om-ai-chat-fix-request'
16
+
17
+ export interface AiChatFixRequestDetail {
18
+ message: string
19
+ toolName?: string
20
+ pendingActionId?: string
21
+ }
22
+
23
+ function dispatchFixRequest(detail: AiChatFixRequestDetail): void {
24
+ if (typeof window === 'undefined') return
25
+ window.dispatchEvent(new CustomEvent<AiChatFixRequestDetail>(AI_CHAT_FIX_REQUEST_EVENT, { detail }))
26
+ }
27
+
28
+ /**
29
+ * Terminal-state card that renders the `executionResult` of a pending
30
+ * action. Success → `Alert variant="success"` with a record link; partial
31
+ * success (batch `failedRecords[]`) → `variant="warning"` with the list;
32
+ * failure → `variant="destructive"` with the error code + message.
33
+ *
34
+ * Reads the pending action via the shared polling hook so page reloads
35
+ * still recover state. The hook short-circuits once the row is terminal
36
+ * (spec's reconnect behavior), which is always the case for this card.
37
+ */
38
+ export interface MutationResultCardPayload {
39
+ /** Server-serialized pending action snapshot (optional — the hook refetches). */
40
+ pendingAction?: AiPendingActionCardAction
41
+ /** Optional link target for the success record. */
42
+ recordHref?: string
43
+ }
44
+
45
+ export interface MutationResultCardProps extends AiUiPartProps {
46
+ /** Optional injected action for tests — bypasses the polling fetch. */
47
+ initialAction?: AiPendingActionCardAction
48
+ /** Poll endpoint override for tests. */
49
+ endpoint?: string
50
+ }
51
+
52
+ function isSuccessStatus(status: AiPendingActionCardStatus | null): boolean {
53
+ return status === 'confirmed'
54
+ }
55
+
56
+ function isFailureStatus(status: AiPendingActionCardStatus | null): boolean {
57
+ return status === 'failed' || status === 'cancelled' || status === 'expired'
58
+ }
59
+
60
+ export function MutationResultCard(props: MutationResultCardProps) {
61
+ const t = useT()
62
+ const pendingActionId = props.pendingActionId ?? ''
63
+ const payload = (props.payload as MutationResultCardPayload | undefined) ?? {}
64
+ const injected = props.initialAction ?? payload.pendingAction ?? null
65
+
66
+ const { action: polled } = useAiPendingActionPolling({
67
+ pendingActionId,
68
+ endpoint: props.endpoint,
69
+ disabled: !pendingActionId || Boolean(injected),
70
+ })
71
+ const action = injected ?? polled
72
+ const status = action?.status ?? null
73
+ const failedRecords = action?.failedRecords ?? null
74
+ const result = action?.executionResult ?? null
75
+
76
+ if (!action) {
77
+ return null
78
+ }
79
+
80
+ if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {
81
+ return (
82
+ <Alert variant="warning" data-ai-mutation-result="partial">
83
+ <AlertTriangle className="size-4" aria-hidden />
84
+ <AlertTitle>
85
+ {t(
86
+ 'ai_assistant.chat.mutation_cards.result.partialTitle',
87
+ 'Action applied with failures',
88
+ )}
89
+ </AlertTitle>
90
+ <div className="text-sm leading-relaxed">
91
+ <p>
92
+ {t(
93
+ 'ai_assistant.chat.mutation_cards.result.partialBody',
94
+ 'Some records could not be updated.',
95
+ )}
96
+ </p>
97
+ <ul
98
+ className="mt-2 list-disc space-y-1 pl-5 text-xs"
99
+ data-ai-mutation-failed-records
100
+ >
101
+ {failedRecords.map((record) => (
102
+ <li key={record.recordId} data-ai-mutation-failed-record={record.recordId}>
103
+ <span className="font-mono">{record.recordId}</span>
104
+ <span className="mx-1 text-muted-foreground">•</span>
105
+ <span className="font-mono">{record.error.code}</span>
106
+ <span className="mx-1 text-muted-foreground">—</span>
107
+ <span>{record.error.message}</span>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ </div>
112
+ </Alert>
113
+ )
114
+ }
115
+
116
+ if (isSuccessStatus(status)) {
117
+ const recordId = result?.recordId ?? action.targetRecordId ?? null
118
+ const href = payload.recordHref ?? null
119
+ return (
120
+ <Alert variant="success" data-ai-mutation-result="success">
121
+ <CheckCircle2 className="size-4" aria-hidden />
122
+ <AlertTitle>
123
+ {t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')}
124
+ </AlertTitle>
125
+ <div className="text-sm leading-relaxed">
126
+ <p>
127
+ {result?.commandName
128
+ ? t(
129
+ 'ai_assistant.chat.mutation_cards.result.successWithCommand',
130
+ 'Completed',
131
+ ) + `: ${result.commandName}`
132
+ : t(
133
+ 'ai_assistant.chat.mutation_cards.result.successBody',
134
+ 'The mutation completed successfully.',
135
+ )}
136
+ </p>
137
+ {recordId ? (
138
+ <p className="mt-1 text-xs">
139
+ {href ? (
140
+ <a
141
+ className="font-mono text-primary underline"
142
+ href={href}
143
+ data-ai-mutation-result-link
144
+ >
145
+ {t(
146
+ 'ai_assistant.chat.mutation_cards.result.viewRecord',
147
+ 'View record',
148
+ )}
149
+ : {recordId}
150
+ </a>
151
+ ) : (
152
+ <span className="font-mono" data-ai-mutation-result-record-id>
153
+ {recordId}
154
+ </span>
155
+ )}
156
+ </p>
157
+ ) : null}
158
+ </div>
159
+ </Alert>
160
+ )
161
+ }
162
+
163
+ // Render the failure alert when the action is in a failure status OR when
164
+ // the dispatcher already captured an `executionResult.error` (the row may
165
+ // not yet have transitioned out of `executing` in the polling snapshot,
166
+ // but the handler error is authoritative — surface it immediately so the
167
+ // operator never sees a stuck "applying…" state silently masking a real
168
+ // failure).
169
+ if (isFailureStatus(status) || result?.error) {
170
+ const code = result?.error?.code ?? status ?? 'failed'
171
+ const message =
172
+ result?.error?.message ??
173
+ t(
174
+ 'ai_assistant.chat.mutation_cards.result.failureBody',
175
+ 'The mutation could not be applied.',
176
+ )
177
+ const errorObj = result?.error
178
+ const errorDetails = errorObj?.details
179
+ const errorInput = errorObj?.input
180
+ const errorName = errorObj?.name
181
+ const onFixWithAi = () => {
182
+ // Build a structured prompt that gives the agent enough context to
183
+ // diagnose and retry without copy/paste from the operator. Keeping
184
+ // it explicit ("retry with corrected arguments") nudges the model
185
+ // away from re-issuing the same args (which would just hit the
186
+ // same error). The repository's idempotency check only dedupes
187
+ // active `pending` rows, so a fresh prepareMutation call after a
188
+ // terminal failure always produces a new pending action — the
189
+ // retry is never silently collapsed.
190
+ //
191
+ // The prompt now embeds the full structured failure context the
192
+ // server captured (Zod issues / fieldErrors, original arguments,
193
+ // failedRecords for batch tools, error name + cause). Without this
194
+ // the operator routinely saw "Invalid input" with no field path —
195
+ // the model literally could not fix what it could not see.
196
+ const promptLines: string[] = [
197
+ `The previous call to tool "${action.toolName}" failed.`,
198
+ `Error: ${code} — ${message}.`,
199
+ ]
200
+ if (errorName && errorName !== 'Error') {
201
+ promptLines.push(`Error class: ${errorName}.`)
202
+ }
203
+ if (action.targetEntityType || action.targetRecordId) {
204
+ promptLines.push(
205
+ `Target: ${action.targetEntityType ?? '?'}${action.targetRecordId ? ' / ' + action.targetRecordId : ''}.`,
206
+ )
207
+ }
208
+
209
+ // Field-level validation issues (Zod, custom). Render as a bulleted
210
+ // list of `path: message` so the model can locate the offender by
211
+ // schema path instead of guessing from a generic message.
212
+ const issues = errorDetails?.issues ?? []
213
+ if (Array.isArray(issues) && issues.length > 0) {
214
+ promptLines.push('', 'Validation issues:')
215
+ for (const issue of issues) {
216
+ const path = Array.isArray(issue?.path) && issue.path.length > 0
217
+ ? issue.path.join('.')
218
+ : '(root)'
219
+ const msg = issue?.message ?? '(no message)'
220
+ const codeHint = issue?.code ? ` [${issue.code}]` : ''
221
+ const expHint =
222
+ issue?.expected || issue?.received
223
+ ? ` (expected ${issue?.expected ?? '?'}, got ${issue?.received ?? '?'})`
224
+ : ''
225
+ promptLines.push(`- ${path}: ${msg}${codeHint}${expHint}`)
226
+ }
227
+ } else if (errorDetails?.fieldErrors && typeof errorDetails.fieldErrors === 'object') {
228
+ const entries = Object.entries(errorDetails.fieldErrors)
229
+ if (entries.length > 0) {
230
+ promptLines.push('', 'Field errors:')
231
+ for (const [path, msgs] of entries) {
232
+ const list = Array.isArray(msgs) ? msgs.join('; ') : String(msgs)
233
+ promptLines.push(`- ${path}: ${list}`)
234
+ }
235
+ }
236
+ }
237
+
238
+ // Echo the arguments the handler was invoked with so the model can
239
+ // see exactly what it sent and change at least one parameter on
240
+ // retry. JSON-stringified inline for compactness; non-serializable
241
+ // values are dropped on the server side already.
242
+ if (errorInput !== undefined) {
243
+ try {
244
+ const json = JSON.stringify(errorInput, null, 2)
245
+ if (json && json !== '{}' && json.length <= 4000) {
246
+ promptLines.push('', 'Arguments you sent:', '```json', json, '```')
247
+ } else if (json && json.length > 4000) {
248
+ promptLines.push(
249
+ '',
250
+ 'Arguments you sent (truncated):',
251
+ '```json',
252
+ json.slice(0, 4000) + '\n… [truncated]',
253
+ '```',
254
+ )
255
+ }
256
+ } catch {
257
+ // ignore
258
+ }
259
+ }
260
+
261
+ // Surface root-cause when the handler nested another error inside.
262
+ if (errorDetails?.cause !== undefined) {
263
+ try {
264
+ const causeJson = JSON.stringify(errorDetails.cause, null, 2)
265
+ if (causeJson && causeJson !== '{}' && causeJson.length <= 1500) {
266
+ promptLines.push('', 'Underlying cause:', '```json', causeJson, '```')
267
+ }
268
+ } catch {
269
+ // ignore
270
+ }
271
+ }
272
+
273
+ // Per-record failures from batch tools (Step 5.14). These usually
274
+ // carry the most actionable information for partial-success cases.
275
+ if (failedRecords && failedRecords.length > 0) {
276
+ promptLines.push('', 'Records that failed:')
277
+ for (const rec of failedRecords) {
278
+ promptLines.push(
279
+ `- ${rec.recordId} → ${rec.error.code}: ${rec.error.message}`,
280
+ )
281
+ }
282
+ }
283
+
284
+ promptLines.push(
285
+ '',
286
+ '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 — you must change at least one parameter or stop and explain.',
287
+ )
288
+ dispatchFixRequest({
289
+ message: promptLines.join('\n'),
290
+ toolName: action.toolName,
291
+ pendingActionId: action.id,
292
+ })
293
+ }
294
+ const visibleIssues = Array.isArray(errorDetails?.issues)
295
+ ? errorDetails!.issues!.filter((entry) => entry && (entry.message || entry.path))
296
+ : []
297
+ return (
298
+ <Alert variant="destructive" data-ai-mutation-result="failure">
299
+ <XCircle className="size-4" aria-hidden />
300
+ <AlertTitle>
301
+ {t(
302
+ 'ai_assistant.chat.mutation_cards.result.failureTitle',
303
+ 'Action failed',
304
+ )}
305
+ </AlertTitle>
306
+ <div className="text-sm leading-relaxed">
307
+ <div>
308
+ <span className="mr-2 font-mono text-xs" data-ai-mutation-result-code>
309
+ {code}
310
+ </span>
311
+ <span>{message}</span>
312
+ </div>
313
+ {visibleIssues.length > 0 ? (
314
+ <ul
315
+ className="mt-2 list-disc space-y-0.5 pl-5 text-xs"
316
+ data-ai-mutation-result-issues
317
+ >
318
+ {visibleIssues.map((issue, index) => {
319
+ const path =
320
+ Array.isArray(issue?.path) && issue.path.length > 0
321
+ ? issue.path.join('.')
322
+ : null
323
+ return (
324
+ <li key={index}>
325
+ {path ? (
326
+ <span className="font-mono">{path}</span>
327
+ ) : null}
328
+ {path && issue?.message ? <span className="mx-1">—</span> : null}
329
+ {issue?.message ? <span>{issue.message}</span> : null}
330
+ </li>
331
+ )
332
+ })}
333
+ </ul>
334
+ ) : null}
335
+ <div className="mt-2 flex items-center gap-2">
336
+ <Button
337
+ type="button"
338
+ variant="outline"
339
+ size="sm"
340
+ onClick={onFixWithAi}
341
+ data-ai-mutation-result-fix
342
+ >
343
+ <Wand2 className="size-4" aria-hidden />
344
+ <span>
345
+ {t(
346
+ 'ai_assistant.chat.mutation_cards.result.fixWithAi',
347
+ 'Fix with AI',
348
+ )}
349
+ </span>
350
+ </Button>
351
+ </div>
352
+ </div>
353
+ </Alert>
354
+ )
355
+ }
356
+
357
+ return null
358
+ }
359
+
360
+ export default MutationResultCard
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+
9
+ jest.mock('../useAiPendingActionPolling', () => ({
10
+ useAiPendingActionPolling: jest.fn(),
11
+ }))
12
+
13
+ jest.mock('../pending-action-api', () => ({
14
+ confirmPendingAction: jest.fn(),
15
+ cancelPendingAction: jest.fn(),
16
+ }))
17
+
18
+ import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
19
+ import { cancelPendingAction } from '../pending-action-api'
20
+ import { ConfirmationCard } from '../ConfirmationCard'
21
+ import type { AiPendingActionCardAction } from '../types'
22
+
23
+ const dict = {
24
+ 'ai_assistant.chat.mutation_cards.confirmation.title': 'Applying action...',
25
+ 'ai_assistant.chat.mutation_cards.confirmation.cancel': 'Cancel',
26
+ 'ai_assistant.chat.mutation_cards.confirmation.defaultSummary': 'Applying the requested changes...',
27
+ 'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle': 'Re-propose required',
28
+ 'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody':
29
+ 'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',
30
+ 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle': 'Schema changed',
31
+ 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftBody':
32
+ 'The tool signature changed since this preview was generated. Ask the assistant to re-propose the change.',
33
+ 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle': 'Action already resolved',
34
+ 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusBody':
35
+ 'This action has already been confirmed, cancelled, or executed.',
36
+ 'ai_assistant.chat.mutation_cards.confirmation.errorTitle': 'Confirm failed',
37
+ 'ui.spinner.ariaLabel': 'Loading',
38
+ }
39
+
40
+ function baseAction(
41
+ overrides: Partial<AiPendingActionCardAction> = {},
42
+ ): AiPendingActionCardAction {
43
+ return {
44
+ id: 'pa-1',
45
+ agentId: 'customers.account_assistant',
46
+ toolName: 'customers.update_person',
47
+ status: 'pending',
48
+ fieldDiff: [],
49
+ records: null,
50
+ failedRecords: null,
51
+ sideEffectsSummary: 'Rename Alice to Alicia',
52
+ attachmentIds: [],
53
+ targetEntityType: 'customers.person',
54
+ targetRecordId: 'p-1',
55
+ recordVersion: '1',
56
+ executionResult: null,
57
+ createdAt: new Date().toISOString(),
58
+ expiresAt: new Date(Date.now() + 10_000).toISOString(),
59
+ resolvedAt: null,
60
+ resolvedByUserId: null,
61
+ ...overrides,
62
+ }
63
+ }
64
+
65
+ function installPollingMock(action: AiPendingActionCardAction | null) {
66
+ ;(useAiPendingActionPolling as jest.Mock).mockReturnValue({
67
+ action,
68
+ status: action?.status ?? null,
69
+ isPolling: action?.status === 'pending' || action?.status === 'executing',
70
+ error: null,
71
+ refresh: jest.fn().mockResolvedValue(action),
72
+ })
73
+ }
74
+
75
+ describe('ConfirmationCard', () => {
76
+ beforeEach(() => {
77
+ ;(useAiPendingActionPolling as jest.Mock).mockReset()
78
+ ;(cancelPendingAction as jest.Mock).mockReset()
79
+ ;(cancelPendingAction as jest.Mock).mockResolvedValue({
80
+ ok: true,
81
+ data: { ok: true, pendingAction: baseAction({ status: 'cancelled' }) },
82
+ })
83
+ })
84
+
85
+ it('renders spinner + side effects copy in pending state', () => {
86
+ installPollingMock(baseAction({ status: 'pending' }))
87
+ renderWithProviders(
88
+ <ConfirmationCard componentId="confirmation-card" pendingActionId="pa-1" />,
89
+ { dict },
90
+ )
91
+ expect(screen.getByText('Applying action...')).toBeInTheDocument()
92
+ expect(screen.getByText('Rename Alice to Alicia')).toBeInTheDocument()
93
+ // The cancel button must be enabled while status is pending.
94
+ const cancelButton = document.querySelector(
95
+ '[data-ai-confirmation-cancel]',
96
+ ) as HTMLButtonElement
97
+ expect(cancelButton.disabled).toBe(false)
98
+ })
99
+
100
+ it('disables Cancel once the server flips status to executing', () => {
101
+ installPollingMock(baseAction({ status: 'executing' }))
102
+ renderWithProviders(
103
+ <ConfirmationCard componentId="confirmation-card" pendingActionId="pa-1" />,
104
+ { dict },
105
+ )
106
+ const cancelButton = document.querySelector(
107
+ '[data-ai-confirmation-cancel]',
108
+ ) as HTMLButtonElement
109
+ expect(cancelButton.disabled).toBe(true)
110
+ })
111
+
112
+ it('renders the stale_version "Re-propose required" alert with failed record ids', () => {
113
+ installPollingMock(baseAction({ status: 'pending' }))
114
+ renderWithProviders(
115
+ <ConfirmationCard
116
+ componentId="confirmation-card"
117
+ pendingActionId="pa-1"
118
+ payload={{
119
+ confirmError: {
120
+ status: 412,
121
+ code: 'stale_version',
122
+ message: 'Record version changed since preview.',
123
+ extra: { failedRecords: [{ recordId: 'r-1' }, { recordId: 'r-2' }] },
124
+ },
125
+ }}
126
+ />,
127
+ { dict },
128
+ )
129
+ expect(
130
+ document.querySelector('[data-ai-confirmation-error="stale_version"]'),
131
+ ).not.toBeNull()
132
+ expect(screen.getByText('Re-propose required')).toBeInTheDocument()
133
+ expect(
134
+ document.querySelectorAll('[data-ai-confirmation-stale-record]').length,
135
+ ).toBe(2)
136
+ })
137
+
138
+ it('renders the schema_drift "Schema changed" alert', () => {
139
+ installPollingMock(baseAction({ status: 'pending' }))
140
+ renderWithProviders(
141
+ <ConfirmationCard
142
+ componentId="confirmation-card"
143
+ pendingActionId="pa-1"
144
+ payload={{
145
+ confirmError: {
146
+ status: 412,
147
+ code: 'schema_drift',
148
+ message: 'Tool schema changed.',
149
+ },
150
+ }}
151
+ />,
152
+ { dict },
153
+ )
154
+ expect(
155
+ document.querySelector('[data-ai-confirmation-error="schema_drift"]'),
156
+ ).not.toBeNull()
157
+ expect(screen.getByText('Schema changed')).toBeInTheDocument()
158
+ })
159
+
160
+ it('dispatches cancelPendingAction on Cancel click', async () => {
161
+ installPollingMock(baseAction({ status: 'pending' }))
162
+ renderWithProviders(
163
+ <ConfirmationCard componentId="confirmation-card" pendingActionId="pa-1" />,
164
+ { dict },
165
+ )
166
+ fireEvent.click(document.querySelector('[data-ai-confirmation-cancel]')!)
167
+ await waitFor(() => expect(cancelPendingAction).toHaveBeenCalledWith('pa-1', expect.any(Object)))
168
+ })
169
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { screen } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import { FieldDiffCard } from '../FieldDiffCard'
9
+
10
+ const dict = {
11
+ 'ai_assistant.chat.mutation_cards.diff.fieldHeader': 'Field',
12
+ 'ai_assistant.chat.mutation_cards.diff.beforeHeader': 'Before',
13
+ 'ai_assistant.chat.mutation_cards.diff.afterHeader': 'After',
14
+ 'ai_assistant.chat.mutation_cards.diff.empty': 'No field changes for this record.',
15
+ }
16
+
17
+ describe('FieldDiffCard', () => {
18
+ it('renders before/after cells using semantic token classes', () => {
19
+ renderWithProviders(
20
+ <FieldDiffCard
21
+ fieldDiff={[
22
+ { field: 'name', before: 'Alice', after: 'Alicia' },
23
+ { field: 'stage', before: 'prospect', after: 'qualified' },
24
+ ]}
25
+ />,
26
+ { dict },
27
+ )
28
+
29
+ expect(screen.getByText('Alice')).toBeInTheDocument()
30
+ expect(screen.getByText('Alicia')).toBeInTheDocument()
31
+ expect(screen.getByText('prospect')).toBeInTheDocument()
32
+ expect(screen.getByText('qualified')).toBeInTheDocument()
33
+
34
+ const before = screen.getAllByText('Alice')[0].closest('[data-ai-field-diff-before]')
35
+ expect(before?.className).toContain('text-status-warning-text')
36
+ const after = screen.getAllByText('Alicia')[0].closest('[data-ai-field-diff-after]')
37
+ expect(after?.className).toContain('text-status-success-text')
38
+ })
39
+
40
+ it('renders an info placeholder when fieldDiff is empty (single-record mode)', () => {
41
+ renderWithProviders(<FieldDiffCard fieldDiff={[]} />, { dict })
42
+ expect(
43
+ screen.getByText('No field changes for this record.'),
44
+ ).toBeInTheDocument()
45
+ // No table must be rendered in the empty state.
46
+ expect(document.querySelector('[data-ai-field-diff-table]')).toBeNull()
47
+ })
48
+
49
+ it('renders batch records[] mode with one section per record', () => {
50
+ renderWithProviders(
51
+ <FieldDiffCard
52
+ records={[
53
+ {
54
+ recordId: 'r-1',
55
+ entityType: 'customers.person',
56
+ label: 'Alice',
57
+ fieldDiff: [{ field: 'name', before: 'Alice', after: 'Al' }],
58
+ },
59
+ {
60
+ recordId: 'r-2',
61
+ entityType: 'customers.person',
62
+ label: 'Bob',
63
+ fieldDiff: [{ field: 'name', before: 'Bob', after: 'Robert' }],
64
+ },
65
+ ]}
66
+ />,
67
+ { dict },
68
+ )
69
+ expect(document.querySelectorAll('[data-ai-field-diff-record]').length).toBe(2)
70
+ expect(document.querySelectorAll('h4')[0].textContent).toBe('Alice')
71
+ expect(document.querySelectorAll('h4')[1].textContent).toBe('Bob')
72
+ expect(screen.getByText('Robert')).toBeInTheDocument()
73
+ })
74
+ })