@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,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
|
+
})
|