@open-mercato/ui 0.6.2-develop.3406.1.2b403f40da → 0.6.2-develop.3446.1.bd060c6017
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/dist/ai/AiAssistantLauncher.js +11 -10
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +218 -83
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiIcon.js +1 -1
- package/dist/ai/AiIcon.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +4 -3
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/useAiChat.js +98 -2
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/DataTable.js +4 -13
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/primitives/date-format.js +53 -0
- package/dist/primitives/date-format.js.map +7 -0
- package/dist/primitives/date-picker.js +3 -26
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/date-range-picker.js +3 -22
- package/dist/primitives/date-range-picker.js.map +2 -2
- package/package.json +4 -4
- package/src/ai/AiAssistantLauncher.tsx +11 -10
- package/src/ai/AiChat.tsx +200 -26
- package/src/ai/AiIcon.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +356 -0
- package/src/ai/__tests__/AiChat.test.tsx +44 -2
- package/src/ai/parts/FieldDiffCard.tsx +4 -3
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +24 -0
- package/src/ai/parts/types.ts +3 -0
- package/src/ai/useAiChat.ts +154 -1
- package/src/backend/DataTable.tsx +12 -22
- package/src/primitives/__tests__/date-format.test.ts +43 -0
- package/src/primitives/date-format.ts +60 -0
- package/src/primitives/date-picker.tsx +5 -17
- package/src/primitives/date-range-picker.tsx +3 -13
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Acceptance tests for the visible agent task plan
|
|
7
|
+
* (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`).
|
|
8
|
+
*
|
|
9
|
+
* Asserts that:
|
|
10
|
+
* - `useAiChat` merges `data-agent-task-plan` snapshots and
|
|
11
|
+
* `data-agent-task-update` deltas into the assistant message.
|
|
12
|
+
* - `<AiChat>` renders running/done/failed states above tool-call rows
|
|
13
|
+
* without hiding text output.
|
|
14
|
+
* - Unknown task-plan chunks are ignored safely.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// jsdom polyfills (same shape as the existing AiChat tests).
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
19
|
+
const nodeUtil = require('node:util') as typeof import('node:util')
|
|
20
|
+
if (typeof globalThis.TextEncoder === 'undefined') {
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
;(globalThis as any).TextEncoder = nodeUtil.TextEncoder
|
|
23
|
+
}
|
|
24
|
+
if (typeof globalThis.TextDecoder === 'undefined') {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
;(globalThis as any).TextDecoder = nodeUtil.TextDecoder as unknown as typeof TextDecoder
|
|
27
|
+
}
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
29
|
+
const nodeStreamWeb = require('node:stream/web') as typeof import('node:stream/web')
|
|
30
|
+
if (typeof (globalThis as unknown as { ReadableStream?: unknown }).ReadableStream === 'undefined') {
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
;(globalThis as any).ReadableStream = nodeStreamWeb.ReadableStream
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
import * as React from 'react'
|
|
36
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
37
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
38
|
+
|
|
39
|
+
jest.mock('@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport', () => ({
|
|
40
|
+
createAiAgentTransport: jest.fn(() => ({
|
|
41
|
+
sendMessages: jest.fn(),
|
|
42
|
+
reconnectToStream: jest.fn(),
|
|
43
|
+
})),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
jest.mock('../../backend/utils/api', () => ({
|
|
47
|
+
apiFetch: jest.fn(),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
51
|
+
apiCall: jest.fn(async () => ({
|
|
52
|
+
ok: true,
|
|
53
|
+
status: 200,
|
|
54
|
+
result: {
|
|
55
|
+
agentId: 'customers.account_assistant',
|
|
56
|
+
allowRuntimeModelOverride: false,
|
|
57
|
+
defaultProviderId: 'openai',
|
|
58
|
+
defaultModelId: 'gpt-5-mini',
|
|
59
|
+
providers: [],
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
import { apiFetch } from '../../backend/utils/api'
|
|
65
|
+
import { AiChat } from '../AiChat'
|
|
66
|
+
|
|
67
|
+
const dict = {
|
|
68
|
+
'ai_assistant.chat.assistantRoleLabel': 'Assistant',
|
|
69
|
+
'ai_assistant.chat.cancel': 'Cancel streaming response',
|
|
70
|
+
'ai_assistant.chat.composerLabel': 'Message composer',
|
|
71
|
+
'ai_assistant.chat.composerPlaceholder': 'Message the AI agent...',
|
|
72
|
+
'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
|
|
73
|
+
'ai_assistant.chat.agentTasksTitle': 'Tool calls',
|
|
74
|
+
'ai_assistant.chat.regionLabel': 'AI chat',
|
|
75
|
+
'ai_assistant.chat.send': 'Send message',
|
|
76
|
+
'ai_assistant.chat.taskPlanTitle': 'Plan',
|
|
77
|
+
'ai_assistant.chat.taskRunning': 'running…',
|
|
78
|
+
'ai_assistant.chat.taskDone': 'done',
|
|
79
|
+
'ai_assistant.chat.taskFailed': 'failed',
|
|
80
|
+
'ai_assistant.chat.taskPending': 'pending',
|
|
81
|
+
'ai_assistant.chat.taskSkipped': 'skipped',
|
|
82
|
+
'ai_assistant.chat.thinking': 'Thinking...',
|
|
83
|
+
'ai_assistant.chat.transcriptLabel': 'Chat transcript',
|
|
84
|
+
'ai_assistant.chat.userRoleLabel': 'You',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type ResponseLike = {
|
|
88
|
+
ok: boolean
|
|
89
|
+
status: number
|
|
90
|
+
body: ReadableStream<Uint8Array> | null
|
|
91
|
+
headers?: { get: (name: string) => string | null }
|
|
92
|
+
clone: () => ResponseLike
|
|
93
|
+
json: () => Promise<unknown>
|
|
94
|
+
text: () => Promise<string>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createUiMessageSseResponse(chunks: Array<Record<string, unknown>>): ResponseLike {
|
|
98
|
+
const encoder = new TextEncoder()
|
|
99
|
+
const raw = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('')
|
|
100
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
101
|
+
start(controller) {
|
|
102
|
+
controller.enqueue(encoder.encode(`${raw}data: [DONE]\n\n`))
|
|
103
|
+
controller.close()
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
const headers = {
|
|
107
|
+
get: (name: string) => {
|
|
108
|
+
const normalized = name.toLowerCase()
|
|
109
|
+
if (normalized === 'content-type') return 'text/event-stream'
|
|
110
|
+
if (normalized === 'x-vercel-ai-ui-message-stream') return 'v1'
|
|
111
|
+
return null
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
const self: ResponseLike = {
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
body: stream,
|
|
118
|
+
headers,
|
|
119
|
+
clone: () => ({ ...self, body: null }),
|
|
120
|
+
json: async () => ({}),
|
|
121
|
+
text: async () => raw,
|
|
122
|
+
}
|
|
123
|
+
return self
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('<AiChat> task plan', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
jest.clearAllMocks()
|
|
129
|
+
window.localStorage.clear()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
async function sendAndWait(): Promise<void> {
|
|
133
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
134
|
+
fireEvent.change(textarea, { target: { value: 'Do something' } })
|
|
135
|
+
await act(async () => {
|
|
136
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
137
|
+
})
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect((apiFetch as unknown as jest.Mock).mock.calls.length).toBeGreaterThan(0)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('renders a running task from a snapshot chunk above the tool-call row', async () => {
|
|
144
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
145
|
+
fetchMock.mockResolvedValueOnce(
|
|
146
|
+
createUiMessageSseResponse([
|
|
147
|
+
{
|
|
148
|
+
type: 'data-agent-task-plan',
|
|
149
|
+
planId: 'turn_abc',
|
|
150
|
+
tasks: [
|
|
151
|
+
{
|
|
152
|
+
id: 'call-1',
|
|
153
|
+
label: 'Customers · List people',
|
|
154
|
+
state: 'running',
|
|
155
|
+
source: 'agent',
|
|
156
|
+
toolCallId: 'call-1',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'customers__list_people' },
|
|
161
|
+
{ type: 'text-delta', id: 't', delta: 'Working on it…' },
|
|
162
|
+
]),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
166
|
+
await sendAndWait()
|
|
167
|
+
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
expect(screen.getByText('Customers · List people')).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
expect(screen.getByText('Plan')).toBeInTheDocument()
|
|
172
|
+
const taskRow = document.querySelector('[data-ai-chat-task-id="call-1"]') as HTMLElement | null
|
|
173
|
+
expect(taskRow).not.toBeNull()
|
|
174
|
+
expect(taskRow?.getAttribute('data-ai-chat-task-state')).toBe('running')
|
|
175
|
+
expect(taskRow?.textContent).toContain('running…')
|
|
176
|
+
expect(screen.getByText('Working on it…')).toBeInTheDocument()
|
|
177
|
+
// Existing tool-call detail row is still rendered separately with a friendly caption.
|
|
178
|
+
expect(screen.getByText('Customers - List People (customers.list_people)')).toBeInTheDocument()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('updates task state from running to done when a task-update arrives', async () => {
|
|
182
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
183
|
+
fetchMock.mockResolvedValueOnce(
|
|
184
|
+
createUiMessageSseResponse([
|
|
185
|
+
{
|
|
186
|
+
type: 'data-agent-task-plan',
|
|
187
|
+
planId: 'turn_def',
|
|
188
|
+
tasks: [
|
|
189
|
+
{
|
|
190
|
+
id: 'call-1',
|
|
191
|
+
label: 'Catalog · Search products',
|
|
192
|
+
state: 'running',
|
|
193
|
+
source: 'agent',
|
|
194
|
+
toolCallId: 'call-1',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
type: 'data-agent-task-update',
|
|
200
|
+
planId: 'turn_def',
|
|
201
|
+
task: {
|
|
202
|
+
id: 'call-1',
|
|
203
|
+
label: 'Catalog · Search products',
|
|
204
|
+
state: 'done',
|
|
205
|
+
source: 'agent',
|
|
206
|
+
toolCallId: 'call-1',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{ type: 'text-delta', id: 't', delta: 'All set.' },
|
|
210
|
+
]),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
214
|
+
await sendAndWait()
|
|
215
|
+
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
expect(screen.getByText('done')).toBeInTheDocument()
|
|
218
|
+
})
|
|
219
|
+
expect(screen.getByText('Catalog · Search products')).toBeInTheDocument()
|
|
220
|
+
expect(screen.getByText('All set.')).toBeInTheDocument()
|
|
221
|
+
expect(screen.queryByText('running…')).not.toBeInTheDocument()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('renders agent-authored plan snapshots without exposing the internal plan tool row', async () => {
|
|
225
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
226
|
+
fetchMock.mockResolvedValueOnce(
|
|
227
|
+
createUiMessageSseResponse([
|
|
228
|
+
{ type: 'tool-input-start', toolCallId: 'plan-call', toolName: 'meta__update_task_plan' },
|
|
229
|
+
{
|
|
230
|
+
type: 'data-agent-task-plan',
|
|
231
|
+
planId: 'turn_plan',
|
|
232
|
+
tasks: [
|
|
233
|
+
{
|
|
234
|
+
id: 'search-step',
|
|
235
|
+
label: 'Search matching products',
|
|
236
|
+
state: 'pending',
|
|
237
|
+
source: 'agent',
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'tool-input-available',
|
|
243
|
+
toolCallId: 'plan-call',
|
|
244
|
+
toolName: 'meta__update_task_plan',
|
|
245
|
+
input: { tasks: [{ label: 'Search matching products' }] },
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
type: 'tool-output-available',
|
|
249
|
+
toolCallId: 'plan-call',
|
|
250
|
+
output: { ok: true },
|
|
251
|
+
},
|
|
252
|
+
{ type: 'text-delta', id: 't', delta: 'Starting now.' },
|
|
253
|
+
]),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
257
|
+
await sendAndWait()
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(screen.getByText('Search matching products')).toBeInTheDocument()
|
|
261
|
+
})
|
|
262
|
+
expect(screen.getByText('pending')).toBeInTheDocument()
|
|
263
|
+
expect(screen.queryByText('meta.update_task_plan')).not.toBeInTheDocument()
|
|
264
|
+
expect(screen.getByText('Starting now.')).toBeInTheDocument()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('renders failed state without hiding text output', async () => {
|
|
268
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
269
|
+
fetchMock.mockResolvedValueOnce(
|
|
270
|
+
createUiMessageSseResponse([
|
|
271
|
+
{
|
|
272
|
+
type: 'data-agent-task-plan',
|
|
273
|
+
planId: 'turn_ghi',
|
|
274
|
+
tasks: [
|
|
275
|
+
{
|
|
276
|
+
id: 'call-1',
|
|
277
|
+
label: 'Catalog · Search products',
|
|
278
|
+
state: 'failed',
|
|
279
|
+
source: 'agent',
|
|
280
|
+
toolCallId: 'call-1',
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
{ type: 'text-delta', id: 't', delta: 'I could not search.' },
|
|
285
|
+
]),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
289
|
+
await sendAndWait()
|
|
290
|
+
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
expect(screen.getByText('failed')).toBeInTheDocument()
|
|
293
|
+
})
|
|
294
|
+
expect(screen.getByText('I could not search.')).toBeInTheDocument()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('does not render runtime-derived tool lifecycle tasks as the visible plan', async () => {
|
|
298
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
299
|
+
fetchMock.mockResolvedValueOnce(
|
|
300
|
+
createUiMessageSseResponse([
|
|
301
|
+
{
|
|
302
|
+
type: 'data-agent-task-plan',
|
|
303
|
+
planId: 'turn_runtime',
|
|
304
|
+
tasks: [
|
|
305
|
+
{
|
|
306
|
+
id: 'call-1',
|
|
307
|
+
label: 'Customers · List deals',
|
|
308
|
+
state: 'running',
|
|
309
|
+
source: 'runtime',
|
|
310
|
+
toolCallId: 'call-1',
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
{ type: 'tool-input-start', toolCallId: 'call-1', toolName: 'customers__list_deals' },
|
|
315
|
+
{ type: 'text-delta', id: 't', delta: 'Checking deals.' },
|
|
316
|
+
]),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
320
|
+
await sendAndWait()
|
|
321
|
+
|
|
322
|
+
await waitFor(() => {
|
|
323
|
+
expect(screen.getByText('Checking deals.')).toBeInTheDocument()
|
|
324
|
+
})
|
|
325
|
+
expect(screen.queryByText('Plan')).not.toBeInTheDocument()
|
|
326
|
+
expect(screen.queryByText('Customers · List deals')).not.toBeInTheDocument()
|
|
327
|
+
expect(screen.getByText('Customers - List Deals (customers.list_deals)')).toBeInTheDocument()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('ignores task-update chunks that are missing required fields', async () => {
|
|
331
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
332
|
+
fetchMock.mockResolvedValueOnce(
|
|
333
|
+
createUiMessageSseResponse([
|
|
334
|
+
{
|
|
335
|
+
type: 'data-agent-task-update',
|
|
336
|
+
planId: 'turn_jkl',
|
|
337
|
+
task: { id: '', label: 'Missing id', state: 'running', source: 'runtime' },
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
type: 'data-agent-task-update',
|
|
341
|
+
planId: 'turn_jkl',
|
|
342
|
+
task: { id: 'call-1', label: '', state: 'running', source: 'runtime' },
|
|
343
|
+
},
|
|
344
|
+
{ type: 'text-delta', id: 't', delta: 'Done with safe parser.' },
|
|
345
|
+
]),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
349
|
+
await sendAndWait()
|
|
350
|
+
|
|
351
|
+
await waitFor(() => {
|
|
352
|
+
expect(screen.getByText('Done with safe parser.')).toBeInTheDocument()
|
|
353
|
+
})
|
|
354
|
+
expect(screen.queryByText('Plan')).not.toBeInTheDocument()
|
|
355
|
+
})
|
|
356
|
+
})
|
|
@@ -93,7 +93,7 @@ const dict = {
|
|
|
93
93
|
'ai_assistant.chat.emptyTranscript':
|
|
94
94
|
'No messages yet. Ask the agent anything to get started.',
|
|
95
95
|
'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
|
|
96
|
-
'ai_assistant.chat.agentTasksTitle': '
|
|
96
|
+
'ai_assistant.chat.agentTasksTitle': 'Tool calls',
|
|
97
97
|
'ai_assistant.chat.regionLabel': 'AI chat',
|
|
98
98
|
'ai_assistant.chat.send': 'Send message',
|
|
99
99
|
'ai_assistant.chat.shortcutHint':
|
|
@@ -454,7 +454,7 @@ describe('<AiChat>', () => {
|
|
|
454
454
|
await waitFor(() => {
|
|
455
455
|
expect(screen.getByText('customers.analyze_deals')).toBeInTheDocument()
|
|
456
456
|
})
|
|
457
|
-
expect(screen.getByText('
|
|
457
|
+
expect(screen.getByText('Tool calls')).toBeInTheDocument()
|
|
458
458
|
expect(screen.getByText('Analysis complete.')).toBeInTheDocument()
|
|
459
459
|
expect(screen.queryByText(/Tool call:/i)).not.toBeInTheDocument()
|
|
460
460
|
})
|
|
@@ -485,6 +485,48 @@ describe('<AiChat>', () => {
|
|
|
485
485
|
})
|
|
486
486
|
})
|
|
487
487
|
|
|
488
|
+
it('uses content-specific welcome suggestion icons', () => {
|
|
489
|
+
renderWithProviders(
|
|
490
|
+
<AiChat
|
|
491
|
+
agent="customers.account_assistant"
|
|
492
|
+
suggestions={[
|
|
493
|
+
{ label: 'Analyze stalled deals', prompt: 'Analyze stalled deals' },
|
|
494
|
+
{ label: 'Show at-risk pipeline', prompt: 'Show at-risk pipeline' },
|
|
495
|
+
{ label: 'What can you help me with?', prompt: 'What can you help me with?' },
|
|
496
|
+
{ label: 'Show what data you can access', prompt: 'Show what data you can access' },
|
|
497
|
+
{ label: 'Suggest things to try', prompt: 'Suggest things to try' },
|
|
498
|
+
{ label: 'How do I use this assistant?', prompt: 'How do I use this assistant?' },
|
|
499
|
+
]}
|
|
500
|
+
/>,
|
|
501
|
+
{ dict },
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
expect(screen.getByRole('button', { name: 'Analyze stalled deals' })).toHaveAttribute(
|
|
505
|
+
'data-ai-chat-suggestion-icon',
|
|
506
|
+
'analysis',
|
|
507
|
+
)
|
|
508
|
+
expect(screen.getByRole('button', { name: 'Show at-risk pipeline' })).toHaveAttribute(
|
|
509
|
+
'data-ai-chat-suggestion-icon',
|
|
510
|
+
'risk',
|
|
511
|
+
)
|
|
512
|
+
expect(screen.getByRole('button', { name: 'What can you help me with?' })).toHaveAttribute(
|
|
513
|
+
'data-ai-chat-suggestion-icon',
|
|
514
|
+
'capabilities',
|
|
515
|
+
)
|
|
516
|
+
expect(screen.getByRole('button', { name: 'Show what data you can access' })).toHaveAttribute(
|
|
517
|
+
'data-ai-chat-suggestion-icon',
|
|
518
|
+
'data',
|
|
519
|
+
)
|
|
520
|
+
expect(screen.getByRole('button', { name: 'Suggest things to try' })).toHaveAttribute(
|
|
521
|
+
'data-ai-chat-suggestion-icon',
|
|
522
|
+
'ideas',
|
|
523
|
+
)
|
|
524
|
+
expect(screen.getByRole('button', { name: 'How do I use this assistant?' })).toHaveAttribute(
|
|
525
|
+
'data-ai-chat-suggestion-icon',
|
|
526
|
+
'guide',
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
|
|
488
530
|
it('surfaces dispatcher error envelopes via Alert and onError callback', async () => {
|
|
489
531
|
const fetchMock = apiFetch as unknown as jest.Mock
|
|
490
532
|
fetchMock.mockResolvedValueOnce(
|
|
@@ -33,12 +33,13 @@ function formatValue(value: unknown): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function DiffRow({ entry }: { entry: AiPendingActionCardFieldDiff }) {
|
|
36
|
-
const before = formatValue(entry.before)
|
|
37
|
-
const after = formatValue(entry.after)
|
|
36
|
+
const before = formatValue(entry.beforeDisplay ?? entry.before)
|
|
37
|
+
const after = formatValue(entry.afterDisplay ?? entry.after)
|
|
38
|
+
const field = entry.fieldLabel ?? entry.field
|
|
38
39
|
return (
|
|
39
40
|
<tr className="border-b border-border last:border-b-0" data-ai-field-diff-row>
|
|
40
41
|
<td className="py-1.5 pr-4 text-xs font-mono text-muted-foreground align-top">
|
|
41
|
-
{
|
|
42
|
+
{field}
|
|
42
43
|
</td>
|
|
43
44
|
<td
|
|
44
45
|
className="py-1.5 pr-4 text-sm align-top text-status-warning-text"
|
|
@@ -37,6 +37,30 @@ describe('FieldDiffCard', () => {
|
|
|
37
37
|
expect(after?.className).toContain('text-status-success-text')
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
it('prefers display labels while preserving raw diff fields for the row contract', () => {
|
|
41
|
+
renderWithProviders(
|
|
42
|
+
<FieldDiffCard
|
|
43
|
+
fieldDiff={[
|
|
44
|
+
{
|
|
45
|
+
field: 'pipelineStageId',
|
|
46
|
+
fieldLabel: 'Pipeline stage',
|
|
47
|
+
before: '11111111-1111-4111-8111-111111111111',
|
|
48
|
+
after: '22222222-2222-4222-8222-222222222222',
|
|
49
|
+
beforeDisplay: 'Offering',
|
|
50
|
+
afterDisplay: 'Lost',
|
|
51
|
+
},
|
|
52
|
+
]}
|
|
53
|
+
/>,
|
|
54
|
+
{ dict },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(screen.getByText('Pipeline stage')).toBeInTheDocument()
|
|
58
|
+
expect(screen.getByText('Offering')).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByText('Lost')).toBeInTheDocument()
|
|
60
|
+
expect(screen.queryByText('11111111-1111-4111-8111-111111111111')).not.toBeInTheDocument()
|
|
61
|
+
expect(screen.queryByText('22222222-2222-4222-8222-222222222222')).not.toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
40
64
|
it('renders an info placeholder when fieldDiff is empty (single-record mode)', () => {
|
|
41
65
|
renderWithProviders(<FieldDiffCard fieldDiff={[]} />, { dict })
|
|
42
66
|
expect(
|
package/src/ai/parts/types.ts
CHANGED
|
@@ -19,8 +19,11 @@ export type AiPendingActionCardStatus =
|
|
|
19
19
|
|
|
20
20
|
export type AiPendingActionCardFieldDiff = {
|
|
21
21
|
field: string
|
|
22
|
+
fieldLabel?: string
|
|
22
23
|
before: unknown
|
|
23
24
|
after: unknown
|
|
25
|
+
beforeDisplay?: unknown
|
|
26
|
+
afterDisplay?: unknown
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export type AiPendingActionCardRecordDiff = {
|