@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,163 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, render } from '@testing-library/react'
7
+ import { useAiChatUpload, type UseAiChatUploadState } from '../useAiChatUpload'
8
+
9
+ function jsonResponse(status: number, body: unknown): Response {
10
+ const payload = typeof body === 'string' ? body : JSON.stringify(body)
11
+ return new Response(payload, {
12
+ status,
13
+ headers: { 'content-type': 'application/json' },
14
+ })
15
+ }
16
+
17
+ function makeFile(name: string, content = 'hello', type = 'text/plain'): File {
18
+ return new File([content], name, { type })
19
+ }
20
+
21
+ interface HarnessProps {
22
+ fetchImpl: typeof fetch
23
+ signal?: AbortSignal
24
+ onState?: (state: UseAiChatUploadState) => void
25
+ }
26
+
27
+ function HookHarness({ fetchImpl, signal, onState }: HarnessProps) {
28
+ const state = useAiChatUpload({ fetchImpl, signal })
29
+ React.useEffect(() => {
30
+ onState?.(state)
31
+ })
32
+ return null
33
+ }
34
+
35
+ describe('useAiChatUpload', () => {
36
+ it('toggles busy and writes per-file done status on success', async () => {
37
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
38
+ const form = init?.body as FormData
39
+ const file = form.get('file') as File
40
+ return jsonResponse(200, {
41
+ ok: true,
42
+ item: {
43
+ id: `att_${file.name}`,
44
+ fileName: file.name,
45
+ fileSize: file.size,
46
+ mimeType: file.type || 'text/plain',
47
+ },
48
+ })
49
+ }) as unknown as typeof fetch
50
+
51
+ const snapshots: UseAiChatUploadState[] = []
52
+ render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
53
+ const latest = () => snapshots[snapshots.length - 1]
54
+
55
+ await act(async () => {
56
+ await latest().upload([makeFile('a.txt'), makeFile('b.txt')])
57
+ })
58
+
59
+ const final = latest()
60
+ expect(final.busy).toBe(false)
61
+ expect(final.files).toHaveLength(2)
62
+ expect(final.files.every((entry) => entry.status === 'done')).toBe(true)
63
+ expect(final.files.map((entry) => entry.attachmentId)).toEqual(['att_a.txt', 'att_b.txt'])
64
+ expect(final.overallProgress).toBe(1)
65
+ })
66
+
67
+ it('averages overallProgress across files', async () => {
68
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
69
+ const form = init?.body as FormData
70
+ const file = form.get('file') as File
71
+ if (file.name === 'fail.txt') {
72
+ return jsonResponse(413, { error: 'Attachment exceeds the maximum upload size.' })
73
+ }
74
+ return jsonResponse(200, {
75
+ ok: true,
76
+ item: {
77
+ id: `att_${file.name}`,
78
+ fileName: file.name,
79
+ fileSize: file.size,
80
+ mimeType: 'text/plain',
81
+ },
82
+ })
83
+ }) as unknown as typeof fetch
84
+
85
+ const snapshots: UseAiChatUploadState[] = []
86
+ render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
87
+ const latest = () => snapshots[snapshots.length - 1]
88
+
89
+ await act(async () => {
90
+ await latest().upload([makeFile('ok.txt'), makeFile('fail.txt')])
91
+ })
92
+
93
+ const final = latest()
94
+ // One done at 1.0, one error at 0.0 → average 0.5.
95
+ expect(final.overallProgress).toBeCloseTo(0.5, 5)
96
+ expect(final.files[0].status).toBe('done')
97
+ expect(final.files[1].status).toBe('error')
98
+ expect(final.files[1].reason).toBe('size_exceeded')
99
+ })
100
+
101
+ it('reset() clears state', async () => {
102
+ const fetchImpl = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
103
+ const form = init?.body as FormData
104
+ const file = form.get('file') as File
105
+ return jsonResponse(200, {
106
+ ok: true,
107
+ item: {
108
+ id: `att_${file.name}`,
109
+ fileName: file.name,
110
+ fileSize: file.size,
111
+ mimeType: 'text/plain',
112
+ },
113
+ })
114
+ }) as unknown as typeof fetch
115
+
116
+ const snapshots: UseAiChatUploadState[] = []
117
+ render(<HookHarness fetchImpl={fetchImpl} onState={(s) => snapshots.push(s)} />)
118
+ const latest = () => snapshots[snapshots.length - 1]
119
+
120
+ await act(async () => {
121
+ await latest().upload([makeFile('a.txt')])
122
+ })
123
+ expect(latest().files).toHaveLength(1)
124
+
125
+ act(() => {
126
+ latest().reset()
127
+ })
128
+
129
+ const final = latest()
130
+ expect(final.files).toEqual([])
131
+ expect(final.busy).toBe(false)
132
+ expect(final.overallProgress).toBe(0)
133
+ })
134
+
135
+ it('propagates AbortSignal into per-file aborted status', async () => {
136
+ const controller = new AbortController()
137
+ const fetchImpl = jest.fn(async () => {
138
+ controller.abort()
139
+ const error = new Error('aborted') as Error & { name: string }
140
+ error.name = 'AbortError'
141
+ throw error
142
+ }) as unknown as typeof fetch
143
+
144
+ const snapshots: UseAiChatUploadState[] = []
145
+ render(
146
+ <HookHarness
147
+ fetchImpl={fetchImpl}
148
+ signal={controller.signal}
149
+ onState={(s) => snapshots.push(s)}
150
+ />,
151
+ )
152
+ const latest = () => snapshots[snapshots.length - 1]
153
+
154
+ await act(async () => {
155
+ await latest().upload([makeFile('a.txt'), makeFile('b.txt')])
156
+ })
157
+
158
+ const final = latest()
159
+ expect(final.busy).toBe(false)
160
+ expect(final.files.every((entry) => entry.status === 'error')).toBe(true)
161
+ expect(final.files.every((entry) => entry.reason === 'aborted')).toBe(true)
162
+ })
163
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { useAiShortcuts } from '../useAiShortcuts'
8
+
9
+ function Harness({
10
+ onSubmit,
11
+ onCancel,
12
+ enabled,
13
+ }: {
14
+ onSubmit?: () => void
15
+ onCancel?: () => void
16
+ enabled?: boolean
17
+ }) {
18
+ const { handleKeyDown } = useAiShortcuts({ onSubmit, onCancel, enabled })
19
+ return (
20
+ <textarea aria-label="shortcuts harness" onKeyDown={handleKeyDown} />
21
+ )
22
+ }
23
+
24
+ describe('useAiShortcuts', () => {
25
+ it('calls onSubmit on plain Enter and prevents default', () => {
26
+ const onSubmit = jest.fn()
27
+ render(<Harness onSubmit={onSubmit} />)
28
+ const textarea = screen.getByLabelText('shortcuts harness')
29
+
30
+ const result = fireEvent.keyDown(textarea, { key: 'Enter' })
31
+ // `fireEvent.keyDown` returns `false` when the handler called preventDefault.
32
+ expect(result).toBe(false)
33
+ expect(onSubmit).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ it('calls onSubmit on Cmd+Enter (still works for power users)', () => {
37
+ const onSubmit = jest.fn()
38
+ render(<Harness onSubmit={onSubmit} />)
39
+ fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
40
+ key: 'Enter',
41
+ metaKey: true,
42
+ })
43
+ expect(onSubmit).toHaveBeenCalledTimes(1)
44
+ })
45
+
46
+ it('calls onSubmit on Ctrl+Enter (still works for power users)', () => {
47
+ const onSubmit = jest.fn()
48
+ render(<Harness onSubmit={onSubmit} />)
49
+ fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
50
+ key: 'Enter',
51
+ ctrlKey: true,
52
+ })
53
+ expect(onSubmit).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('does not fire submit on Shift+Enter (native newline behavior preserved)', () => {
57
+ const onSubmit = jest.fn()
58
+ render(<Harness onSubmit={onSubmit} />)
59
+ fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), {
60
+ key: 'Enter',
61
+ shiftKey: true,
62
+ })
63
+ expect(onSubmit).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('calls onCancel on Escape', () => {
67
+ const onCancel = jest.fn()
68
+ render(<Harness onCancel={onCancel} />)
69
+ fireEvent.keyDown(screen.getByLabelText('shortcuts harness'), { key: 'Escape' })
70
+ expect(onCancel).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('does not fire when disabled', () => {
74
+ const onSubmit = jest.fn()
75
+ const onCancel = jest.fn()
76
+ render(<Harness onSubmit={onSubmit} onCancel={onCancel} enabled={false} />)
77
+ const textarea = screen.getByLabelText('shortcuts harness')
78
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
79
+ fireEvent.keyDown(textarea, { key: 'Escape' })
80
+ expect(onSubmit).not.toHaveBeenCalled()
81
+ expect(onCancel).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('does not swallow Escape when no onCancel is bound', () => {
85
+ render(<Harness />)
86
+ const textarea = screen.getByLabelText('shortcuts harness')
87
+ const result = fireEvent.keyDown(textarea, { key: 'Escape' })
88
+ // preventDefault NOT called — native Escape bubbles.
89
+ expect(result).toBe(true)
90
+ })
91
+
92
+ it('does not double-fire when keyDown is dispatched twice', () => {
93
+ const onSubmit = jest.fn()
94
+ render(<Harness onSubmit={onSubmit} />)
95
+ const textarea = screen.getByLabelText('shortcuts harness')
96
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
97
+ fireEvent.keyDown(textarea, { key: 'a' })
98
+ expect(onSubmit).toHaveBeenCalledTimes(1)
99
+ })
100
+ })
@@ -0,0 +1,125 @@
1
+ export { AiChat, type AiChatProps, type AiChatDebugTool, type AiChatDebugPromptSection } from './AiChat'
2
+ export {
3
+ AiAssistantLauncher,
4
+ type AiAssistantLauncherAgent,
5
+ type AiAssistantLauncherProps,
6
+ } from './AiAssistantLauncher'
7
+ export {
8
+ AiDockProvider,
9
+ useAiDock,
10
+ type AiDockedAssistant,
11
+ } from './AiDock'
12
+ export {
13
+ AiChatSessionsProvider,
14
+ useAiChatSessions,
15
+ defaultSessionLabel,
16
+ type AiChatSession,
17
+ } from './AiChatSessions'
18
+ export { ChatPaneTabs } from './ChatPaneTabs'
19
+ export {
20
+ useAiChat,
21
+ type AiChatMessage,
22
+ type AiChatToolCallSnapshot,
23
+ type AiChatMessageFile,
24
+ type AiChatErrorEnvelope,
25
+ type UseAiChatInput,
26
+ type UseAiChatResult,
27
+ } from './useAiChat'
28
+ export {
29
+ useAiShortcuts,
30
+ type UseAiShortcutsOptions,
31
+ type UseAiShortcutsResult,
32
+ } from './useAiShortcuts'
33
+ export {
34
+ registerAiUiPart,
35
+ resolveAiUiPart,
36
+ unregisterAiUiPart,
37
+ resetAiUiPartRegistryForTests,
38
+ listAiUiParts,
39
+ createAiUiPartRegistry,
40
+ defaultAiUiPartRegistry,
41
+ RESERVED_AI_UI_PART_IDS,
42
+ isReservedAiUiPartId,
43
+ type AiUiPartComponent,
44
+ type AiUiPartComponentId,
45
+ type AiUiPartProps,
46
+ type AiUiPartRegistry,
47
+ type AiUiPartRegistryEntry,
48
+ type CreateAiUiPartRegistryOptions,
49
+ type ReservedAiUiPartId,
50
+ } from './ui-part-registry'
51
+ export { PendingPhase3Placeholder } from './ui-parts/pending-phase3-placeholder'
52
+ export {
53
+ MutationPreviewCard,
54
+ FieldDiffCard,
55
+ ConfirmationCard,
56
+ MutationResultCard,
57
+ AI_MUTATION_APPROVAL_CARDS,
58
+ useAiPendingActionPolling,
59
+ confirmPendingAction,
60
+ cancelPendingAction,
61
+ type UseAiPendingActionPollingOptions,
62
+ type UseAiPendingActionPollingResult,
63
+ type PendingActionMutationOk,
64
+ type PendingActionMutationError,
65
+ type PendingActionMutationResult,
66
+ type AiPendingActionCardAction,
67
+ type AiPendingActionCardStatus,
68
+ type AiPendingActionCardFieldDiff,
69
+ type AiPendingActionCardRecordDiff,
70
+ type AiPendingActionCardFailedRecord,
71
+ type AiPendingActionCardExecutionResult,
72
+ } from './parts'
73
+ export {
74
+ uploadAttachmentsForChat,
75
+ type UploadAttachmentsForChatOptions,
76
+ type UploadAttachmentsForChatResult,
77
+ type UploadedAttachment,
78
+ type UploadFailure,
79
+ type UploadFailureReason,
80
+ } from './upload-adapter'
81
+ export {
82
+ RecordCard,
83
+ DealCard,
84
+ PersonCard,
85
+ CompanyCard,
86
+ ProductCard,
87
+ ActivityCard,
88
+ RecordCardShell,
89
+ KeyValueList,
90
+ TagRow,
91
+ statusToTagVariant,
92
+ type RecordCardProps,
93
+ type RecordCardShellProps,
94
+ type KeyValueListItem,
95
+ type DealCardProps,
96
+ type PersonCardProps,
97
+ type CompanyCardProps,
98
+ type ProductCardProps,
99
+ type ActivityCardProps,
100
+ type RecordCardKind,
101
+ type RecordCardPayload,
102
+ type RecordCardBaseProps,
103
+ type DealRecordPayload,
104
+ type PersonRecordPayload,
105
+ type CompanyRecordPayload,
106
+ type ProductRecordPayload,
107
+ type ActivityRecordPayload,
108
+ registerRecordCardUiParts,
109
+ RECORD_CARD_COMPONENT_IDS,
110
+ type RecordCardComponentId,
111
+ } from './records'
112
+ export {
113
+ AiMessageContent,
114
+ parseAiContentSegments,
115
+ RECORD_CARD_FENCE_INFO_PREFIX,
116
+ type AiMessageContentSegment,
117
+ type AiMessageContentProps,
118
+ } from './AiMessageContent'
119
+ export {
120
+ useAiChatUpload,
121
+ type UseAiChatUploadOptions,
122
+ type UseAiChatUploadState,
123
+ type AiChatUploadFileState,
124
+ type AiChatUploadFileStatus,
125
+ } from './useAiChatUpload'
@@ -0,0 +1,310 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { AlertTriangle, Loader2 } 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 { Spinner } from '../../primitives/spinner'
9
+ import { useAiShortcuts } from '../useAiShortcuts'
10
+ import type { AiUiPartProps } from '../ui-part-registry'
11
+ import { cancelPendingAction } from './pending-action-api'
12
+ import { useAiPendingActionPolling } from './useAiPendingActionPolling'
13
+ import { MutationResultCard } from './MutationResultCard'
14
+ import type { AiPendingActionCardAction } from './types'
15
+
16
+ /**
17
+ * Confirmation / in-flight card rendered after the user clicks `Confirm` on
18
+ * the preview card. Shows a spinner + the side-effects summary while the
19
+ * server runs the re-check contract and executes the wrapped tool.
20
+ *
21
+ * The user can race the confirm by clicking Cancel — but only while the
22
+ * server has not yet flipped the row to `executing`. The polling hook
23
+ * drives the disable logic.
24
+ *
25
+ * Surfaces structured error envelopes from the confirm route: 412
26
+ * `stale_version` (records changed since preview) and 412 `schema_drift`
27
+ * (tool input schema changed) render targeted alerts with the specific
28
+ * recovery copy. Keyboard: `Escape` triggers Cancel; `Cmd/Ctrl+Enter` is
29
+ * intentionally inert (the user already confirmed).
30
+ *
31
+ * Terminal states flip this card into a {@link MutationResultCard} render
32
+ * so the chat transcript does not need two separate cards for confirmed
33
+ * vs pending.
34
+ */
35
+ export interface ConfirmationCardPayload {
36
+ sideEffectsSummary?: string | null
37
+ pendingAction?: AiPendingActionCardAction
38
+ confirmError?: {
39
+ status: number
40
+ code?: string
41
+ message: string
42
+ extra?: Record<string, unknown>
43
+ }
44
+ }
45
+
46
+ export interface ConfirmationCardProps extends AiUiPartProps {
47
+ /** Optional override for tests — bypasses the polling fetch. */
48
+ initialAction?: AiPendingActionCardAction
49
+ /** Endpoint override (tests). */
50
+ endpoint?: string
51
+ /** Optional cancel handler override (tests). */
52
+ onCancel?: () => Promise<void> | void
53
+ }
54
+
55
+ export function ConfirmationCard(props: ConfirmationCardProps) {
56
+ const t = useT()
57
+ const pendingActionId = props.pendingActionId ?? ''
58
+ const payload = (props.payload as ConfirmationCardPayload | undefined) ?? {}
59
+ const injected = props.initialAction ?? payload.pendingAction ?? null
60
+
61
+ const { action, status, refresh } = useAiPendingActionPolling({
62
+ pendingActionId,
63
+ endpoint: props.endpoint,
64
+ disabled: !pendingActionId,
65
+ })
66
+
67
+ const effectiveAction = action ?? injected ?? null
68
+ const effectiveStatus = effectiveAction?.status ?? status
69
+
70
+ const [isCancelling, setIsCancelling] = React.useState(false)
71
+ const [localError, setLocalError] = React.useState<{
72
+ code?: string
73
+ message: string
74
+ extra?: Record<string, unknown>
75
+ } | null>(
76
+ payload.confirmError
77
+ ? { code: payload.confirmError.code, message: payload.confirmError.message, extra: payload.confirmError.extra }
78
+ : null,
79
+ )
80
+
81
+ const canCancel =
82
+ !isCancelling &&
83
+ (effectiveStatus === 'pending' || effectiveStatus === 'confirmed' || effectiveStatus == null)
84
+
85
+ const handleCancel = React.useCallback(async () => {
86
+ if (!canCancel) return
87
+ if (props.onCancel) {
88
+ await props.onCancel()
89
+ return
90
+ }
91
+ if (!pendingActionId) return
92
+ setIsCancelling(true)
93
+ try {
94
+ const result = await cancelPendingAction(pendingActionId, {
95
+ endpoint: props.endpoint,
96
+ })
97
+ if (!result.ok) {
98
+ setLocalError({
99
+ code: result.error.code,
100
+ message: result.error.message,
101
+ extra: result.error.extra,
102
+ })
103
+ }
104
+ await refresh()
105
+ } finally {
106
+ setIsCancelling(false)
107
+ }
108
+ }, [canCancel, pendingActionId, props, refresh])
109
+
110
+ const { handleKeyDown } = useAiShortcuts({
111
+ onCancel: () => {
112
+ void handleCancel()
113
+ },
114
+ enabled: canCancel,
115
+ })
116
+
117
+ // Terminal states — hand off to the result card renderer. Also short-
118
+ // circuit when the dispatcher has already populated an
119
+ // `executionResult.error`: the row may not have transitioned out of
120
+ // `executing` in the latest polling snapshot, but the handler error is
121
+ // authoritative, and leaving the spinner up while a known error is
122
+ // available is exactly the "stalled at processing" symptom the user
123
+ // reported. Surface the error card immediately.
124
+ const executionError = effectiveAction?.executionResult?.error
125
+ if (
126
+ effectiveStatus === 'confirmed' ||
127
+ effectiveStatus === 'failed' ||
128
+ effectiveStatus === 'cancelled' ||
129
+ effectiveStatus === 'expired' ||
130
+ executionError
131
+ ) {
132
+ return (
133
+ <MutationResultCard
134
+ componentId="mutation-result-card"
135
+ pendingActionId={pendingActionId}
136
+ initialAction={effectiveAction ?? undefined}
137
+ endpoint={props.endpoint}
138
+ />
139
+ )
140
+ }
141
+
142
+ const summary =
143
+ effectiveAction?.sideEffectsSummary ??
144
+ payload.sideEffectsSummary ??
145
+ t(
146
+ 'ai_assistant.chat.mutation_cards.confirmation.defaultSummary',
147
+ 'Applying the requested changes...',
148
+ )
149
+
150
+ return (
151
+ <section
152
+ className="rounded-md border border-border bg-muted/30 p-4 text-sm outline-none"
153
+ tabIndex={0}
154
+ onKeyDown={handleKeyDown}
155
+ data-ai-confirmation-card
156
+ data-ai-confirmation-status={effectiveStatus ?? 'pending'}
157
+ aria-busy
158
+ >
159
+ <div className="flex items-start gap-3">
160
+ <Spinner size="sm" className="mt-0.5" />
161
+ <div className="flex-1">
162
+ <h4 className="text-sm font-semibold">
163
+ {t(
164
+ 'ai_assistant.chat.mutation_cards.confirmation.title',
165
+ 'Applying action...',
166
+ )}
167
+ </h4>
168
+ <p className="mt-1 text-sm text-muted-foreground">{summary}</p>
169
+ </div>
170
+ </div>
171
+
172
+ {localError ? (
173
+ <ConfirmationErrorAlert error={localError} />
174
+ ) : null}
175
+
176
+ <div className="mt-3 flex items-center justify-end gap-2">
177
+ <Button
178
+ type="button"
179
+ variant="outline"
180
+ size="sm"
181
+ onClick={() => {
182
+ void handleCancel()
183
+ }}
184
+ disabled={!canCancel}
185
+ data-ai-confirmation-cancel
186
+ >
187
+ {isCancelling ? (
188
+ <Loader2 className="size-4 animate-spin" aria-hidden />
189
+ ) : null}
190
+ <span>
191
+ {t('ai_assistant.chat.mutation_cards.confirmation.cancel', 'Cancel')}
192
+ </span>
193
+ </Button>
194
+ </div>
195
+ </section>
196
+ )
197
+ }
198
+
199
+ function ConfirmationErrorAlert({
200
+ error,
201
+ }: {
202
+ error: { code?: string; message: string; extra?: Record<string, unknown> }
203
+ }) {
204
+ const t = useT()
205
+ const code = error.code ?? 'unknown'
206
+
207
+ if (code === 'stale_version') {
208
+ const failedRecords = Array.isArray(error.extra?.failedRecords)
209
+ ? (error.extra?.failedRecords as Array<{ recordId?: string }>)
210
+ : []
211
+ return (
212
+ <Alert
213
+ variant="warning"
214
+ className="mt-3"
215
+ data-ai-confirmation-error="stale_version"
216
+ >
217
+ <AlertTriangle className="size-4" aria-hidden />
218
+ <AlertTitle>
219
+ {t(
220
+ 'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle',
221
+ 'Re-propose required',
222
+ )}
223
+ </AlertTitle>
224
+ <AlertDescription>
225
+ <p>
226
+ {t(
227
+ 'ai_assistant.chat.mutation_cards.confirmation.staleVersionBody',
228
+ 'One or more records changed since this preview was generated. Ask the assistant to re-propose the change.',
229
+ )}
230
+ </p>
231
+ {failedRecords.length > 0 ? (
232
+ <ul className="mt-2 list-disc pl-5 text-xs">
233
+ {failedRecords.map((record, idx) => (
234
+ <li
235
+ key={`${record.recordId ?? idx}`}
236
+ data-ai-confirmation-stale-record={record.recordId ?? ''}
237
+ >
238
+ <span className="font-mono">{record.recordId ?? '—'}</span>
239
+ </li>
240
+ ))}
241
+ </ul>
242
+ ) : null}
243
+ </AlertDescription>
244
+ </Alert>
245
+ )
246
+ }
247
+
248
+ if (code === 'schema_drift') {
249
+ return (
250
+ <Alert
251
+ variant="warning"
252
+ className="mt-3"
253
+ data-ai-confirmation-error="schema_drift"
254
+ >
255
+ <AlertTriangle className="size-4" aria-hidden />
256
+ <AlertTitle>
257
+ {t(
258
+ 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle',
259
+ 'Schema changed',
260
+ )}
261
+ </AlertTitle>
262
+ <AlertDescription>
263
+ {t(
264
+ 'ai_assistant.chat.mutation_cards.confirmation.schemaDriftBody',
265
+ 'The tool signature changed since this preview was generated. Ask the assistant to re-propose the change.',
266
+ )}
267
+ </AlertDescription>
268
+ </Alert>
269
+ )
270
+ }
271
+
272
+ if (code === 'invalid_status') {
273
+ return (
274
+ <Alert
275
+ variant="warning"
276
+ className="mt-3"
277
+ data-ai-confirmation-error="invalid_status"
278
+ >
279
+ <AlertTriangle className="size-4" aria-hidden />
280
+ <AlertTitle>
281
+ {t(
282
+ 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle',
283
+ 'Action already resolved',
284
+ )}
285
+ </AlertTitle>
286
+ <AlertDescription>
287
+ {t(
288
+ 'ai_assistant.chat.mutation_cards.confirmation.invalidStatusBody',
289
+ 'This action has already been confirmed, cancelled, or executed.',
290
+ )}
291
+ </AlertDescription>
292
+ </Alert>
293
+ )
294
+ }
295
+
296
+ return (
297
+ <Alert variant="destructive" className="mt-3" data-ai-confirmation-error={code}>
298
+ <AlertTriangle className="size-4" aria-hidden />
299
+ <AlertTitle>
300
+ {t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}
301
+ </AlertTitle>
302
+ <AlertDescription>
303
+ <span className="mr-2 font-mono text-xs">{code}</span>
304
+ <span>{error.message}</span>
305
+ </AlertDescription>
306
+ </Alert>
307
+ )
308
+ }
309
+
310
+ export default ConfirmationCard