@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,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
+ }