@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,213 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ // Polyfill what jsdom lacks before AiChat imports pull in anything stream-y.
6
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
7
+ const nodeUtil = require('node:util') as typeof import('node:util')
8
+ if (typeof globalThis.TextEncoder === 'undefined') {
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ ;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
11
+ }
12
+ if (typeof globalThis.TextDecoder === 'undefined') {
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ ;(globalThis as any).TextDecoder =
15
+ nodeUtil.TextDecoder as unknown as typeof TextDecoder
16
+ }
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
19
+ if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ ;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
22
+ }
23
+
24
+ import * as React from 'react'
25
+ import { screen, within } from '@testing-library/react'
26
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
27
+
28
+ jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
29
+ createAiAgentTransport: jest.fn(() => ({
30
+ sendMessages: jest.fn(),
31
+ reconnectToStream: jest.fn(),
32
+ })),
33
+ }))
34
+
35
+ jest.mock('../../backend/utils/api', () => ({
36
+ apiFetch: jest.fn(),
37
+ }))
38
+
39
+ import { AiChat } from '../AiChat'
40
+ import {
41
+ createAiUiPartRegistry,
42
+ defaultAiUiPartRegistry,
43
+ resetAiUiPartRegistryForTests,
44
+ } from '../ui-part-registry'
45
+
46
+ const dict = {
47
+ 'ai_assistant.chat.assistantRoleLabel': 'Assistant',
48
+ 'ai_assistant.chat.cancel': 'Cancel streaming response',
49
+ 'ai_assistant.chat.composerLabel': 'Message composer',
50
+ 'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
51
+ 'ai_assistant.chat.debugPanelTitle': 'Debug panel',
52
+ 'ai_assistant.chat.emptyTranscript':
53
+ 'No messages yet. Ask the agent anything to get started.',
54
+ 'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
55
+ 'ai_assistant.chat.regionLabel': 'AI chat',
56
+ 'ai_assistant.chat.send': 'Send message',
57
+ 'ai_assistant.chat.shortcutHint':
58
+ 'Press Cmd/Ctrl+Enter to send, Escape to cancel.',
59
+ 'ai_assistant.chat.thinking': 'Thinking...',
60
+ 'ai_assistant.chat.transcriptLabel': 'Chat transcript',
61
+ 'ai_assistant.chat.pending_phase3.body':
62
+ 'This interactive card will land in Phase 3 of the unified AI framework.',
63
+ 'ai_assistant.chat.pending_phase3.title': 'Mutation approval card pending',
64
+ 'ai_assistant.chat.uiPartPending': 'Pending UI part:',
65
+ 'ai_assistant.chat.userRoleLabel': 'You',
66
+ }
67
+
68
+ describe('<AiChat> × UI-part registry', () => {
69
+ beforeEach(() => {
70
+ resetAiUiPartRegistryForTests()
71
+ })
72
+
73
+ afterAll(() => {
74
+ resetAiUiPartRegistryForTests()
75
+ })
76
+
77
+ it('renders the Phase 3 placeholder for a reserved id on a scoped registry without live seeding', () => {
78
+ // Step 5.10 flipped the DEFAULT registry over to live approval cards,
79
+ // so we use a scoped registry (the placeholder default) to exercise the
80
+ // pending-chip renderer path.
81
+ const scoped = createAiUiPartRegistry()
82
+ renderWithProviders(
83
+ <AiChat
84
+ agent="customers.account_assistant"
85
+ registry={scoped}
86
+ uiParts={[{ componentId: 'mutation-preview-card', payload: { foo: 1 } }]}
87
+ />,
88
+ { dict },
89
+ )
90
+ const region = screen.getByRole('region', { name: 'AI chat' })
91
+ const placeholder = region.querySelector(
92
+ '[data-ai-ui-part-pending-phase3="mutation-preview-card"]',
93
+ )
94
+ expect(placeholder).not.toBeNull()
95
+ expect(placeholder?.textContent).toContain('Mutation approval card pending')
96
+ })
97
+
98
+ it('renders a registered custom component instead of the placeholder', () => {
99
+ function RealCard({ componentId }: { componentId: string }) {
100
+ return (
101
+ <div data-testid="real-mutation-preview">REAL:{componentId}</div>
102
+ )
103
+ }
104
+ const scoped = createAiUiPartRegistry()
105
+ scoped.register('mutation-preview-card', RealCard)
106
+
107
+ renderWithProviders(
108
+ <AiChat
109
+ agent="customers.account_assistant"
110
+ registry={scoped}
111
+ uiParts={[{ componentId: 'mutation-preview-card' }]}
112
+ />,
113
+ { dict },
114
+ )
115
+
116
+ expect(screen.getByTestId('real-mutation-preview')).toHaveTextContent(
117
+ 'REAL:mutation-preview-card',
118
+ )
119
+ // The placeholder is NOT rendered when a real component is registered.
120
+ expect(
121
+ document.querySelector(
122
+ '[data-ai-ui-part-pending-phase3="mutation-preview-card"]',
123
+ ),
124
+ ).toBeNull()
125
+ })
126
+
127
+ it('renders the neutral chip (not the Phase 3 placeholder) for unknown non-reserved ids', () => {
128
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
129
+ try {
130
+ renderWithProviders(
131
+ <AiChat
132
+ agent="customers.account_assistant"
133
+ uiParts={[{ componentId: 'not-a-real-id' }]}
134
+ />,
135
+ { dict },
136
+ )
137
+
138
+ expect(
139
+ document.querySelector('[data-ai-ui-part-placeholder="not-a-real-id"]'),
140
+ ).not.toBeNull()
141
+ expect(
142
+ document.querySelector(
143
+ '[data-ai-ui-part-pending-phase3="not-a-real-id"]',
144
+ ),
145
+ ).toBeNull()
146
+ expect(warnSpy).toHaveBeenCalled()
147
+ } finally {
148
+ warnSpy.mockRestore()
149
+ }
150
+ })
151
+
152
+ it('two <AiChat> instances with different registries do not leak registrations', () => {
153
+ function RedCard({ componentId }: { componentId: string }) {
154
+ return <div data-testid={`red-${componentId}`}>RED</div>
155
+ }
156
+ function BlueCard({ componentId }: { componentId: string }) {
157
+ return <div data-testid={`blue-${componentId}`}>BLUE</div>
158
+ }
159
+
160
+ const registryA = createAiUiPartRegistry()
161
+ registryA.register('confirmation-card', RedCard)
162
+ const registryB = createAiUiPartRegistry()
163
+ registryB.register('confirmation-card', BlueCard)
164
+
165
+ const { container } = renderWithProviders(
166
+ <div>
167
+ <div data-testid="host-a">
168
+ <AiChat
169
+ agent="customers.account_assistant"
170
+ registry={registryA}
171
+ uiParts={[{ componentId: 'confirmation-card' }]}
172
+ />
173
+ </div>
174
+ <div data-testid="host-b">
175
+ <AiChat
176
+ agent="customers.account_assistant"
177
+ registry={registryB}
178
+ uiParts={[{ componentId: 'confirmation-card' }]}
179
+ />
180
+ </div>
181
+ </div>,
182
+ { dict },
183
+ )
184
+
185
+ const hostA = within(screen.getByTestId('host-a'))
186
+ const hostB = within(screen.getByTestId('host-b'))
187
+ expect(hostA.getByTestId('red-confirmation-card')).toBeInTheDocument()
188
+ expect(hostB.getByTestId('blue-confirmation-card')).toBeInTheDocument()
189
+ expect(container.querySelectorAll('[data-testid="red-confirmation-card"]')).toHaveLength(1)
190
+ expect(container.querySelectorAll('[data-testid="blue-confirmation-card"]')).toHaveLength(1)
191
+
192
+ // The scoped registries never wrote through to the default registry.
193
+ expect(defaultAiUiPartRegistry.resolve('confirmation-card')).not.toBe(RedCard)
194
+ expect(defaultAiUiPartRegistry.resolve('confirmation-card')).not.toBe(BlueCard)
195
+ })
196
+
197
+ it('falls back to the default registry when no registry prop is provided', () => {
198
+ function GlobalCard() {
199
+ return <div data-testid="global-card">GLOBAL</div>
200
+ }
201
+ defaultAiUiPartRegistry.register('mutation-result-card', GlobalCard)
202
+
203
+ renderWithProviders(
204
+ <AiChat
205
+ agent="customers.account_assistant"
206
+ uiParts={[{ componentId: 'mutation-result-card' }]}
207
+ />,
208
+ { dict },
209
+ )
210
+
211
+ expect(screen.getByTestId('global-card')).toBeInTheDocument()
212
+ })
213
+ })
@@ -0,0 +1,257 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ // jsdom does not ship TextEncoder/TextDecoder/Response globals — polyfill from
6
+ // the Node util + undici modules before any consumer module imports them.
7
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
8
+ const nodeUtil = require('node:util') as typeof import('node:util')
9
+ if (typeof globalThis.TextEncoder === 'undefined') {
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ ;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
12
+ }
13
+ if (typeof globalThis.TextDecoder === 'undefined') {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ ;(globalThis as any).TextDecoder = nodeUtil.TextDecoder as unknown as typeof TextDecoder
16
+ }
17
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
18
+ const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
19
+ if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ ;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
22
+ }
23
+
24
+ import * as React from 'react'
25
+ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
26
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
27
+
28
+ jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
29
+ createAiAgentTransport: jest.fn(() => ({
30
+ sendMessages: jest.fn(),
31
+ reconnectToStream: jest.fn(),
32
+ })),
33
+ }))
34
+
35
+ jest.mock('../../backend/utils/api', () => ({
36
+ apiFetch: jest.fn(),
37
+ }))
38
+
39
+ import { apiFetch } from '../../backend/utils/api'
40
+ import { AiChat } from '../AiChat'
41
+
42
+ const dict = {
43
+ 'ai_assistant.chat.assistantRoleLabel': 'Assistant',
44
+ 'ai_assistant.chat.cancel': 'Cancel streaming response',
45
+ 'ai_assistant.chat.composerLabel': 'Message composer',
46
+ 'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
47
+ 'ai_assistant.chat.debugPanelTitle': 'Debug panel',
48
+ 'ai_assistant.chat.emptyTranscript':
49
+ 'No messages yet. Ask the agent anything to get started.',
50
+ 'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
51
+ 'ai_assistant.chat.regionLabel': 'AI chat',
52
+ 'ai_assistant.chat.send': 'Send message',
53
+ 'ai_assistant.chat.shortcutHint':
54
+ 'Press Cmd/Ctrl+Enter to send, Escape to cancel.',
55
+ 'ai_assistant.chat.thinking': 'Thinking...',
56
+ 'ai_assistant.chat.transcriptLabel': 'Chat transcript',
57
+ 'ai_assistant.chat.uiPartPending': 'Pending UI part:',
58
+ 'ai_assistant.chat.userRoleLabel': 'You',
59
+ }
60
+
61
+ // Minimal Response-like shape honouring only the methods the hook calls:
62
+ // `ok`, `status`, `body`, `clone()`, `json()`, `text()`.
63
+ type ResponseLike = {
64
+ ok: boolean
65
+ status: number
66
+ body: ReadableStream<Uint8Array> | null
67
+ clone: () => ResponseLike
68
+ json: () => Promise<unknown>
69
+ text: () => Promise<string>
70
+ }
71
+
72
+ function createStreamingResponse(chunks: string[]): ResponseLike {
73
+ const encoder = new TextEncoder()
74
+ const stream = new ReadableStream<Uint8Array>({
75
+ start(controller) {
76
+ for (const chunk of chunks) {
77
+ controller.enqueue(encoder.encode(chunk))
78
+ }
79
+ controller.close()
80
+ },
81
+ })
82
+ const raw = chunks.join('')
83
+ const self: ResponseLike = {
84
+ ok: true,
85
+ status: 200,
86
+ body: stream,
87
+ clone: () => ({ ...self, body: null }),
88
+ json: async () => ({}),
89
+ text: async () => raw,
90
+ }
91
+ return self
92
+ }
93
+
94
+ function createErrorResponse(status: number, payload: Record<string, unknown>): ResponseLike {
95
+ const jsonText = JSON.stringify(payload)
96
+ const self: ResponseLike = {
97
+ ok: false,
98
+ status,
99
+ body: null,
100
+ clone: () => ({ ...self }),
101
+ json: async () => payload,
102
+ text: async () => jsonText,
103
+ }
104
+ return self
105
+ }
106
+
107
+ describe('<AiChat>', () => {
108
+ beforeEach(() => {
109
+ jest.clearAllMocks()
110
+ })
111
+
112
+ it('renders the composer with the i18n placeholder and region labels', () => {
113
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
114
+
115
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
116
+ expect(textarea.placeholder).toBe('Message the AI agent...')
117
+ expect(screen.getByRole('log', { name: 'Chat transcript' })).toBeInTheDocument()
118
+ expect(screen.getByRole('region', { name: 'AI chat' })).toBeInTheDocument()
119
+ expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled()
120
+ })
121
+
122
+ it('submits the message on Cmd+Enter and streams assistant text into the transcript', async () => {
123
+ const fetchMock = apiFetch as unknown as jest.Mock
124
+ fetchMock.mockResolvedValueOnce(
125
+ createStreamingResponse(['Hello', ', ', 'world!']),
126
+ )
127
+
128
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
129
+
130
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
131
+ fireEvent.change(textarea, { target: { value: 'Hi there' } })
132
+
133
+ await act(async () => {
134
+ fireEvent.keyDown(textarea, {
135
+ key: 'Enter',
136
+ metaKey: true,
137
+ })
138
+ })
139
+
140
+ await waitFor(() => {
141
+ expect(fetchMock).toHaveBeenCalledTimes(1)
142
+ })
143
+ const [url, init] = fetchMock.mock.calls[0]
144
+ expect(String(url)).toContain('/api/ai_assistant/ai/chat')
145
+ expect(String(url)).toContain('agent=customers.account_assistant')
146
+ expect(init.method).toBe('POST')
147
+ const parsedBody = JSON.parse(init.body as string)
148
+ expect(parsedBody.messages[0]).toMatchObject({ role: 'user', content: 'Hi there' })
149
+
150
+ await waitFor(() => {
151
+ expect(screen.getByText('Hello, world!')).toBeInTheDocument()
152
+ })
153
+ expect(screen.getByText('Hi there')).toBeInTheDocument()
154
+ })
155
+
156
+ it('surfaces dispatcher error envelopes via Alert and onError callback', async () => {
157
+ const fetchMock = apiFetch as unknown as jest.Mock
158
+ fetchMock.mockResolvedValueOnce(
159
+ createErrorResponse(404, {
160
+ error: 'Unknown agent "bogus.agent"',
161
+ code: 'agent_unknown',
162
+ }),
163
+ )
164
+
165
+ const onError = jest.fn()
166
+ renderWithProviders(
167
+ <AiChat agent="bogus.agent" onError={onError} />,
168
+ { dict },
169
+ )
170
+
171
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
172
+ fireEvent.change(textarea, { target: { value: 'test' } })
173
+ await act(async () => {
174
+ fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
175
+ })
176
+
177
+ await waitFor(() => {
178
+ expect(onError).toHaveBeenCalledWith(
179
+ expect.objectContaining({
180
+ code: 'agent_unknown',
181
+ message: expect.stringContaining('Unknown agent'),
182
+ }),
183
+ )
184
+ })
185
+
186
+ expect(screen.getByText('Agent dispatch failed')).toBeInTheDocument()
187
+ expect(screen.getByText(/Unknown agent/)).toBeInTheDocument()
188
+ expect(screen.getByText('agent_unknown')).toBeInTheDocument()
189
+ })
190
+
191
+ it('Escape aborts an in-flight streaming response', async () => {
192
+ const fetchMock = apiFetch as unknown as jest.Mock
193
+ // Build a stream we can keep open until the component aborts it.
194
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null = null
195
+ const encoder = new TextEncoder()
196
+ const pendingStream = new ReadableStream<Uint8Array>({
197
+ start(controller) {
198
+ streamController = controller
199
+ controller.enqueue(encoder.encode('partial'))
200
+ },
201
+ cancel() {
202
+ // no-op; act as if cancellation succeeds
203
+ },
204
+ })
205
+
206
+ fetchMock.mockImplementationOnce(async (_input: unknown, init: RequestInit) => {
207
+ const signal = init.signal as AbortSignal | undefined
208
+ if (signal) {
209
+ signal.addEventListener('abort', () => {
210
+ try {
211
+ streamController?.error(new DOMException('Aborted', 'AbortError'))
212
+ } catch {
213
+ // already closed
214
+ }
215
+ })
216
+ }
217
+ const responseLike: ResponseLike = {
218
+ ok: true,
219
+ status: 200,
220
+ body: pendingStream,
221
+ clone: () => ({
222
+ ok: true,
223
+ status: 200,
224
+ body: null,
225
+ clone: () => responseLike,
226
+ json: async () => ({}),
227
+ text: async () => '',
228
+ }),
229
+ json: async () => ({}),
230
+ text: async () => '',
231
+ }
232
+ return responseLike
233
+ })
234
+
235
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
236
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
237
+ fireEvent.change(textarea, { target: { value: 'stream please' } })
238
+ await act(async () => {
239
+ fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
240
+ })
241
+
242
+ // Wait for the partial chunk to show up so we are definitely streaming.
243
+ await waitFor(() => {
244
+ expect(screen.getByText('partial')).toBeInTheDocument()
245
+ })
246
+
247
+ await act(async () => {
248
+ fireEvent.keyDown(textarea, { key: 'Escape' })
249
+ })
250
+
251
+ // The assistant message keeps whatever we streamed so far but the thinking
252
+ // indicator should be gone (status === 'idle').
253
+ await waitFor(() => {
254
+ expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
255
+ })
256
+ })
257
+ })
@@ -0,0 +1,124 @@
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
+ import { AiChatSessionsProvider } from '../AiChatSessions'
9
+ import { AiDockProvider, useAiDock, type AiDockedAssistant } from '../AiDock'
10
+
11
+ jest.mock('../AiChat', () => {
12
+ const ReactModule = require('react') as typeof import('react')
13
+ return {
14
+ AiChat: ({ agent }: { agent: string }) =>
15
+ ReactModule.createElement('div', {
16
+ 'data-testid': 'ai-chat',
17
+ 'data-agent': agent,
18
+ }),
19
+ }
20
+ })
21
+
22
+ const STORAGE_KEY = 'om-ai-dock-v1'
23
+
24
+ const assistant: AiDockedAssistant = {
25
+ agent: 'customers.account_assistant',
26
+ label: 'CRM Assistant',
27
+ description: 'Customers',
28
+ pageContext: { view: 'customers.people.list' },
29
+ placeholder: 'Ask about customers...',
30
+ welcomeTitle: 'CRM Assistant',
31
+ welcomeDescription: 'Ask me anything about customers.',
32
+ suggestions: [
33
+ {
34
+ label: 'Summarize selected',
35
+ prompt: 'Summarize the selected customers.',
36
+ icon: <span>Icon</span>,
37
+ },
38
+ ],
39
+ contextItems: [{ label: '2 contacts selected' }],
40
+ }
41
+
42
+ function readStored(): Record<string, unknown> {
43
+ const raw = window.localStorage.getItem(STORAGE_KEY)
44
+ return raw ? JSON.parse(raw) as Record<string, unknown> : {}
45
+ }
46
+
47
+ function readStoredAssistant(): Record<string, unknown> | null {
48
+ const value = readStored().assistant
49
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
50
+ return value as Record<string, unknown>
51
+ }
52
+
53
+ function DockButton() {
54
+ const dock = useAiDock()
55
+ return (
56
+ <button type="button" onClick={() => dock.dock(assistant)}>
57
+ Dock assistant
58
+ </button>
59
+ )
60
+ }
61
+
62
+ function Harness({ withButton = true }: { withButton?: boolean }) {
63
+ return (
64
+ <AiChatSessionsProvider>
65
+ <AiDockProvider>
66
+ {withButton ? <DockButton /> : null}
67
+ <main>Content</main>
68
+ </AiDockProvider>
69
+ </AiChatSessionsProvider>
70
+ )
71
+ }
72
+
73
+ describe('<AiDockProvider>', () => {
74
+ beforeEach(() => {
75
+ window.localStorage.clear()
76
+ })
77
+
78
+ afterEach(() => {
79
+ window.localStorage.clear()
80
+ })
81
+
82
+ it('persists the docked assistant and restores it after remount', async () => {
83
+ const { unmount } = renderWithProviders(<Harness />)
84
+
85
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
86
+
87
+ expect(document.querySelector('[data-ai-dock-agent="customers.account_assistant"]')).toBeInTheDocument()
88
+ await waitFor(() => {
89
+ expect(readStoredAssistant()?.agent).toBe('customers.account_assistant')
90
+ })
91
+
92
+ const storedAssistant = readStoredAssistant()
93
+ expect(storedAssistant?.label).toBe('CRM Assistant')
94
+ expect(storedAssistant?.pageContext).toEqual({ view: 'customers.people.list' })
95
+ expect(storedAssistant?.suggestions).toEqual([
96
+ { label: 'Summarize selected', prompt: 'Summarize the selected customers.' },
97
+ ])
98
+
99
+ unmount()
100
+ renderWithProviders(<Harness withButton={false} />)
101
+
102
+ await waitFor(() => {
103
+ expect(document.querySelector('[data-ai-dock-agent="customers.account_assistant"]')).toBeInTheDocument()
104
+ })
105
+ })
106
+
107
+ it('clears the persisted assistant when the dock is closed', async () => {
108
+ renderWithProviders(<Harness />)
109
+
110
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
111
+ await waitFor(() => {
112
+ expect(readStoredAssistant()?.agent).toBe('customers.account_assistant')
113
+ })
114
+
115
+ const closeButton = document.querySelector('[data-ai-dock-close=""]')
116
+ expect(closeButton).toBeInstanceOf(HTMLElement)
117
+ fireEvent.click(closeButton as HTMLElement)
118
+
119
+ await waitFor(() => {
120
+ expect(readStoredAssistant()).toBeNull()
121
+ })
122
+ expect(document.querySelector('[data-ai-dock-panel=""]')).not.toBeInTheDocument()
123
+ })
124
+ })