@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- 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
|
+
})
|