@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.
Files changed (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -8
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +92 -93
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiIcon.js +17 -0
  8. package/dist/ai/AiIcon.js.map +7 -0
  9. package/dist/ai/LoopDisabledBanner.js +28 -0
  10. package/dist/ai/LoopDisabledBanner.js.map +7 -0
  11. package/dist/ai/LoopTracePanel.js +123 -0
  12. package/dist/ai/LoopTracePanel.js.map +7 -0
  13. package/dist/ai/ModelPicker.js.map +1 -1
  14. package/dist/ai/index.js +8 -0
  15. package/dist/ai/index.js.map +2 -2
  16. package/dist/ai/parts/ConfirmationCard.js +1 -5
  17. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  18. package/dist/ai/parts/FieldDiffCard.js +8 -15
  19. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  20. package/dist/ai/parts/MutationResultCard.js +1 -4
  21. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  22. package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
  23. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  24. package/dist/ai/useAiChat.js +18 -4
  25. package/dist/ai/useAiChat.js.map +2 -2
  26. package/dist/backend/CrudForm.js +24 -2
  27. package/dist/backend/CrudForm.js.map +2 -2
  28. package/dist/backend/DataTable.js +2 -2
  29. package/dist/backend/DataTable.js.map +2 -2
  30. package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
  31. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  32. package/dist/backend/detail/CustomDataSection.js +22 -3
  33. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  34. package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
  35. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  36. package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
  37. package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
  38. package/dist/backend/inputs/ComboboxInput.js +25 -7
  39. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  40. package/dist/backend/progress/useProgressPoll.js +58 -3
  41. package/dist/backend/progress/useProgressPoll.js.map +2 -2
  42. package/dist/backend/progress/useProgressSse.js +15 -2
  43. package/dist/backend/progress/useProgressSse.js.map +2 -2
  44. package/dist/backend/utils/bulkDelete.js +76 -0
  45. package/dist/backend/utils/bulkDelete.js.map +2 -2
  46. package/package.json +11 -6
  47. package/src/ai/AiAssistantLauncher.tsx +8 -8
  48. package/src/ai/AiChat.tsx +44 -38
  49. package/src/ai/AiIcon.tsx +12 -0
  50. package/src/ai/LoopDisabledBanner.tsx +38 -0
  51. package/src/ai/LoopTracePanel.tsx +184 -0
  52. package/src/ai/ModelPicker.tsx +1 -1
  53. package/src/ai/__tests__/AiChat.test.tsx +174 -5
  54. package/src/ai/index.ts +8 -0
  55. package/src/ai/parts/ConfirmationCard.tsx +1 -5
  56. package/src/ai/parts/FieldDiffCard.tsx +0 -3
  57. package/src/ai/parts/MutationResultCard.tsx +1 -4
  58. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
  59. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
  60. package/src/ai/useAiChat.ts +35 -5
  61. package/src/backend/CrudForm.tsx +30 -3
  62. package/src/backend/DataTable.tsx +2 -2
  63. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
  64. package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
  65. package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
  66. package/src/backend/__tests__/component-replacement.test.tsx +44 -0
  67. package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
  68. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
  69. package/src/backend/detail/CustomDataSection.tsx +26 -3
  70. package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
  71. package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
  72. package/src/backend/inputs/ComboboxInput.tsx +29 -7
  73. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
  74. package/src/backend/progress/useProgressPoll.ts +68 -3
  75. package/src/backend/progress/useProgressSse.ts +16 -2
  76. package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
  77. 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('replaces a stale stored model picker value with the first available model', async () => {
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
- 'OpenAI / GPT-5 Mini',
238
+ 'Default: openai / gpt-5-mini',
206
239
  )
207
240
  })
208
- expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBe(
209
- JSON.stringify({ providerId: 'openai', modelId: 'gpt-5-mini' }),
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 { AlertTriangle, Loader2 } from 'lucide-react'
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 { AlertTriangle, CheckCircle2, Wand2, XCircle } from 'lucide-react'
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',
@@ -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: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
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: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
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: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
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 && typeof parsed.type === 'string') {
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,
@@ -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[f.id]}
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
- // Sentinel maps back to undefined so optional selects can be cleared.
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
- setValue(field.id, undefined)
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