@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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiAssistantLauncher.js +11 -10
  3. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  4. package/dist/ai/AiChat.js +218 -83
  5. package/dist/ai/AiChat.js.map +2 -2
  6. package/dist/ai/AiIcon.js +1 -1
  7. package/dist/ai/AiIcon.js.map +2 -2
  8. package/dist/ai/parts/FieldDiffCard.js +4 -3
  9. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  10. package/dist/ai/useAiChat.js +98 -2
  11. package/dist/ai/useAiChat.js.map +2 -2
  12. package/dist/backend/DataTable.js +4 -13
  13. package/dist/backend/DataTable.js.map +2 -2
  14. package/dist/primitives/date-format.js +53 -0
  15. package/dist/primitives/date-format.js.map +7 -0
  16. package/dist/primitives/date-picker.js +3 -26
  17. package/dist/primitives/date-picker.js.map +2 -2
  18. package/dist/primitives/date-range-picker.js +3 -22
  19. package/dist/primitives/date-range-picker.js.map +2 -2
  20. package/package.json +4 -4
  21. package/src/ai/AiAssistantLauncher.tsx +11 -10
  22. package/src/ai/AiChat.tsx +200 -26
  23. package/src/ai/AiIcon.tsx +1 -1
  24. package/src/ai/__tests__/AiChat.task-plan.test.tsx +356 -0
  25. package/src/ai/__tests__/AiChat.test.tsx +44 -2
  26. package/src/ai/parts/FieldDiffCard.tsx +4 -3
  27. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +24 -0
  28. package/src/ai/parts/types.ts +3 -0
  29. package/src/ai/useAiChat.ts +154 -1
  30. package/src/backend/DataTable.tsx +12 -22
  31. package/src/primitives/__tests__/date-format.test.ts +43 -0
  32. package/src/primitives/date-format.ts +60 -0
  33. package/src/primitives/date-picker.tsx +5 -17
  34. 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': 'Agent tasks',
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('Agent tasks')).toBeInTheDocument()
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
- {entry.field}
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(
@@ -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 = {