@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,177 @@
|
|
|
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 {
|
|
20
|
+
confirmPendingAction,
|
|
21
|
+
cancelPendingAction,
|
|
22
|
+
} from '../pending-action-api'
|
|
23
|
+
import { MutationPreviewCard } from '../MutationPreviewCard'
|
|
24
|
+
import type { AiPendingActionCardAction } from '../types'
|
|
25
|
+
|
|
26
|
+
const dict = {
|
|
27
|
+
'ai_assistant.chat.mutation_cards.preview.title': 'Review proposed changes',
|
|
28
|
+
'ai_assistant.chat.mutation_cards.preview.batchSummary': 'Batch update',
|
|
29
|
+
'ai_assistant.chat.mutation_cards.preview.batchRecords': 'records',
|
|
30
|
+
'ai_assistant.chat.mutation_cards.preview.confirm': 'Confirm',
|
|
31
|
+
'ai_assistant.chat.mutation_cards.preview.cancel': 'Cancel',
|
|
32
|
+
'ai_assistant.chat.mutation_cards.preview.reviewDetails': 'Review details',
|
|
33
|
+
'ai_assistant.chat.mutation_cards.diff.fieldHeader': 'Field',
|
|
34
|
+
'ai_assistant.chat.mutation_cards.diff.beforeHeader': 'Before',
|
|
35
|
+
'ai_assistant.chat.mutation_cards.diff.afterHeader': 'After',
|
|
36
|
+
'ai_assistant.chat.mutation_cards.diff.empty': 'No field changes for this record.',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeAction(
|
|
40
|
+
overrides: Partial<AiPendingActionCardAction> = {},
|
|
41
|
+
): AiPendingActionCardAction {
|
|
42
|
+
return {
|
|
43
|
+
id: 'pa-1',
|
|
44
|
+
agentId: 'customers.account_assistant',
|
|
45
|
+
toolName: 'customers.update_person',
|
|
46
|
+
status: 'pending',
|
|
47
|
+
fieldDiff: [{ field: 'name', before: 'Alice', after: 'Alicia' }],
|
|
48
|
+
records: null,
|
|
49
|
+
failedRecords: null,
|
|
50
|
+
sideEffectsSummary: 'Rename Alice to Alicia',
|
|
51
|
+
attachmentIds: [],
|
|
52
|
+
targetEntityType: 'customers.person',
|
|
53
|
+
targetRecordId: 'p-1',
|
|
54
|
+
recordVersion: '1',
|
|
55
|
+
executionResult: null,
|
|
56
|
+
createdAt: new Date().toISOString(),
|
|
57
|
+
expiresAt: new Date(Date.now() + 10_000).toISOString(),
|
|
58
|
+
resolvedAt: null,
|
|
59
|
+
resolvedByUserId: null,
|
|
60
|
+
...overrides,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installPollingMock(action: AiPendingActionCardAction | null) {
|
|
65
|
+
;(useAiPendingActionPolling as jest.Mock).mockReturnValue({
|
|
66
|
+
action,
|
|
67
|
+
status: action?.status ?? null,
|
|
68
|
+
isPolling: false,
|
|
69
|
+
error: null,
|
|
70
|
+
refresh: jest.fn().mockResolvedValue(action),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('MutationPreviewCard', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
;(useAiPendingActionPolling as jest.Mock).mockReset()
|
|
77
|
+
;(confirmPendingAction as jest.Mock).mockReset()
|
|
78
|
+
;(cancelPendingAction as jest.Mock).mockReset()
|
|
79
|
+
;(confirmPendingAction as jest.Mock).mockResolvedValue({
|
|
80
|
+
ok: true,
|
|
81
|
+
data: { ok: true, pendingAction: makeAction({ status: 'confirmed' }) },
|
|
82
|
+
})
|
|
83
|
+
;(cancelPendingAction as jest.Mock).mockResolvedValue({
|
|
84
|
+
ok: true,
|
|
85
|
+
data: { ok: true, pendingAction: makeAction({ status: 'cancelled' }) },
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('renders fieldDiff mode with before/after cells', () => {
|
|
90
|
+
installPollingMock(makeAction())
|
|
91
|
+
renderWithProviders(
|
|
92
|
+
<MutationPreviewCard
|
|
93
|
+
componentId="mutation-preview-card"
|
|
94
|
+
pendingActionId="pa-1"
|
|
95
|
+
/>,
|
|
96
|
+
{ dict },
|
|
97
|
+
)
|
|
98
|
+
expect(screen.getByText('Alice')).toBeInTheDocument()
|
|
99
|
+
expect(screen.getByText('Alicia')).toBeInTheDocument()
|
|
100
|
+
expect(document.querySelector('[data-ai-mutation-preview-mode]')?.getAttribute('data-ai-mutation-preview-mode')).toBe('single')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders records[] batch mode with count + labels summary', () => {
|
|
104
|
+
installPollingMock(
|
|
105
|
+
makeAction({
|
|
106
|
+
records: [
|
|
107
|
+
{ recordId: 'r-1', entityType: 'customers.person', label: 'Alice', fieldDiff: [] },
|
|
108
|
+
{ recordId: 'r-2', entityType: 'customers.person', label: 'Bob', fieldDiff: [] },
|
|
109
|
+
{ recordId: 'r-3', entityType: 'customers.person', label: 'Chris', fieldDiff: [] },
|
|
110
|
+
],
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
renderWithProviders(
|
|
114
|
+
<MutationPreviewCard
|
|
115
|
+
componentId="mutation-preview-card"
|
|
116
|
+
pendingActionId="pa-1"
|
|
117
|
+
/>,
|
|
118
|
+
{ dict },
|
|
119
|
+
)
|
|
120
|
+
expect(screen.getByText('3')).toBeInTheDocument()
|
|
121
|
+
expect(screen.getByText('records')).toBeInTheDocument()
|
|
122
|
+
expect(screen.getByText(/Alice, Bob, Chris/)).toBeInTheDocument()
|
|
123
|
+
expect(document.querySelector('[data-ai-mutation-preview-mode]')?.getAttribute('data-ai-mutation-preview-mode')).toBe('batch')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('Cmd+Enter triggers confirm; Escape triggers cancel', async () => {
|
|
127
|
+
installPollingMock(makeAction())
|
|
128
|
+
renderWithProviders(
|
|
129
|
+
<MutationPreviewCard
|
|
130
|
+
componentId="mutation-preview-card"
|
|
131
|
+
pendingActionId="pa-1"
|
|
132
|
+
/>,
|
|
133
|
+
{ dict },
|
|
134
|
+
)
|
|
135
|
+
const host = document.querySelector('[data-ai-mutation-preview]') as HTMLElement
|
|
136
|
+
expect(host).not.toBeNull()
|
|
137
|
+
|
|
138
|
+
fireEvent.keyDown(host, { key: 'Enter', metaKey: true })
|
|
139
|
+
await waitFor(() => expect(confirmPendingAction).toHaveBeenCalledWith('pa-1', expect.any(Object)))
|
|
140
|
+
|
|
141
|
+
// Card flipped to confirming — reinstall mock with fresh action and mount again for Escape.
|
|
142
|
+
;(confirmPendingAction as jest.Mock).mockClear()
|
|
143
|
+
installPollingMock(makeAction())
|
|
144
|
+
const { unmount } = renderWithProviders(
|
|
145
|
+
<MutationPreviewCard
|
|
146
|
+
componentId="mutation-preview-card"
|
|
147
|
+
pendingActionId="pa-2"
|
|
148
|
+
/>,
|
|
149
|
+
{ dict },
|
|
150
|
+
)
|
|
151
|
+
const hosts = document.querySelectorAll('[data-ai-mutation-preview]')
|
|
152
|
+
const host2 = hosts[hosts.length - 1] as HTMLElement
|
|
153
|
+
fireEvent.keyDown(host2, { key: 'Escape' })
|
|
154
|
+
await waitFor(() => expect(cancelPendingAction).toHaveBeenCalledWith('pa-2', expect.any(Object)))
|
|
155
|
+
unmount()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('Review details toggles the expanded diff section', async () => {
|
|
159
|
+
installPollingMock(makeAction())
|
|
160
|
+
renderWithProviders(
|
|
161
|
+
<MutationPreviewCard
|
|
162
|
+
componentId="mutation-preview-card"
|
|
163
|
+
pendingActionId="pa-1"
|
|
164
|
+
/>,
|
|
165
|
+
{ dict },
|
|
166
|
+
)
|
|
167
|
+
expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
|
|
168
|
+
fireEvent.click(screen.getByRole('button', { name: /Review details/i }))
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(document.querySelector('[data-ai-mutation-preview-details]')).not.toBeNull()
|
|
171
|
+
})
|
|
172
|
+
fireEvent.click(screen.getByRole('button', { name: /Review details/i }))
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
|
|
9
|
+
jest.mock('../useAiPendingActionPolling', () => ({
|
|
10
|
+
useAiPendingActionPolling: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
|
|
14
|
+
import { MutationResultCard } from '../MutationResultCard'
|
|
15
|
+
import type { AiPendingActionCardAction } from '../types'
|
|
16
|
+
|
|
17
|
+
const dict = {
|
|
18
|
+
'ai_assistant.chat.mutation_cards.result.successTitle': 'Action applied',
|
|
19
|
+
'ai_assistant.chat.mutation_cards.result.successBody': 'The mutation completed successfully.',
|
|
20
|
+
'ai_assistant.chat.mutation_cards.result.successWithCommand': 'Completed',
|
|
21
|
+
'ai_assistant.chat.mutation_cards.result.viewRecord': 'View record',
|
|
22
|
+
'ai_assistant.chat.mutation_cards.result.partialTitle': 'Action applied with failures',
|
|
23
|
+
'ai_assistant.chat.mutation_cards.result.partialBody': 'Some records could not be updated.',
|
|
24
|
+
'ai_assistant.chat.mutation_cards.result.failureTitle': 'Action failed',
|
|
25
|
+
'ai_assistant.chat.mutation_cards.result.failureBody': 'The mutation could not be applied.',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function baseAction(
|
|
29
|
+
overrides: Partial<AiPendingActionCardAction> = {},
|
|
30
|
+
): AiPendingActionCardAction {
|
|
31
|
+
return {
|
|
32
|
+
id: 'pa-1',
|
|
33
|
+
agentId: 'customers.account_assistant',
|
|
34
|
+
toolName: 'customers.update_person',
|
|
35
|
+
status: 'confirmed',
|
|
36
|
+
fieldDiff: [],
|
|
37
|
+
records: null,
|
|
38
|
+
failedRecords: null,
|
|
39
|
+
sideEffectsSummary: null,
|
|
40
|
+
attachmentIds: [],
|
|
41
|
+
targetEntityType: 'customers.person',
|
|
42
|
+
targetRecordId: 'p-1',
|
|
43
|
+
recordVersion: '1',
|
|
44
|
+
executionResult: { recordId: 'p-1', commandName: 'customers.updatePerson' },
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
expiresAt: new Date(Date.now() + 10_000).toISOString(),
|
|
47
|
+
resolvedAt: new Date().toISOString(),
|
|
48
|
+
resolvedByUserId: 'user-1',
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installPollingMock(action: AiPendingActionCardAction | null) {
|
|
54
|
+
;(useAiPendingActionPolling as jest.Mock).mockReturnValue({
|
|
55
|
+
action,
|
|
56
|
+
status: action?.status ?? null,
|
|
57
|
+
isPolling: false,
|
|
58
|
+
error: null,
|
|
59
|
+
refresh: jest.fn().mockResolvedValue(action),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('MutationResultCard', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
;(useAiPendingActionPolling as jest.Mock).mockReset()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders the success variant + record link', () => {
|
|
69
|
+
installPollingMock(baseAction())
|
|
70
|
+
renderWithProviders(
|
|
71
|
+
<MutationResultCard
|
|
72
|
+
componentId="mutation-result-card"
|
|
73
|
+
pendingActionId="pa-1"
|
|
74
|
+
payload={{ recordHref: '/backend/customers/people/p-1' }}
|
|
75
|
+
/>,
|
|
76
|
+
{ dict },
|
|
77
|
+
)
|
|
78
|
+
expect(
|
|
79
|
+
document.querySelector('[data-ai-mutation-result="success"]'),
|
|
80
|
+
).not.toBeNull()
|
|
81
|
+
expect(screen.getByText('Action applied')).toBeInTheDocument()
|
|
82
|
+
const link = document.querySelector('[data-ai-mutation-result-link]') as HTMLAnchorElement
|
|
83
|
+
expect(link).not.toBeNull()
|
|
84
|
+
expect(link.getAttribute('href')).toBe('/backend/customers/people/p-1')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders the partial success variant + list of failed records', () => {
|
|
88
|
+
installPollingMock(
|
|
89
|
+
baseAction({
|
|
90
|
+
failedRecords: [
|
|
91
|
+
{ recordId: 'r-1', error: { code: 'stale_version', message: 'changed' } },
|
|
92
|
+
{ recordId: 'r-2', error: { code: 'validation_error', message: 'bad name' } },
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
renderWithProviders(
|
|
97
|
+
<MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
|
|
98
|
+
{ dict },
|
|
99
|
+
)
|
|
100
|
+
expect(
|
|
101
|
+
document.querySelector('[data-ai-mutation-result="partial"]'),
|
|
102
|
+
).not.toBeNull()
|
|
103
|
+
expect(
|
|
104
|
+
document.querySelectorAll('[data-ai-mutation-failed-record]').length,
|
|
105
|
+
).toBe(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders the destructive failure variant with the error code', () => {
|
|
109
|
+
installPollingMock(
|
|
110
|
+
baseAction({
|
|
111
|
+
status: 'failed',
|
|
112
|
+
executionResult: {
|
|
113
|
+
error: { code: 'tool_execution_failed', message: 'Internal error' },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
renderWithProviders(
|
|
118
|
+
<MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
|
|
119
|
+
{ dict },
|
|
120
|
+
)
|
|
121
|
+
expect(
|
|
122
|
+
document.querySelector('[data-ai-mutation-result="failure"]'),
|
|
123
|
+
).not.toBeNull()
|
|
124
|
+
const code = document.querySelector('[data-ai-mutation-result-code]')
|
|
125
|
+
expect(code?.textContent).toBe('tool_execution_failed')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../../backend/utils/apiCall', () => ({
|
|
9
|
+
apiCallOrThrow: jest.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { apiCallOrThrow } from '../../../backend/utils/apiCall'
|
|
13
|
+
import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
|
|
14
|
+
import type { AiPendingActionCardAction, AiPendingActionCardStatus } from '../types'
|
|
15
|
+
|
|
16
|
+
function makeAction(
|
|
17
|
+
overrides: Partial<AiPendingActionCardAction> = {},
|
|
18
|
+
): AiPendingActionCardAction {
|
|
19
|
+
return {
|
|
20
|
+
id: 'pa-1',
|
|
21
|
+
agentId: 'customers.account_assistant',
|
|
22
|
+
toolName: 'customers.update_person',
|
|
23
|
+
status: 'pending',
|
|
24
|
+
fieldDiff: [],
|
|
25
|
+
records: null,
|
|
26
|
+
failedRecords: null,
|
|
27
|
+
sideEffectsSummary: null,
|
|
28
|
+
attachmentIds: [],
|
|
29
|
+
targetEntityType: 'customers.person',
|
|
30
|
+
targetRecordId: 'p-1',
|
|
31
|
+
recordVersion: '1',
|
|
32
|
+
executionResult: null,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
expiresAt: new Date(Date.now() + 10_000).toISOString(),
|
|
35
|
+
resolvedAt: null,
|
|
36
|
+
resolvedByUserId: null,
|
|
37
|
+
...overrides,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mockResponse(action: AiPendingActionCardAction) {
|
|
42
|
+
;(apiCallOrThrow as jest.Mock).mockImplementation(async () => ({
|
|
43
|
+
ok: true,
|
|
44
|
+
status: 200,
|
|
45
|
+
result: { pendingAction: action },
|
|
46
|
+
response: {},
|
|
47
|
+
cacheStatus: null,
|
|
48
|
+
}))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('useAiPendingActionPolling', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
;(apiCallOrThrow as jest.Mock).mockReset()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('fetches on mount and continues polling while status is pending', async () => {
|
|
57
|
+
let status: AiPendingActionCardStatus = 'pending'
|
|
58
|
+
;(apiCallOrThrow as jest.Mock).mockImplementation(async () => ({
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 200,
|
|
61
|
+
result: { pendingAction: makeAction({ status }) },
|
|
62
|
+
response: {},
|
|
63
|
+
cacheStatus: null,
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
jest.useFakeTimers()
|
|
67
|
+
try {
|
|
68
|
+
const { result, unmount } = renderHook(() =>
|
|
69
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// Flush the promise chain kicked off on mount.
|
|
73
|
+
await act(async () => {
|
|
74
|
+
await Promise.resolve()
|
|
75
|
+
await Promise.resolve()
|
|
76
|
+
})
|
|
77
|
+
expect(apiCallOrThrow).toHaveBeenCalledTimes(1)
|
|
78
|
+
expect(result.current.status).toBe('pending')
|
|
79
|
+
|
|
80
|
+
await act(async () => {
|
|
81
|
+
jest.advanceTimersByTime(1000)
|
|
82
|
+
await Promise.resolve()
|
|
83
|
+
await Promise.resolve()
|
|
84
|
+
})
|
|
85
|
+
expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
86
|
+
|
|
87
|
+
status = 'confirmed'
|
|
88
|
+
await act(async () => {
|
|
89
|
+
jest.advanceTimersByTime(1000)
|
|
90
|
+
await Promise.resolve()
|
|
91
|
+
await Promise.resolve()
|
|
92
|
+
})
|
|
93
|
+
expect(result.current.status).toBe('confirmed')
|
|
94
|
+
const callsAfterTerminal = (apiCallOrThrow as jest.Mock).mock.calls.length
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
jest.advanceTimersByTime(5000)
|
|
98
|
+
await Promise.resolve()
|
|
99
|
+
})
|
|
100
|
+
expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBe(callsAfterTerminal)
|
|
101
|
+
expect(result.current.isPolling).toBe(false)
|
|
102
|
+
|
|
103
|
+
unmount()
|
|
104
|
+
} finally {
|
|
105
|
+
jest.useRealTimers()
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('refresh() force-fetches and updates state', async () => {
|
|
110
|
+
mockResponse(makeAction({ status: 'pending' }))
|
|
111
|
+
const { result } = renderHook(() =>
|
|
112
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 99999 }),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(result.current.action?.status).toBe('pending'))
|
|
116
|
+
|
|
117
|
+
;(apiCallOrThrow as jest.Mock).mockImplementationOnce(async () => ({
|
|
118
|
+
ok: true,
|
|
119
|
+
status: 200,
|
|
120
|
+
result: { pendingAction: makeAction({ status: 'confirmed' }) },
|
|
121
|
+
response: {},
|
|
122
|
+
cacheStatus: null,
|
|
123
|
+
}))
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await result.current.refresh()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result.current.action?.status).toBe('confirmed')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('clears outstanding timers when unmounted mid-poll', async () => {
|
|
133
|
+
jest.useFakeTimers()
|
|
134
|
+
try {
|
|
135
|
+
mockResponse(makeAction({ status: 'pending' }))
|
|
136
|
+
const { unmount } = renderHook(() =>
|
|
137
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
138
|
+
)
|
|
139
|
+
await waitFor(() => expect(apiCallOrThrow).toHaveBeenCalledTimes(1))
|
|
140
|
+
unmount()
|
|
141
|
+
const callsAtUnmount = (apiCallOrThrow as jest.Mock).mock.calls.length
|
|
142
|
+
// Advance well past several intervals — no more calls must be fired.
|
|
143
|
+
await act(async () => {
|
|
144
|
+
jest.advanceTimersByTime(10_000)
|
|
145
|
+
})
|
|
146
|
+
expect((apiCallOrThrow as jest.Mock).mock.calls.length).toBe(callsAtUnmount)
|
|
147
|
+
} finally {
|
|
148
|
+
jest.useRealTimers()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { AiUiPartComponent, AiUiPartProps } from '../ui-part-registry'
|
|
4
|
+
import { MutationPreviewCard } from './MutationPreviewCard'
|
|
5
|
+
import { FieldDiffCard } from './FieldDiffCard'
|
|
6
|
+
import { ConfirmationCard } from './ConfirmationCard'
|
|
7
|
+
import { MutationResultCard } from './MutationResultCard'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonical map of the four Phase 3 mutation-approval cards keyed by their
|
|
11
|
+
* reserved registry component ids. Consumers can spread this into any
|
|
12
|
+
* `AiUiPartRegistry` to wire the live cards at once. Isolated from
|
|
13
|
+
* `parts/index.ts` so the `ui-part-registry` module can import the map
|
|
14
|
+
* without importing the barrel (which would re-export back into the same
|
|
15
|
+
* module and trip Node's "Cannot access before initialization" ordering).
|
|
16
|
+
*/
|
|
17
|
+
export const AI_MUTATION_APPROVAL_CARDS: Readonly<
|
|
18
|
+
Record<string, AiUiPartComponent<AiUiPartProps>>
|
|
19
|
+
> = Object.freeze({
|
|
20
|
+
'mutation-preview-card': MutationPreviewCard as AiUiPartComponent<AiUiPartProps>,
|
|
21
|
+
'field-diff-card': FieldDiffCard as unknown as AiUiPartComponent<AiUiPartProps>,
|
|
22
|
+
'confirmation-card': ConfirmationCard as AiUiPartComponent<AiUiPartProps>,
|
|
23
|
+
'mutation-result-card': MutationResultCard as AiUiPartComponent<AiUiPartProps>,
|
|
24
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
export { MutationPreviewCard } from './MutationPreviewCard'
|
|
4
|
+
export { FieldDiffCard } from './FieldDiffCard'
|
|
5
|
+
export { ConfirmationCard } from './ConfirmationCard'
|
|
6
|
+
export { MutationResultCard } from './MutationResultCard'
|
|
7
|
+
export {
|
|
8
|
+
useAiPendingActionPolling,
|
|
9
|
+
type UseAiPendingActionPollingOptions,
|
|
10
|
+
type UseAiPendingActionPollingResult,
|
|
11
|
+
} from './useAiPendingActionPolling'
|
|
12
|
+
export {
|
|
13
|
+
confirmPendingAction,
|
|
14
|
+
cancelPendingAction,
|
|
15
|
+
type PendingActionMutationOk,
|
|
16
|
+
type PendingActionMutationError,
|
|
17
|
+
type PendingActionMutationResult,
|
|
18
|
+
} from './pending-action-api'
|
|
19
|
+
export type {
|
|
20
|
+
AiPendingActionCardAction,
|
|
21
|
+
AiPendingActionCardStatus,
|
|
22
|
+
AiPendingActionCardFieldDiff,
|
|
23
|
+
AiPendingActionCardRecordDiff,
|
|
24
|
+
AiPendingActionCardFailedRecord,
|
|
25
|
+
AiPendingActionCardExecutionResult,
|
|
26
|
+
} from './types'
|
|
27
|
+
export { AI_MUTATION_APPROVAL_CARDS } from './approval-cards-map'
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
4
|
+
import type { AiPendingActionCardAction } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thin client wrappers over the pending-action confirm/cancel routes
|
|
8
|
+
* (Steps 5.8 / 5.9). Kept here so the mutation-approval cards (Step 5.10)
|
|
9
|
+
* can thread structured error envelopes — especially the 412 `stale_version`
|
|
10
|
+
* / 412 `schema_drift` / 409 `invalid_status` shapes — back into the UI
|
|
11
|
+
* without each card reimplementing the same fetch boilerplate.
|
|
12
|
+
*
|
|
13
|
+
* `apiCall` is used (not `apiCallOrThrow`) because the cards need the
|
|
14
|
+
* non-2xx response body (`{ error, code, failedRecords?, issues? }`) to
|
|
15
|
+
* surface a targeted alert, not a generic thrown error.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PendingActionMutationOk = {
|
|
19
|
+
ok: boolean
|
|
20
|
+
pendingAction: AiPendingActionCardAction
|
|
21
|
+
mutationResult?: AiPendingActionCardAction['executionResult']
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PendingActionMutationError = {
|
|
25
|
+
status: number
|
|
26
|
+
code?: string
|
|
27
|
+
message: string
|
|
28
|
+
extra?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type PendingActionMutationResult =
|
|
32
|
+
| { ok: true; data: PendingActionMutationOk }
|
|
33
|
+
| { ok: false; error: PendingActionMutationError }
|
|
34
|
+
|
|
35
|
+
// Hard ceiling for confirm/cancel calls. The dispatcher's mutation gate runs
|
|
36
|
+
// the wrapped tool handler synchronously inside the POST, so for slow
|
|
37
|
+
// providers (LLM-backed handlers, large bulk batches, external APIs) we still
|
|
38
|
+
// give it ~1 minute. Going longer than this is almost always a server-side
|
|
39
|
+
// hang and the operator should see an error rather than an indefinite
|
|
40
|
+
// "processing…" spinner.
|
|
41
|
+
const POST_JSON_TIMEOUT_MS = 60_000
|
|
42
|
+
|
|
43
|
+
async function postJson(
|
|
44
|
+
url: string,
|
|
45
|
+
body?: unknown,
|
|
46
|
+
): Promise<PendingActionMutationResult> {
|
|
47
|
+
const controller = new AbortController()
|
|
48
|
+
const timer =
|
|
49
|
+
typeof window !== 'undefined'
|
|
50
|
+
? window.setTimeout(() => controller.abort(), POST_JSON_TIMEOUT_MS)
|
|
51
|
+
: null
|
|
52
|
+
try {
|
|
53
|
+
const call = await apiCall<Record<string, unknown>>(url, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
56
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
} as RequestInit)
|
|
59
|
+
if (call.ok) {
|
|
60
|
+
const data = call.result as PendingActionMutationOk
|
|
61
|
+
return { ok: true, data }
|
|
62
|
+
}
|
|
63
|
+
const raw = (call.result ?? {}) as {
|
|
64
|
+
error?: unknown
|
|
65
|
+
code?: unknown
|
|
66
|
+
[key: string]: unknown
|
|
67
|
+
}
|
|
68
|
+
const errorMessage =
|
|
69
|
+
typeof raw.error === 'string' && raw.error.length > 0
|
|
70
|
+
? raw.error
|
|
71
|
+
: `Request failed (${call.status}).`
|
|
72
|
+
const code = typeof raw.code === 'string' ? raw.code : undefined
|
|
73
|
+
const { error: _err, code: _code, ...extra } = raw
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: {
|
|
77
|
+
status: call.status,
|
|
78
|
+
code,
|
|
79
|
+
message: errorMessage,
|
|
80
|
+
extra: Object.keys(extra).length > 0 ? extra : undefined,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Aborted (timeout) or network error. Surface a structured envelope so
|
|
85
|
+
// the card can render the failure inline instead of stalling.
|
|
86
|
+
const aborted =
|
|
87
|
+
(err as { name?: string } | null)?.name === 'AbortError' ||
|
|
88
|
+
controller.signal.aborted
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: {
|
|
92
|
+
status: aborted ? 408 : 0,
|
|
93
|
+
code: aborted ? 'request_timeout' : 'network_error',
|
|
94
|
+
message: aborted
|
|
95
|
+
? `Request timed out after ${Math.round(POST_JSON_TIMEOUT_MS / 1000)}s.`
|
|
96
|
+
: err instanceof Error
|
|
97
|
+
? err.message
|
|
98
|
+
: 'Network error contacting the AI dispatcher.',
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
if (timer !== null) window.clearTimeout(timer)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function confirmPendingAction(
|
|
107
|
+
pendingActionId: string,
|
|
108
|
+
options?: { endpoint?: string },
|
|
109
|
+
): Promise<PendingActionMutationResult> {
|
|
110
|
+
const base = options?.endpoint ?? '/api/ai_assistant/ai/actions'
|
|
111
|
+
const url = `${base}/${encodeURIComponent(pendingActionId)}/confirm`
|
|
112
|
+
return postJson(url)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function cancelPendingAction(
|
|
116
|
+
pendingActionId: string,
|
|
117
|
+
options?: { endpoint?: string; reason?: string },
|
|
118
|
+
): Promise<PendingActionMutationResult> {
|
|
119
|
+
const base = options?.endpoint ?? '/api/ai_assistant/ai/actions'
|
|
120
|
+
const url = `${base}/${encodeURIComponent(pendingActionId)}/cancel`
|
|
121
|
+
const body = options?.reason ? { reason: options.reason } : undefined
|
|
122
|
+
return postJson(url, body)
|
|
123
|
+
}
|