@open-mercato/ui 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1
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 -0
- package/dist/ai/AiAssistantLauncher.js +8 -8
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +92 -93
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiIcon.js +17 -0
- package/dist/ai/AiIcon.js.map +7 -0
- package/dist/ai/LoopDisabledBanner.js +28 -0
- package/dist/ai/LoopDisabledBanner.js.map +7 -0
- package/dist/ai/LoopTracePanel.js +123 -0
- package/dist/ai/LoopTracePanel.js.map +7 -0
- package/dist/ai/ModelPicker.js.map +1 -1
- package/dist/ai/index.js +8 -0
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/parts/ConfirmationCard.js +1 -5
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +8 -15
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +1 -4
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
- package/dist/ai/useAiChat.js +18 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/CrudForm.js +24 -2
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +2 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/detail/CustomDataSection.js +22 -3
- package/dist/backend/detail/CustomDataSection.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
- package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +25 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/progress/useProgressPoll.js +58 -3
- package/dist/backend/progress/useProgressPoll.js.map +2 -2
- package/dist/backend/progress/useProgressSse.js +15 -2
- package/dist/backend/progress/useProgressSse.js.map +2 -2
- package/dist/backend/utils/bulkDelete.js +76 -0
- package/dist/backend/utils/bulkDelete.js.map +2 -2
- package/package.json +11 -6
- package/src/ai/AiAssistantLauncher.tsx +8 -8
- package/src/ai/AiChat.tsx +44 -38
- package/src/ai/AiIcon.tsx +12 -0
- package/src/ai/LoopDisabledBanner.tsx +38 -0
- package/src/ai/LoopTracePanel.tsx +184 -0
- package/src/ai/ModelPicker.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +174 -5
- package/src/ai/index.ts +8 -0
- package/src/ai/parts/ConfirmationCard.tsx +1 -5
- package/src/ai/parts/FieldDiffCard.tsx +0 -3
- package/src/ai/parts/MutationResultCard.tsx +1 -4
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
- package/src/ai/useAiChat.ts +35 -5
- package/src/backend/CrudForm.tsx +30 -3
- package/src/backend/DataTable.tsx +2 -2
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
- package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
- package/src/backend/__tests__/component-replacement.test.tsx +44 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
- package/src/backend/detail/CustomDataSection.tsx +26 -3
- package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
- package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
- package/src/backend/inputs/ComboboxInput.tsx +29 -7
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
- package/src/backend/progress/useProgressPoll.ts +68 -3
- package/src/backend/progress/useProgressSse.ts +16 -2
- package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
- package/src/backend/utils/bulkDelete.ts +93 -0
|
@@ -93,6 +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
97
|
'ai_assistant.chat.regionLabel': 'AI chat',
|
|
97
98
|
'ai_assistant.chat.send': 'Send message',
|
|
98
99
|
'ai_assistant.chat.shortcutHint':
|
|
@@ -109,6 +110,7 @@ type ResponseLike = {
|
|
|
109
110
|
ok: boolean
|
|
110
111
|
status: number
|
|
111
112
|
body: ReadableStream<Uint8Array> | null
|
|
113
|
+
headers?: { get: (name: string) => string | null }
|
|
112
114
|
clone: () => ResponseLike
|
|
113
115
|
json: () => Promise<unknown>
|
|
114
116
|
text: () => Promise<string>
|
|
@@ -136,6 +138,37 @@ function createStreamingResponse(chunks: string[]): ResponseLike {
|
|
|
136
138
|
return self
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
function createUiMessageSseResponse(chunks: Array<Record<string, unknown>>): ResponseLike {
|
|
142
|
+
const encoder = new TextEncoder()
|
|
143
|
+
const raw = chunks
|
|
144
|
+
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
|
145
|
+
.join('')
|
|
146
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
147
|
+
start(controller) {
|
|
148
|
+
controller.enqueue(encoder.encode(`${raw}data: [DONE]\n\n`))
|
|
149
|
+
controller.close()
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
const headers = {
|
|
153
|
+
get: (name: string) => {
|
|
154
|
+
const normalized = name.toLowerCase()
|
|
155
|
+
if (normalized === 'content-type') return 'text/event-stream'
|
|
156
|
+
if (normalized === 'x-vercel-ai-ui-message-stream') return 'v1'
|
|
157
|
+
return null
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
const self: ResponseLike = {
|
|
161
|
+
ok: true,
|
|
162
|
+
status: 200,
|
|
163
|
+
body: stream,
|
|
164
|
+
headers,
|
|
165
|
+
clone: () => ({ ...self, body: null }),
|
|
166
|
+
json: async () => ({}),
|
|
167
|
+
text: async () => raw,
|
|
168
|
+
}
|
|
169
|
+
return self
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
function createErrorResponse(status: number, payload: Record<string, unknown>): ResponseLike {
|
|
140
173
|
const jsonText = JSON.stringify(payload)
|
|
141
174
|
const self: ResponseLike = {
|
|
@@ -166,7 +199,7 @@ describe('<AiChat>', () => {
|
|
|
166
199
|
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled()
|
|
167
200
|
})
|
|
168
201
|
|
|
169
|
-
it('
|
|
202
|
+
it('clears a stale stored model picker value back to the agent default', async () => {
|
|
170
203
|
const apiCallMock = apiCall as unknown as jest.Mock
|
|
171
204
|
apiCallMock.mockResolvedValueOnce({
|
|
172
205
|
ok: true,
|
|
@@ -202,12 +235,64 @@ describe('<AiChat>', () => {
|
|
|
202
235
|
await waitFor(() => {
|
|
203
236
|
expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveAttribute(
|
|
204
237
|
'title',
|
|
205
|
-
'
|
|
238
|
+
'Default: openai / gpt-5-mini',
|
|
206
239
|
)
|
|
207
240
|
})
|
|
208
|
-
expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).
|
|
209
|
-
|
|
210
|
-
|
|
241
|
+
expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBeNull()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not send provider or model overrides while the model picker is on Default', async () => {
|
|
245
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
246
|
+
apiCallMock.mockResolvedValueOnce({
|
|
247
|
+
ok: true,
|
|
248
|
+
status: 200,
|
|
249
|
+
result: {
|
|
250
|
+
agentId: 'customers.deal_analyzer',
|
|
251
|
+
allowRuntimeModelOverride: true,
|
|
252
|
+
defaultProviderId: 'openai',
|
|
253
|
+
defaultModelId: 'gpt-5-mini',
|
|
254
|
+
providers: [
|
|
255
|
+
{
|
|
256
|
+
id: 'openai',
|
|
257
|
+
name: 'OpenAI',
|
|
258
|
+
isDefault: true,
|
|
259
|
+
models: [
|
|
260
|
+
{
|
|
261
|
+
id: 'gpt-5-mini',
|
|
262
|
+
name: 'GPT-5 Mini',
|
|
263
|
+
isDefault: true,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
271
|
+
fetchMock.mockResolvedValueOnce(createStreamingResponse(['Done']))
|
|
272
|
+
|
|
273
|
+
renderWithProviders(<AiChat agent="customers.deal_analyzer" />, { dict })
|
|
274
|
+
|
|
275
|
+
await waitFor(() => {
|
|
276
|
+
expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveAttribute(
|
|
277
|
+
'title',
|
|
278
|
+
'Default: openai / gpt-5-mini',
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
283
|
+
fireEvent.change(textarea, { target: { value: 'Analyze deals' } })
|
|
284
|
+
await act(async () => {
|
|
285
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await waitFor(() => {
|
|
289
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
290
|
+
})
|
|
291
|
+
const [url] = fetchMock.mock.calls[0]
|
|
292
|
+
const parsedUrl = new URL(String(url), 'http://localhost')
|
|
293
|
+
expect(parsedUrl.searchParams.get('agent')).toBe('customers.deal_analyzer')
|
|
294
|
+
expect(parsedUrl.searchParams.has('provider')).toBe(false)
|
|
295
|
+
expect(parsedUrl.searchParams.has('model')).toBe(false)
|
|
211
296
|
})
|
|
212
297
|
|
|
213
298
|
it('renders a compact footer before a constrained host is measured', async () => {
|
|
@@ -334,6 +419,46 @@ describe('<AiChat>', () => {
|
|
|
334
419
|
expect(screen.getByText('Hi there')).toBeInTheDocument()
|
|
335
420
|
})
|
|
336
421
|
|
|
422
|
+
it('renders AI SDK tool-call chunks as tool call rows with display names', async () => {
|
|
423
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
424
|
+
fetchMock.mockResolvedValueOnce(
|
|
425
|
+
createUiMessageSseResponse([
|
|
426
|
+
{
|
|
427
|
+
type: 'tool-input-start',
|
|
428
|
+
toolCallId: 'call-1',
|
|
429
|
+
toolName: 'customers__analyze_deals',
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
type: 'tool-input-available',
|
|
433
|
+
toolCallId: 'call-1',
|
|
434
|
+
toolName: 'customers__analyze_deals',
|
|
435
|
+
input: { dealStageFilter: 'open', daysOfActivityWindow: 30 },
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
type: 'tool-output-available',
|
|
439
|
+
toolCallId: 'call-1',
|
|
440
|
+
output: { totalAnalyzed: 3, stalledCount: 1 },
|
|
441
|
+
},
|
|
442
|
+
{ type: 'text-delta', id: 'text-1', delta: 'Analysis complete.' },
|
|
443
|
+
]),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
447
|
+
|
|
448
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
449
|
+
fireEvent.change(textarea, { target: { value: 'Analyze deals' } })
|
|
450
|
+
await act(async () => {
|
|
451
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
await waitFor(() => {
|
|
455
|
+
expect(screen.getByText('customers.analyze_deals')).toBeInTheDocument()
|
|
456
|
+
})
|
|
457
|
+
expect(screen.getByText('Agent tasks')).toBeInTheDocument()
|
|
458
|
+
expect(screen.getByText('Analysis complete.')).toBeInTheDocument()
|
|
459
|
+
expect(screen.queryByText(/Tool call:/i)).not.toBeInTheDocument()
|
|
460
|
+
})
|
|
461
|
+
|
|
337
462
|
it('submits suggested prompts visibly after React StrictMode mount replay', async () => {
|
|
338
463
|
const fetchMock = apiFetch as unknown as jest.Mock
|
|
339
464
|
fetchMock.mockResolvedValueOnce(createStreamingResponse(['Suggested answer']))
|
|
@@ -461,4 +586,48 @@ describe('<AiChat>', () => {
|
|
|
461
586
|
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
|
|
462
587
|
})
|
|
463
588
|
})
|
|
589
|
+
|
|
590
|
+
it('hides Thinking once visible text has streamed even if the stream remains open', async () => {
|
|
591
|
+
const fetchMock = apiFetch as unknown as jest.Mock
|
|
592
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
593
|
+
const encoder = new TextEncoder()
|
|
594
|
+
const pendingStream = new ReadableStream<Uint8Array>({
|
|
595
|
+
start(controller) {
|
|
596
|
+
streamController = controller
|
|
597
|
+
controller.enqueue(encoder.encode('partial answer'))
|
|
598
|
+
},
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
fetchMock.mockResolvedValueOnce({
|
|
602
|
+
ok: true,
|
|
603
|
+
status: 200,
|
|
604
|
+
body: pendingStream,
|
|
605
|
+
clone: () => ({
|
|
606
|
+
ok: true,
|
|
607
|
+
status: 200,
|
|
608
|
+
body: null,
|
|
609
|
+
clone: () => ({}),
|
|
610
|
+
json: async () => ({}),
|
|
611
|
+
text: async () => '',
|
|
612
|
+
}),
|
|
613
|
+
json: async () => ({}),
|
|
614
|
+
text: async () => '',
|
|
615
|
+
} as ResponseLike)
|
|
616
|
+
|
|
617
|
+
renderWithProviders(<AiChat agent="catalog.catalog_assistant" />, { dict })
|
|
618
|
+
const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
|
|
619
|
+
fireEvent.change(textarea, { target: { value: 'Suggest five questions' } })
|
|
620
|
+
await act(async () => {
|
|
621
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
await waitFor(() => {
|
|
625
|
+
expect(screen.getByText('partial answer')).toBeInTheDocument()
|
|
626
|
+
})
|
|
627
|
+
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
|
|
628
|
+
|
|
629
|
+
await act(async () => {
|
|
630
|
+
streamController?.close()
|
|
631
|
+
})
|
|
632
|
+
})
|
|
464
633
|
})
|
package/src/ai/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AiChat, type AiChatProps, type AiChatDebugTool, type AiChatDebugPromptSection } from './AiChat'
|
|
2
|
+
export { AiIcon } from './AiIcon'
|
|
2
3
|
export {
|
|
3
4
|
AiAssistantLauncher,
|
|
4
5
|
AI_ASSISTANT_LAUNCHER_OPEN_EVENT,
|
|
@@ -124,3 +125,10 @@ export {
|
|
|
124
125
|
type AiChatUploadFileState,
|
|
125
126
|
type AiChatUploadFileStatus,
|
|
126
127
|
} from './useAiChatUpload'
|
|
128
|
+
export { LoopDisabledBanner } from './LoopDisabledBanner'
|
|
129
|
+
export {
|
|
130
|
+
LoopTracePanel,
|
|
131
|
+
type LoopTracePanelProps,
|
|
132
|
+
type LoopTracePanelTrace,
|
|
133
|
+
type LoopTracePanelStepRecord,
|
|
134
|
+
} from './LoopTracePanel'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { Loader2 } from 'lucide-react'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'
|
|
7
7
|
import { Button } from '../../primitives/button'
|
|
@@ -214,7 +214,6 @@ function ConfirmationErrorAlert({
|
|
|
214
214
|
className="mt-3"
|
|
215
215
|
data-ai-confirmation-error="stale_version"
|
|
216
216
|
>
|
|
217
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
218
217
|
<AlertTitle>
|
|
219
218
|
{t(
|
|
220
219
|
'ai_assistant.chat.mutation_cards.confirmation.staleVersionTitle',
|
|
@@ -252,7 +251,6 @@ function ConfirmationErrorAlert({
|
|
|
252
251
|
className="mt-3"
|
|
253
252
|
data-ai-confirmation-error="schema_drift"
|
|
254
253
|
>
|
|
255
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
256
254
|
<AlertTitle>
|
|
257
255
|
{t(
|
|
258
256
|
'ai_assistant.chat.mutation_cards.confirmation.schemaDriftTitle',
|
|
@@ -276,7 +274,6 @@ function ConfirmationErrorAlert({
|
|
|
276
274
|
className="mt-3"
|
|
277
275
|
data-ai-confirmation-error="invalid_status"
|
|
278
276
|
>
|
|
279
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
280
277
|
<AlertTitle>
|
|
281
278
|
{t(
|
|
282
279
|
'ai_assistant.chat.mutation_cards.confirmation.invalidStatusTitle',
|
|
@@ -295,7 +292,6 @@ function ConfirmationErrorAlert({
|
|
|
295
292
|
|
|
296
293
|
return (
|
|
297
294
|
<Alert variant="destructive" className="mt-3" data-ai-confirmation-error={code}>
|
|
298
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
299
295
|
<AlertTitle>
|
|
300
296
|
{t('ai_assistant.chat.mutation_cards.confirmation.errorTitle', 'Confirm failed')}
|
|
301
297
|
</AlertTitle>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Info } from 'lucide-react'
|
|
5
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
5
|
import { Alert, AlertDescription } from '../../primitives/alert'
|
|
7
6
|
import type {
|
|
@@ -129,7 +128,6 @@ export function FieldDiffCard({ fieldDiff, records }: FieldDiffCardProps) {
|
|
|
129
128
|
/>
|
|
130
129
|
) : (
|
|
131
130
|
<Alert variant="info">
|
|
132
|
-
<Info className="size-4" aria-hidden />
|
|
133
131
|
<AlertDescription>
|
|
134
132
|
{t(
|
|
135
133
|
'ai_assistant.chat.mutation_cards.diff.empty',
|
|
@@ -147,7 +145,6 @@ export function FieldDiffCard({ fieldDiff, records }: FieldDiffCardProps) {
|
|
|
147
145
|
if (flat.length === 0) {
|
|
148
146
|
return (
|
|
149
147
|
<Alert variant="info" data-ai-field-diff-mode="empty">
|
|
150
|
-
<Info className="size-4" aria-hidden />
|
|
151
148
|
<AlertDescription>
|
|
152
149
|
{t(
|
|
153
150
|
'ai_assistant.chat.mutation_cards.diff.empty',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { Wand2 } from 'lucide-react'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'
|
|
7
7
|
import { Button } from '../../primitives/button'
|
|
@@ -80,7 +80,6 @@ export function MutationResultCard(props: MutationResultCardProps) {
|
|
|
80
80
|
if (isSuccessStatus(status) && failedRecords && failedRecords.length > 0) {
|
|
81
81
|
return (
|
|
82
82
|
<Alert variant="warning" data-ai-mutation-result="partial">
|
|
83
|
-
<AlertTriangle className="size-4" aria-hidden />
|
|
84
83
|
<AlertTitle>
|
|
85
84
|
{t(
|
|
86
85
|
'ai_assistant.chat.mutation_cards.result.partialTitle',
|
|
@@ -118,7 +117,6 @@ export function MutationResultCard(props: MutationResultCardProps) {
|
|
|
118
117
|
const href = payload.recordHref ?? null
|
|
119
118
|
return (
|
|
120
119
|
<Alert variant="success" data-ai-mutation-result="success">
|
|
121
|
-
<CheckCircle2 className="size-4" aria-hidden />
|
|
122
120
|
<AlertTitle>
|
|
123
121
|
{t('ai_assistant.chat.mutation_cards.result.successTitle', 'Action applied')}
|
|
124
122
|
</AlertTitle>
|
|
@@ -296,7 +294,6 @@ export function MutationResultCard(props: MutationResultCardProps) {
|
|
|
296
294
|
: []
|
|
297
295
|
return (
|
|
298
296
|
<Alert variant="destructive" data-ai-mutation-result="failure">
|
|
299
|
-
<XCircle className="size-4" aria-hidden />
|
|
300
297
|
<AlertTitle>
|
|
301
298
|
{t(
|
|
302
299
|
'ai_assistant.chat.mutation_cards.result.failureTitle',
|
|
@@ -78,6 +78,11 @@ describe('MutationResultCard', () => {
|
|
|
78
78
|
expect(
|
|
79
79
|
document.querySelector('[data-ai-mutation-result="success"]'),
|
|
80
80
|
).not.toBeNull()
|
|
81
|
+
expect(
|
|
82
|
+
document.querySelectorAll(
|
|
83
|
+
'[data-ai-mutation-result="success"] [data-slot="alert-icon-badge"]',
|
|
84
|
+
),
|
|
85
|
+
).toHaveLength(1)
|
|
81
86
|
expect(screen.getByText('Action applied')).toBeInTheDocument()
|
|
82
87
|
const link = document.querySelector('[data-ai-mutation-result-link]') as HTMLAnchorElement
|
|
83
88
|
expect(link).not.toBeNull()
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Info } from 'lucide-react'
|
|
5
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
5
|
import { Alert, AlertDescription, AlertTitle } from '../../primitives/alert'
|
|
7
6
|
import type { AiUiPartProps } from '../ui-part-registry'
|
|
@@ -27,7 +26,6 @@ export function PendingPhase3Placeholder({ componentId }: AiUiPartProps) {
|
|
|
27
26
|
variant="info"
|
|
28
27
|
data-ai-ui-part-pending-phase3={componentId}
|
|
29
28
|
>
|
|
30
|
-
<Info className="size-4" aria-hidden />
|
|
31
29
|
<AlertTitle>
|
|
32
30
|
{t(
|
|
33
31
|
'ai_assistant.chat.pending_phase3.title',
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { createAiAgentTransport } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport'
|
|
5
5
|
import { apiFetch } from '../backend/utils/api'
|
|
6
|
+
import type { LoopTracePanelTrace } from './LoopTracePanel'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Chat message shape used by {@link AiChat}. Kept intentionally minimal so the
|
|
@@ -102,6 +103,15 @@ export interface UseAiChatResult {
|
|
|
102
103
|
* given mount (Phase 3 WS-D contract with `prepareMutation`).
|
|
103
104
|
*/
|
|
104
105
|
conversationId: string
|
|
106
|
+
/**
|
|
107
|
+
* Loop trace from the last completed turn. Populated when the dispatcher
|
|
108
|
+
* emits a `loop-finish` SSE event at the end of the response stream.
|
|
109
|
+
* `null` until the first turn completes or when the dispatcher does not
|
|
110
|
+
* emit trace events (non-debug deployments may omit them).
|
|
111
|
+
*
|
|
112
|
+
* Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
|
|
113
|
+
*/
|
|
114
|
+
lastLoopTrace: LoopTracePanelTrace | null
|
|
105
115
|
sendMessage: (input: string, files?: AiChatMessageFile[]) => Promise<void>
|
|
106
116
|
cancel: () => void
|
|
107
117
|
reset: () => void
|
|
@@ -432,6 +442,11 @@ function updateToolCall(
|
|
|
432
442
|
return { ...state, toolCalls: nextCalls }
|
|
433
443
|
}
|
|
434
444
|
|
|
445
|
+
function displayToolName(toolName: unknown): string | undefined {
|
|
446
|
+
if (typeof toolName !== 'string') return undefined
|
|
447
|
+
return toolName.replace(/__/g, '.')
|
|
448
|
+
}
|
|
449
|
+
|
|
435
450
|
function applyChunk(
|
|
436
451
|
state: AssistantBuilderState,
|
|
437
452
|
chunk: { type: string; [key: string]: unknown },
|
|
@@ -455,12 +470,12 @@ function applyChunk(
|
|
|
455
470
|
return { ...state, reasoningStreaming: false }
|
|
456
471
|
case 'tool-input-start':
|
|
457
472
|
return updateToolCall(state, String(chunk.toolCallId ?? ''), {
|
|
458
|
-
toolName:
|
|
473
|
+
toolName: displayToolName(chunk.toolName),
|
|
459
474
|
state: 'pending',
|
|
460
475
|
})
|
|
461
476
|
case 'tool-input-available':
|
|
462
477
|
return updateToolCall(state, String(chunk.toolCallId ?? ''), {
|
|
463
|
-
toolName:
|
|
478
|
+
toolName: displayToolName(chunk.toolName),
|
|
464
479
|
input: chunk.input,
|
|
465
480
|
state: 'pending',
|
|
466
481
|
})
|
|
@@ -496,7 +511,7 @@ function applyChunk(
|
|
|
496
511
|
})
|
|
497
512
|
case 'tool-input-error':
|
|
498
513
|
return updateToolCall(state, String(chunk.toolCallId ?? ''), {
|
|
499
|
-
toolName:
|
|
514
|
+
toolName: displayToolName(chunk.toolName),
|
|
500
515
|
input: chunk.input,
|
|
501
516
|
state: 'error',
|
|
502
517
|
errorMessage:
|
|
@@ -719,6 +734,8 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
719
734
|
{ status: number; text: string } | null
|
|
720
735
|
>(null)
|
|
721
736
|
|
|
737
|
+
const [lastLoopTrace, setLastLoopTrace] = React.useState<LoopTracePanelTrace | null>(null)
|
|
738
|
+
|
|
722
739
|
const abortRef = React.useRef<AbortController | null>(null)
|
|
723
740
|
const onErrorRef = React.useRef(onError)
|
|
724
741
|
React.useEffect(() => {
|
|
@@ -752,6 +769,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
752
769
|
setError(null)
|
|
753
770
|
setLastRequestDebug(null)
|
|
754
771
|
setLastResponseDebug(null)
|
|
772
|
+
setLastLoopTrace(null)
|
|
755
773
|
clearPersistedSession(agent, persistKey)
|
|
756
774
|
mintedConversationIdRef.current = makeConversationId()
|
|
757
775
|
}, [agent, cancel, persistKey, updateMessages])
|
|
@@ -857,6 +875,10 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
857
875
|
return
|
|
858
876
|
}
|
|
859
877
|
|
|
878
|
+
// Per-turn loop trace collected from the `loop-finish` SSE event emitted
|
|
879
|
+
// by the dispatcher at the end of the stream (Phase 4).
|
|
880
|
+
let pendingLoopTrace: LoopTracePanelTrace | null = null
|
|
881
|
+
|
|
860
882
|
const headerGet = (name: string): string | null => {
|
|
861
883
|
const headers = (response as { headers?: { get?: (k: string) => string | null } })
|
|
862
884
|
.headers
|
|
@@ -886,8 +908,12 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
886
908
|
if (!data) continue
|
|
887
909
|
if (data === '[DONE]') continue
|
|
888
910
|
try {
|
|
889
|
-
const parsed = JSON.parse(data) as { type?: string }
|
|
890
|
-
if (parsed
|
|
911
|
+
const parsed = JSON.parse(data) as { type?: string; trace?: unknown }
|
|
912
|
+
if (!parsed || typeof parsed.type !== 'string') continue
|
|
913
|
+
if (parsed.type === 'loop-finish') {
|
|
914
|
+
// Capture the loop trace for post-stream state update.
|
|
915
|
+
pendingLoopTrace = parsed.trace as LoopTracePanelTrace ?? null
|
|
916
|
+
} else {
|
|
891
917
|
builder = applyChunk(builder, parsed as { type: string })
|
|
892
918
|
}
|
|
893
919
|
} catch {
|
|
@@ -946,6 +972,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
946
972
|
)
|
|
947
973
|
if (mountedRef.current) {
|
|
948
974
|
setLastResponseDebug({ status: response.status, text: streamedRaw })
|
|
975
|
+
if (pendingLoopTrace !== null) {
|
|
976
|
+
setLastLoopTrace(pendingLoopTrace)
|
|
977
|
+
}
|
|
949
978
|
}
|
|
950
979
|
const isEmpty =
|
|
951
980
|
!builder.text.trim() && builder.toolCalls.length === 0 && !builder.reasoning
|
|
@@ -1047,6 +1076,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
1047
1076
|
lastRequestDebug,
|
|
1048
1077
|
lastResponseDebug,
|
|
1049
1078
|
conversationId: effectiveConversationId,
|
|
1079
|
+
lastLoopTrace,
|
|
1050
1080
|
sendMessage,
|
|
1051
1081
|
cancel,
|
|
1052
1082
|
reset,
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -407,6 +407,22 @@ function readInitialCustomFieldValue(
|
|
|
407
407
|
return undefined
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
function readRenderedFieldValue(
|
|
411
|
+
source: Record<string, unknown>,
|
|
412
|
+
fieldId: string,
|
|
413
|
+
): unknown {
|
|
414
|
+
if (Object.prototype.hasOwnProperty.call(source, fieldId)) {
|
|
415
|
+
return source[fieldId]
|
|
416
|
+
}
|
|
417
|
+
if (fieldId.startsWith('cf_')) {
|
|
418
|
+
return readInitialCustomFieldValue(source, fieldId.slice(3))
|
|
419
|
+
}
|
|
420
|
+
if (fieldId.startsWith('cf:')) {
|
|
421
|
+
return readInitialCustomFieldValue(source, fieldId.slice(3))
|
|
422
|
+
}
|
|
423
|
+
return undefined
|
|
424
|
+
}
|
|
425
|
+
|
|
410
426
|
function serializeIssuePath(path: ReadonlyArray<string | number | symbol>): string | null {
|
|
411
427
|
if (!Array.isArray(path) || path.length === 0) return null
|
|
412
428
|
const segments = path
|
|
@@ -837,9 +853,18 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
837
853
|
if (!target || navigationPromptBypassRef.current || submitNavigationBypassRef.current) {
|
|
838
854
|
return original(data, unused, url)
|
|
839
855
|
}
|
|
856
|
+
if (!isDirtyRef.current) {
|
|
857
|
+
return original(data, unused, url)
|
|
858
|
+
}
|
|
840
859
|
if (shouldBypassUnsavedChangesGuardRef.current?.(target)) {
|
|
841
860
|
return original(data, unused, url)
|
|
842
861
|
}
|
|
862
|
+
const baselineSnapshot = dirtyBaselineSnapshotRef.current
|
|
863
|
+
if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
|
|
864
|
+
isDirtyRef.current = false
|
|
865
|
+
setHasUnsavedChanges(false)
|
|
866
|
+
return original(data, unused, url)
|
|
867
|
+
}
|
|
843
868
|
if (navigationConfirmPendingRef.current) {
|
|
844
869
|
return undefined
|
|
845
870
|
}
|
|
@@ -2677,7 +2702,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2677
2702
|
<FieldControl
|
|
2678
2703
|
key={f.id}
|
|
2679
2704
|
field={f}
|
|
2680
|
-
value={values
|
|
2705
|
+
value={readRenderedFieldValue(values as Record<string, unknown>, f.id)}
|
|
2681
2706
|
error={errors[f.id]}
|
|
2682
2707
|
options={fieldOptionsById.get(f.id) || EMPTY_OPTIONS}
|
|
2683
2708
|
setValue={setValue}
|
|
@@ -4081,9 +4106,11 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4081
4106
|
: String(value)
|
|
4082
4107
|
}
|
|
4083
4108
|
onValueChange={(next) => {
|
|
4084
|
-
//
|
|
4109
|
+
// Custom field clears must be explicit nulls; normal optional
|
|
4110
|
+
// fields keep the existing undefined clear semantics.
|
|
4085
4111
|
if (!next || next === SELECT_CLEAR_SENTINEL) {
|
|
4086
|
-
|
|
4112
|
+
const isCustomField = field.id.startsWith('cf_') || field.id.startsWith('cf:')
|
|
4113
|
+
setValue(field.id, isCustomField ? null : undefined)
|
|
4087
4114
|
return
|
|
4088
4115
|
}
|
|
4089
4116
|
setValue(field.id, next)
|
|
@@ -1975,7 +1975,7 @@ export function DataTable<T>({
|
|
|
1975
1975
|
)).sort((left, right) => left - right)
|
|
1976
1976
|
: []
|
|
1977
1977
|
const pageSizeSelect = pageSizeOptions.length > 0 && pagination.onPageSizeChange ? (
|
|
1978
|
-
<span className="inline-flex items-center gap-1.5">
|
|
1978
|
+
<span className="inline-flex flex-none items-center gap-1.5 whitespace-nowrap">
|
|
1979
1979
|
<Select
|
|
1980
1980
|
value={String(pagination.pageSize)}
|
|
1981
1981
|
onValueChange={(value) => {
|
|
@@ -1995,7 +1995,7 @@ export function DataTable<T>({
|
|
|
1995
1995
|
))}
|
|
1996
1996
|
</SelectContent>
|
|
1997
1997
|
</Select>
|
|
1998
|
-
<span className="text-muted-foreground">{t('ui.dataTable.pagination.perPage', 'per page')}</span>
|
|
1998
|
+
<span className="whitespace-nowrap text-muted-foreground">{t('ui.dataTable.pagination.perPage', 'per page')}</span>
|
|
1999
1999
|
</span>
|
|
2000
2000
|
) : null
|
|
2001
2001
|
|
|
@@ -204,6 +204,78 @@ describe('CrudForm custom field loading', () => {
|
|
|
204
204
|
expect(loadOptions).toHaveBeenCalled()
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
+
it('renders custom select values from bare-key initialValues', async () => {
|
|
208
|
+
buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
|
|
209
|
+
id: `cf_${definition.key}`,
|
|
210
|
+
label: definition.label ?? definition.key,
|
|
211
|
+
type: 'select',
|
|
212
|
+
options: definition.options,
|
|
213
|
+
}))
|
|
214
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
215
|
+
fields: [],
|
|
216
|
+
definitions: [
|
|
217
|
+
{
|
|
218
|
+
entityId: 'customers:customer_company_profile',
|
|
219
|
+
key: 'relationship_health',
|
|
220
|
+
label: 'Relationship health',
|
|
221
|
+
kind: 'select',
|
|
222
|
+
options: [
|
|
223
|
+
{ value: 'healthy', label: 'Healthy' },
|
|
224
|
+
{ value: 'monitor', label: 'Monitor' },
|
|
225
|
+
{ value: 'at_risk', label: 'At risk' },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
entityId: 'customers:customer_company_profile',
|
|
230
|
+
key: 'renewal_quarter',
|
|
231
|
+
label: 'Renewal quarter',
|
|
232
|
+
kind: 'select',
|
|
233
|
+
options: [
|
|
234
|
+
{ value: 'Q1', label: 'Q1' },
|
|
235
|
+
{ value: 'Q2', label: 'Q2' },
|
|
236
|
+
{ value: 'Q3', label: 'Q3' },
|
|
237
|
+
{ value: 'Q4', label: 'Q4' },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
metadata: {
|
|
242
|
+
items: [],
|
|
243
|
+
fieldsetsByEntity: {},
|
|
244
|
+
entitySettings: {},
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const { container } = renderWithProviders(
|
|
249
|
+
<CrudForm
|
|
250
|
+
embedded
|
|
251
|
+
title="Form"
|
|
252
|
+
entityId="customers:customer_company_profile"
|
|
253
|
+
fields={fields}
|
|
254
|
+
groups={groups}
|
|
255
|
+
initialValues={{ name: 'Acme', relationship_health: 'healthy', renewal_quarter: 'Q3' }}
|
|
256
|
+
onSubmit={() => {}}
|
|
257
|
+
/>,
|
|
258
|
+
{
|
|
259
|
+
dict: {
|
|
260
|
+
'ui.forms.actions.save': 'Save',
|
|
261
|
+
'entities.customFields.manageFieldset': 'Manage fields',
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const getSelectValue = (fieldId: string) =>
|
|
267
|
+
(container.querySelector(`[data-crud-field-id="${fieldId}"] select`) as HTMLSelectElement | null)?.value
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(getSelectValue('cf_relationship_health')).toBe('healthy')
|
|
271
|
+
})
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(getSelectValue('cf_renewal_quarter')).toBe('Q3')
|
|
274
|
+
})
|
|
275
|
+
expect(container.querySelector('[data-crud-field-id="cf_relationship_health"]')?.textContent).toContain('Healthy')
|
|
276
|
+
expect(container.querySelector('[data-crud-field-id="cf_renewal_quarter"]')?.textContent).toContain('Q3')
|
|
277
|
+
})
|
|
278
|
+
|
|
207
279
|
it('opens the field manager without submitting the parent form', async () => {
|
|
208
280
|
const handleSubmit = jest.fn().mockResolvedValue(undefined)
|
|
209
281
|
|