@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3260.1.dc719cac84

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 (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/CrudForm.js +46 -129
  16. package/dist/backend/CrudForm.js.map +3 -3
  17. package/dist/backend/DataTable.js +10 -3
  18. package/dist/backend/DataTable.js.map +2 -2
  19. package/dist/backend/EmptyState.js +1 -47
  20. package/dist/backend/EmptyState.js.map +2 -2
  21. package/dist/backend/FilterOverlay.js +31 -26
  22. package/dist/backend/FilterOverlay.js.map +2 -2
  23. package/dist/backend/FlashMessages.js +23 -25
  24. package/dist/backend/FlashMessages.js.map +2 -2
  25. package/dist/backend/JsonBuilder.js +4 -2
  26. package/dist/backend/JsonBuilder.js.map +2 -2
  27. package/dist/backend/LogList.js +49 -0
  28. package/dist/backend/LogList.js.map +7 -0
  29. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  30. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  31. package/dist/backend/detail/InlineEditors.js +80 -32
  32. package/dist/backend/detail/InlineEditors.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +60 -6
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/DatePicker.js +6 -142
  36. package/dist/backend/inputs/DatePicker.js.map +2 -2
  37. package/dist/backend/inputs/DateTimePicker.js +6 -182
  38. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  39. package/dist/backend/inputs/TagsInput.js +16 -19
  40. package/dist/backend/inputs/TagsInput.js.map +2 -2
  41. package/dist/backend/inputs/TimeInput.js +1 -1
  42. package/dist/backend/inputs/TimeInput.js.map +2 -2
  43. package/dist/backend/inputs/TimePicker.js +83 -72
  44. package/dist/backend/inputs/TimePicker.js.map +2 -2
  45. package/dist/backend/utils/customFieldForms.js +2 -2
  46. package/dist/backend/utils/customFieldForms.js.map +1 -1
  47. package/dist/portal/components/PortalEmptyState.js +13 -9
  48. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  49. package/dist/primitives/accordion.js +137 -0
  50. package/dist/primitives/accordion.js.map +7 -0
  51. package/dist/primitives/alert.js +180 -12
  52. package/dist/primitives/alert.js.map +2 -2
  53. package/dist/primitives/calendar.js +122 -32
  54. package/dist/primitives/calendar.js.map +2 -2
  55. package/dist/primitives/compact-select.js +47 -0
  56. package/dist/primitives/compact-select.js.map +7 -0
  57. package/dist/primitives/counter-input.js +174 -0
  58. package/dist/primitives/counter-input.js.map +7 -0
  59. package/dist/primitives/date-picker-helpers.js +79 -0
  60. package/dist/primitives/date-picker-helpers.js.map +7 -0
  61. package/dist/primitives/date-picker.js +216 -0
  62. package/dist/primitives/date-picker.js.map +7 -0
  63. package/dist/primitives/date-range-picker.js +239 -0
  64. package/dist/primitives/date-range-picker.js.map +7 -0
  65. package/dist/primitives/digit-input.js +157 -0
  66. package/dist/primitives/digit-input.js.map +7 -0
  67. package/dist/primitives/empty-state.js +93 -0
  68. package/dist/primitives/empty-state.js.map +7 -0
  69. package/dist/primitives/inline-input.js +29 -0
  70. package/dist/primitives/inline-input.js.map +7 -0
  71. package/dist/primitives/inline-select.js +41 -0
  72. package/dist/primitives/inline-select.js.map +7 -0
  73. package/dist/primitives/notification-stack.js +116 -0
  74. package/dist/primitives/notification-stack.js.map +7 -0
  75. package/dist/primitives/notification.js +62 -0
  76. package/dist/primitives/notification.js.map +7 -0
  77. package/dist/primitives/rich-editor.js +1230 -0
  78. package/dist/primitives/rich-editor.js.map +7 -0
  79. package/dist/primitives/select.js +1 -0
  80. package/dist/primitives/select.js.map +2 -2
  81. package/dist/primitives/skeleton.js +55 -0
  82. package/dist/primitives/skeleton.js.map +7 -0
  83. package/dist/primitives/tag-input.js +186 -0
  84. package/dist/primitives/tag-input.js.map +7 -0
  85. package/dist/primitives/tag.js +66 -13
  86. package/dist/primitives/tag.js.map +2 -2
  87. package/dist/primitives/time-picker.js +793 -0
  88. package/dist/primitives/time-picker.js.map +7 -0
  89. package/package.json +4 -3
  90. package/src/ai/AiAssistantLauncher.tsx +8 -0
  91. package/src/ai/AiChat.tsx +229 -11
  92. package/src/ai/AiDock.tsx +3 -2
  93. package/src/ai/ModelPicker.tsx +248 -0
  94. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  99. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  100. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  101. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  102. package/src/ai/index.ts +1 -0
  103. package/src/ai/useAiChat.ts +29 -16
  104. package/src/backend/CrudForm.tsx +61 -113
  105. package/src/backend/DataTable.tsx +17 -5
  106. package/src/backend/EmptyState.tsx +13 -63
  107. package/src/backend/FilterOverlay.tsx +38 -22
  108. package/src/backend/FlashMessages.tsx +23 -24
  109. package/src/backend/JsonBuilder.tsx +4 -2
  110. package/src/backend/LogList.tsx +109 -0
  111. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  112. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  113. package/src/backend/__tests__/LogList.test.tsx +163 -0
  114. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  115. package/src/backend/detail/InlineEditors.tsx +78 -25
  116. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  117. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  118. package/src/backend/inputs/DatePicker.tsx +28 -155
  119. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  120. package/src/backend/inputs/TagsInput.tsx +10 -13
  121. package/src/backend/inputs/TimeInput.tsx +2 -1
  122. package/src/backend/inputs/TimePicker.tsx +99 -70
  123. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  124. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  125. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  126. package/src/backend/utils/customFieldForms.ts +2 -2
  127. package/src/portal/components/PortalEmptyState.tsx +27 -22
  128. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  129. package/src/primitives/__tests__/alert.test.tsx +204 -0
  130. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  131. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  132. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  133. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  134. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  135. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  136. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  137. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  138. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  139. package/src/primitives/__tests__/notification.test.tsx +119 -0
  140. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  141. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  142. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  143. package/src/primitives/__tests__/tag.test.tsx +123 -0
  144. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  145. package/src/primitives/accordion.tsx +225 -0
  146. package/src/primitives/alert.tsx +234 -24
  147. package/src/primitives/calendar.tsx +146 -38
  148. package/src/primitives/compact-select.tsx +59 -0
  149. package/src/primitives/counter-input.tsx +211 -0
  150. package/src/primitives/date-picker-helpers.ts +96 -0
  151. package/src/primitives/date-picker.tsx +290 -0
  152. package/src/primitives/date-range-picker.tsx +281 -0
  153. package/src/primitives/digit-input.tsx +208 -0
  154. package/src/primitives/empty-state.tsx +140 -0
  155. package/src/primitives/inline-input.tsx +55 -0
  156. package/src/primitives/inline-select.tsx +74 -0
  157. package/src/primitives/notification-stack.tsx +185 -0
  158. package/src/primitives/notification.tsx +136 -0
  159. package/src/primitives/rich-editor.tsx +1578 -0
  160. package/src/primitives/select.tsx +1 -0
  161. package/src/primitives/skeleton.tsx +70 -0
  162. package/src/primitives/tag-input.tsx +223 -0
  163. package/src/primitives/tag.tsx +69 -10
  164. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { ModelPicker } from '../ModelPicker'
8
+ import type { ModelPickerProvider, ModelPickerValue } from '../ModelPicker'
9
+
10
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
11
+ useT: () => (key: string, fallback: string) => fallback,
12
+ }))
13
+
14
+ jest.mock('@open-mercato/shared/lib/utils', () => ({
15
+ cn: (...args: string[]) => args.filter(Boolean).join(' '),
16
+ }))
17
+
18
+ const providers: ModelPickerProvider[] = [
19
+ {
20
+ id: 'anthropic',
21
+ name: 'Anthropic',
22
+ isDefault: true,
23
+ models: [
24
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', isDefault: true },
25
+ { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', isDefault: false },
26
+ ],
27
+ },
28
+ {
29
+ id: 'openai',
30
+ name: 'OpenAI',
31
+ isDefault: false,
32
+ models: [{ id: 'gpt-4o', name: 'GPT-4o', isDefault: false }],
33
+ },
34
+ ]
35
+
36
+ function ControlledPicker({
37
+ initial = null,
38
+ onChangeSpy,
39
+ }: {
40
+ initial?: ModelPickerValue | null
41
+ onChangeSpy?: jest.Mock
42
+ }) {
43
+ const [value, setValue] = React.useState<ModelPickerValue | null>(initial)
44
+ const handleChange = React.useCallback(
45
+ (next: ModelPickerValue | null) => {
46
+ setValue(next)
47
+ onChangeSpy?.(next)
48
+ },
49
+ [onChangeSpy],
50
+ )
51
+ return (
52
+ <ModelPicker
53
+ agentId="catalog.merchandising_assistant"
54
+ value={value}
55
+ onChange={handleChange}
56
+ availableProviders={providers}
57
+ />
58
+ )
59
+ }
60
+
61
+ describe('<ModelPicker>', () => {
62
+ it('renders the trigger button', () => {
63
+ render(<ControlledPicker />)
64
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toBeInTheDocument()
65
+ })
66
+
67
+ it('shows the effective default model name when no runtime override is selected', () => {
68
+ render(
69
+ <ModelPicker
70
+ agentId="catalog.merchandising_assistant"
71
+ value={null}
72
+ onChange={jest.fn()}
73
+ availableProviders={providers}
74
+ defaultLabel="OpenAI / GPT-5 Mini"
75
+ />,
76
+ )
77
+
78
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveTextContent(
79
+ 'Default: OpenAI / GPT-5 Mini',
80
+ )
81
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
82
+ expect(screen.getByText('Use agent default: OpenAI / GPT-5 Mini')).toBeInTheDocument()
83
+ })
84
+
85
+ it('does not render when availableProviders is empty', () => {
86
+ const { container } = render(
87
+ <ModelPicker
88
+ agentId="catalog.merchandising_assistant"
89
+ value={null}
90
+ onChange={jest.fn()}
91
+ availableProviders={[]}
92
+ />,
93
+ )
94
+ expect(container.firstChild).toBeNull()
95
+ })
96
+
97
+ it('opens the dropdown on trigger click', () => {
98
+ render(<ControlledPicker />)
99
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
100
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
101
+ })
102
+
103
+ it('closes the dropdown on second trigger click', () => {
104
+ render(<ControlledPicker />)
105
+ const trigger = screen.getByRole('button', { name: 'Select AI model' })
106
+ fireEvent.click(trigger)
107
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
108
+ fireEvent.click(trigger)
109
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
110
+ })
111
+
112
+ it('closes the dropdown when Escape is pressed', () => {
113
+ render(<ControlledPicker />)
114
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
115
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
116
+ fireEvent.keyDown(document, { key: 'Escape' })
117
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
118
+ })
119
+
120
+ it('closes the dropdown when clicking outside', () => {
121
+ render(
122
+ <div>
123
+ <ControlledPicker />
124
+ <button type="button">Outside</button>
125
+ </div>,
126
+ )
127
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
128
+ expect(screen.getByRole('listbox')).toBeInTheDocument()
129
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }))
130
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
131
+ })
132
+
133
+ it('lists all providers and their models', () => {
134
+ render(<ControlledPicker />)
135
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
136
+
137
+ expect(screen.getByText('Anthropic')).toBeInTheDocument()
138
+ expect(screen.getByText('Claude Haiku 4.5')).toBeInTheDocument()
139
+ expect(screen.getByText('Claude Sonnet 4.5')).toBeInTheDocument()
140
+ expect(screen.getByText('OpenAI')).toBeInTheDocument()
141
+ expect(screen.getByText('GPT-4o')).toBeInTheDocument()
142
+ })
143
+
144
+ it('calls onChange with the selected model and closes the dropdown', () => {
145
+ const onChangeSpy = jest.fn()
146
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
147
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
148
+
149
+ fireEvent.click(screen.getByText('GPT-4o'))
150
+
151
+ expect(onChangeSpy).toHaveBeenCalledWith({ providerId: 'openai', modelId: 'gpt-4o' })
152
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
153
+ })
154
+
155
+ it('calls onChange with null when "Use agent default" is clicked', () => {
156
+ const onChangeSpy = jest.fn()
157
+ const initial: ModelPickerValue = { providerId: 'openai', modelId: 'gpt-4o' }
158
+ render(<ControlledPicker initial={initial} onChangeSpy={onChangeSpy} />)
159
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
160
+
161
+ fireEvent.click(screen.getByText('Use agent default'))
162
+
163
+ expect(onChangeSpy).toHaveBeenCalledWith(null)
164
+ })
165
+
166
+ it('selects a model via Enter key', () => {
167
+ const onChangeSpy = jest.fn()
168
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
169
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
170
+
171
+ const gpt4oOption = screen.getByText('GPT-4o').closest('[role="option"]') as HTMLElement
172
+ fireEvent.keyDown(gpt4oOption, { key: 'Enter' })
173
+
174
+ expect(onChangeSpy).toHaveBeenCalledWith({ providerId: 'openai', modelId: 'gpt-4o' })
175
+ })
176
+
177
+ it('selects a model via Space key', () => {
178
+ const onChangeSpy = jest.fn()
179
+ render(<ControlledPicker onChangeSpy={onChangeSpy} />)
180
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
181
+
182
+ const claudeHaikuOption = screen.getByText('Claude Haiku 4.5').closest(
183
+ '[role="option"]',
184
+ ) as HTMLElement
185
+ fireEvent.keyDown(claudeHaikuOption, { key: ' ' })
186
+
187
+ expect(onChangeSpy).toHaveBeenCalledWith({
188
+ providerId: 'anthropic',
189
+ modelId: 'claude-haiku-4-5',
190
+ })
191
+ })
192
+
193
+ it('marks the currently selected model with aria-selected', () => {
194
+ const initial: ModelPickerValue = { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }
195
+ render(<ControlledPicker initial={initial} />)
196
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
197
+
198
+ const selectedOption = screen
199
+ .getByText('Claude Sonnet 4.5')
200
+ .closest('[role="option"]') as HTMLElement
201
+ expect(selectedOption).toHaveAttribute('aria-selected', 'true')
202
+
203
+ const unselectedOption = screen
204
+ .getByText('Claude Haiku 4.5')
205
+ .closest('[role="option"]') as HTMLElement
206
+ expect(unselectedOption).toHaveAttribute('aria-selected', 'false')
207
+ })
208
+
209
+ it('marks the "Use default" option as selected when value is null', () => {
210
+ render(<ControlledPicker initial={null} />)
211
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
212
+
213
+ const defaultOption = screen
214
+ .getByText('Use agent default')
215
+ .closest('[role="option"]') as HTMLElement
216
+ expect(defaultOption).toHaveAttribute('aria-selected', 'true')
217
+ })
218
+
219
+ it('does not open when disabled', () => {
220
+ render(
221
+ <ModelPicker
222
+ agentId="test.agent"
223
+ value={null}
224
+ onChange={jest.fn()}
225
+ availableProviders={providers}
226
+ disabled
227
+ />,
228
+ )
229
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
230
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
231
+ })
232
+
233
+ it('attaches data-ai-model-picker attribute with the agentId', () => {
234
+ const { container } = render(<ControlledPicker />)
235
+ const root = container.querySelector(
236
+ '[data-ai-model-picker="catalog.merchandising_assistant"]',
237
+ )
238
+ expect(root).toBeInTheDocument()
239
+ })
240
+
241
+ it('attaches data-ai-model-picker-model attributes', () => {
242
+ render(<ControlledPicker />)
243
+ fireEvent.click(screen.getByRole('button', { name: 'Select AI model' }))
244
+
245
+ expect(
246
+ document.querySelector('[data-ai-model-picker-model="anthropic:claude-haiku-4-5"]'),
247
+ ).toBeInTheDocument()
248
+ expect(
249
+ document.querySelector('[data-ai-model-picker-model="openai:gpt-4o"]'),
250
+ ).toBeInTheDocument()
251
+ })
252
+ })
package/src/ai/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AiChat, type AiChatProps, type AiChatDebugTool, type AiChatDebugPromptSection } from './AiChat'
2
2
  export {
3
3
  AiAssistantLauncher,
4
+ AI_ASSISTANT_LAUNCHER_OPEN_EVENT,
4
5
  type AiAssistantLauncherAgent,
5
6
  type AiAssistantLauncherProps,
6
7
  } from './AiAssistantLauncher'
@@ -70,6 +70,18 @@ export interface UseAiChatInput {
70
70
  * props at any time to reset the conversation.
71
71
  */
72
72
  conversationId?: string
73
+ /**
74
+ * Runtime provider override (4b.2). Forwarded as `?provider=` query param
75
+ * on every POST to the dispatcher. Undefined/null means use the agent's
76
+ * configured default (no override sent).
77
+ */
78
+ providerOverride?: string | null
79
+ /**
80
+ * Runtime model override (4b.2). Forwarded as `?model=` query param
81
+ * on every POST to the dispatcher. Undefined/null means use the agent's
82
+ * configured default (no override sent).
83
+ */
84
+ modelOverride?: string | null
73
85
  }
74
86
 
75
87
  export interface AiChatErrorEnvelope {
@@ -238,7 +250,12 @@ function clearPersistedSession(agent: string, conversationId?: string | null): v
238
250
  }
239
251
  }
240
252
 
241
- function getTransportEndpoint(agent: string, apiPath?: string): string {
253
+ function getTransportEndpoint(
254
+ agent: string,
255
+ apiPath?: string,
256
+ providerOverride?: string | null,
257
+ modelOverride?: string | null,
258
+ ): string {
242
259
  // Reuse the transport factory so UI consumers share the dispatcher URL
243
260
  // convention with server-side callers (e.g. runAiAgentText / Playwright
244
261
  // fixtures). The factory returns a ChatTransport<UI_MESSAGE> whose internal
@@ -252,7 +269,14 @@ function getTransportEndpoint(agent: string, apiPath?: string): string {
252
269
  void transport
253
270
  const base = apiPath && apiPath.length > 0 ? apiPath : '/api/ai_assistant/ai/chat'
254
271
  const separator = base.includes('?') ? '&' : '?'
255
- return `${base}${separator}agent=${encodeURIComponent(agent)}`
272
+ let url = `${base}${separator}agent=${encodeURIComponent(agent)}`
273
+ if (providerOverride) {
274
+ url += `&provider=${encodeURIComponent(providerOverride)}`
275
+ }
276
+ if (modelOverride) {
277
+ url += `&model=${encodeURIComponent(modelOverride)}`
278
+ }
279
+ return url
256
280
  }
257
281
 
258
282
  interface AssistantBuilderState {
@@ -549,7 +573,7 @@ async function readErrorEnvelope(response: Response): Promise<AiChatErrorEnvelop
549
573
  }
550
574
 
551
575
  export function useAiChat(input: UseAiChatInput): UseAiChatResult {
552
- const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput } = input
576
+ const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride } = input
553
577
 
554
578
  // Minted once on mount when the caller does not supply a conversationId.
555
579
  // The ref keeps the id stable across re-renders and is reused for every
@@ -763,7 +787,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
763
787
  const controller = new AbortController()
764
788
  abortRef.current = controller
765
789
 
766
- const url = getTransportEndpoint(agent, apiPath)
790
+ const url = getTransportEndpoint(agent, apiPath, providerOverride, modelOverride)
767
791
  const body = {
768
792
  messages: outgoingHistory.map((message) => ({
769
793
  role: message.role,
@@ -966,18 +990,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
966
990
  setStatus('idle')
967
991
  }
968
992
  },
969
- [
970
- agent,
971
- apiPath,
972
- attachmentIds,
973
- debug,
974
- effectiveConversationId,
975
- emitError,
976
- pageContext,
977
- sessionStorageKey,
978
- setStatus,
979
- updateMessages,
980
- ],
993
+ [agent, apiPath, attachmentIds, debug, effectiveConversationId, emitError, messages, modelOverride, pageContext, providerOverride],
981
994
  )
982
995
 
983
996
  React.useEffect(() => {
@@ -101,7 +101,7 @@ import { InjectedField } from './injection/InjectedField'
101
101
  import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
102
102
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
103
103
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
104
- import { sanitizeHtmlRichText, sanitizeRichTextHref, sanitizeRichTextPasteContent } from './utils/richTextSanitizer'
104
+ import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
105
105
 
106
106
  // Stable empty options array to avoid creating a new [] every render
107
107
  const EMPTY_OPTIONS: CrudFieldOption[] = []
@@ -216,6 +216,13 @@ export type CrudBuiltinField = CrudFieldBase & {
216
216
  suggestions?: string[]
217
217
  // for combobox fields; allow custom values or restrict to suggestions only
218
218
  allowCustomValues?: boolean
219
+ // for combobox fields; hydrate the option map up front so a pre-selected value
220
+ // (typically an FK whose row is not on the first page of `loadOptions`) renders
221
+ // its label without requiring the user to focus the field
222
+ seedOptions?: CrudFieldOption[]
223
+ // for combobox fields; resolve a pre-selected value to its display label when it
224
+ // is not covered by `options`/`seedOptions`/`loadOptions` results (may be async)
225
+ resolveLabel?: (value: string) => string | Promise<string>
219
226
  // for text/textarea fields; HTML maxLength + (textarea only) char counter when showCount=true
220
227
  maxLength?: number
221
228
  // for textarea fields; show character counter (requires maxLength)
@@ -3620,106 +3627,33 @@ const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange
3620
3627
  )
3621
3628
  }, (prev, next) => prev.value === next.value)
3622
3629
 
3623
- // HTML Rich Text editor (contentEditable) with shortcuts; returns HTML string
3624
- type HtmlRTProps = { value?: string; onChange: (html: string) => void }
3625
- const HtmlRichTextEditor = React.memo(function HtmlRichTextEditor({ value = '', onChange }: HtmlRTProps) {
3630
+ // HTML Rich Text editor wrapper for the CrudForm builtin `editor: 'html'`.
3631
+ // Maps the existing `ui.forms.richtext.*` i18n keys onto the `RichEditor`
3632
+ // `labels` contract and pins the `standard` toolbar variant — heading
3633
+ // dropdown + B/I/U + lists + link, matching the legacy behaviour plus
3634
+ // the Figma `164611:20259` heading selector.
3635
+ type HtmlRichEditorFieldProps = { value?: string; onChange: (html: string) => void }
3636
+ const HtmlRichEditorField = React.memo(function HtmlRichEditorField({ value = '', onChange }: HtmlRichEditorFieldProps) {
3626
3637
  const t = useT()
3627
- const boldLabel = t('ui.forms.richtext.bold')
3628
- const italicLabel = t('ui.forms.richtext.italic')
3629
- const underlineLabel = t('ui.forms.richtext.underline')
3630
- const listLabel = t('ui.forms.richtext.list')
3631
- const heading3Label = t('ui.forms.richtext.heading3')
3632
- const linkLabel = t('ui.forms.richtext.link')
3633
- const linkUrlPrompt = t('ui.forms.richtext.linkUrlPrompt')
3634
- const ref = React.useRef<HTMLDivElement | null>(null)
3635
- const applyingExternal = React.useRef(false)
3636
- const typingRef = React.useRef(false)
3637
-
3638
- React.useEffect(() => {
3639
- const el = ref.current
3640
- if (!el) return
3641
- const current = el.innerHTML
3642
- const sanitizedValue = sanitizeHtmlRichText(value)
3643
- if (!typingRef.current && current !== sanitizedValue) {
3644
- applyingExternal.current = true
3645
- el.innerHTML = sanitizedValue
3646
- requestAnimationFrame(() => { applyingExternal.current = false })
3647
- }
3648
- }, [value])
3649
-
3650
- const exec = (cmd: string, arg?: string) => {
3651
- const el = ref.current
3652
- if (!el) return
3653
- el.focus()
3654
- try {
3655
- document.execCommand(cmd, false, arg)
3656
- } catch {
3657
- // ignore execCommand failures
3658
- }
3659
- }
3660
-
3661
- const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
3662
- const isMod = e.metaKey || e.ctrlKey
3663
- if (!isMod) return
3664
- const k = e.key.toLowerCase()
3665
- if (k === 'b') { e.preventDefault(); exec('bold') }
3666
- if (k === 'i') { e.preventDefault(); exec('italic') }
3667
- if (k === 'u') { e.preventDefault(); exec('underline') }
3668
- }
3669
-
3670
- const onPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
3671
- const html = e.clipboardData.getData('text/html')
3672
- const text = e.clipboardData.getData('text/plain')
3673
- const sanitizedPaste = sanitizeRichTextPasteContent(html, text)
3674
- if (!sanitizedPaste) return
3675
-
3676
- e.preventDefault()
3677
- exec(sanitizedPaste.command, sanitizedPaste.value)
3678
- }
3679
-
3680
- return (
3681
- <div className="w-full rounded border">
3682
- <div className="flex items-center gap-1 px-2 py-1 border-b">
3683
- <Button variant="ghost" size="sm" className="h-auto px-2 py-0.5 text-xs" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>{boldLabel}</Button>
3684
- <Button variant="ghost" size="sm" className="h-auto px-2 py-0.5 text-xs" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>{italicLabel}</Button>
3685
- <Button variant="ghost" size="sm" className="h-auto px-2 py-0.5 text-xs" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>{underlineLabel}</Button>
3686
- <span className="mx-2 text-muted-foreground">|</span>
3687
- <Button variant="ghost" size="sm" className="h-auto px-2 py-0.5 text-xs" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>• {listLabel}</Button>
3688
- <Button variant="ghost" size="sm" className="h-auto px-2 py-0.5 text-xs" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', '<h3>')}>{heading3Label}</Button>
3689
- <Button
3690
- variant="ghost"
3691
- size="sm"
3692
- className="h-auto px-2 py-0.5 text-xs"
3693
- onMouseDown={(e) => e.preventDefault()}
3694
- onClick={() => {
3695
- const url = sanitizeRichTextHref(window.prompt(linkUrlPrompt))
3696
- if (url) exec('createLink', url)
3697
- }}
3698
- >{linkLabel}</Button>
3699
- </div>
3700
- <div
3701
- ref={ref}
3702
- className="w-full px-2 py-2 min-h-[100px] sm:min-h-[160px] focus-visible:outline-none prose prose-sm max-w-none"
3703
- contentEditable
3704
- suppressContentEditableWarning
3705
- onKeyDown={onKeyDown}
3706
- onPaste={onPaste}
3707
- onInput={() => { if (!applyingExternal.current) typingRef.current = true }}
3708
- onBlur={() => {
3709
- const el = ref.current
3710
- if (!el) return
3711
- typingRef.current = false
3712
- const sanitizedValue = sanitizeHtmlRichText(el.innerHTML)
3713
- if (el.innerHTML !== sanitizedValue) {
3714
- applyingExternal.current = true
3715
- el.innerHTML = sanitizedValue
3716
- requestAnimationFrame(() => { applyingExternal.current = false })
3717
- }
3718
- onChange(sanitizedValue)
3719
- }}
3720
- />
3721
- </div>
3722
- )
3638
+ const labels = React.useMemo<Partial<RichEditorLabels>>(() => ({
3639
+ bold: t('ui.forms.richtext.bold'),
3640
+ italic: t('ui.forms.richtext.italic'),
3641
+ underline: t('ui.forms.richtext.underline'),
3642
+ unorderedList: t('ui.forms.richtext.list'),
3643
+ orderedList: t('ui.forms.richtext.orderedList', 'Numbered list'),
3644
+ heading: t('ui.forms.richtext.heading', 'Heading'),
3645
+ heading1: t('ui.forms.richtext.heading1', 'Heading 1'),
3646
+ heading2: t('ui.forms.richtext.heading2', 'Heading 2'),
3647
+ heading3: t('ui.forms.richtext.heading3'),
3648
+ paragraph: t('ui.forms.richtext.paragraph', 'Paragraph'),
3649
+ link: t('ui.forms.richtext.link'),
3650
+ linkUrlPrompt: t('ui.forms.richtext.linkUrlPrompt'),
3651
+ placeholder: t('ui.forms.richtext.placeholder'),
3652
+ comment: t('ui.forms.richtext.comment', 'Add comment'),
3653
+ mention: t('ui.forms.richtext.mention', 'Mention'),
3654
+ more: t('ui.forms.richtext.more', 'More'),
3655
+ }), [t])
3656
+ return <RichEditor value={value} onChange={onChange} variant="full" labels={labels} />
3723
3657
  }, (prev, next) => prev.value === next.value)
3724
3658
 
3725
3659
  // Very simple markdown editor with Bold/Italic/Underline + shortcuts.
@@ -3970,23 +3904,31 @@ const FieldControl = React.memo(function FieldControlImpl({
3970
3904
  />
3971
3905
  )}
3972
3906
  {field.type === 'date' && (
3973
- <Input
3974
- type="date"
3975
- value={typeof value === 'string' ? value : ''}
3976
- onChange={(e) => setValue(field.id, e.target.value || undefined)}
3977
- autoFocus={autoFocusField}
3978
- data-crud-focus-target=""
3907
+ <DatePicker
3908
+ value={typeof value === 'string' && value ? parseISO(value) : value instanceof Date ? value : null}
3909
+ onChange={(date) => setValue(field.id, date ? format(date, 'yyyy-MM-dd') : undefined)}
3979
3910
  disabled={disabled}
3911
+ readOnly={readOnly}
3912
+ placeholder={placeholder}
3913
+ minDate={builtin?.minDate}
3914
+ maxDate={builtin?.maxDate}
3915
+ displayFormat={builtin?.displayFormat}
3916
+ closeOnSelect={builtin?.closeOnSelect}
3917
+ locale={builtin?.locale}
3980
3918
  />
3981
3919
  )}
3982
3920
  {field.type === 'datetime-local' && (
3983
- <Input
3984
- type="datetime-local"
3985
- value={typeof value === 'string' ? value : ''}
3986
- onChange={(e) => setValue(field.id, e.target.value || undefined)}
3987
- autoFocus={autoFocusField}
3988
- data-crud-focus-target=""
3921
+ <DateTimePicker
3922
+ value={typeof value === 'string' && value ? new Date(value) : value instanceof Date ? value : null}
3923
+ onChange={(date) => setValue(field.id, date ? format(date, "yyyy-MM-dd'T'HH:mm") : undefined)}
3989
3924
  disabled={disabled}
3925
+ readOnly={readOnly}
3926
+ placeholder={placeholder}
3927
+ minuteStep={builtin?.minuteStep}
3928
+ minDate={builtin?.minDate}
3929
+ maxDate={builtin?.maxDate}
3930
+ displayFormat={builtin?.displayFormat}
3931
+ locale={builtin?.locale}
3990
3932
  />
3991
3933
  )}
3992
3934
  {field.type === 'datepicker' && (
@@ -4043,7 +3985,7 @@ const FieldControl = React.memo(function FieldControlImpl({
4043
3985
  <SimpleMarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
4044
3986
  )}
4045
3987
  {field.type === 'richtext' && builtin?.editor === 'html' && (
4046
- <HtmlRichTextEditor value={String(value ?? '')} onChange={fieldSetValue} />
3988
+ <HtmlRichEditorField value={String(value ?? '')} onChange={fieldSetValue} />
4047
3989
  )}
4048
3990
  {field.type === 'richtext' && (!builtin?.editor || (builtin.editor !== 'simple' && builtin.editor !== 'html')) && (
4049
3991
  <MarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
@@ -4077,6 +4019,12 @@ const FieldControl = React.memo(function FieldControlImpl({
4077
4019
  ? builtin.suggestions
4078
4020
  : options.map((opt) => ({ value: opt.value, label: opt.label }))
4079
4021
  }
4022
+ seedOptions={
4023
+ builtin?.seedOptions
4024
+ ? builtin.seedOptions.map((opt) => ({ value: opt.value, label: opt.label }))
4025
+ : undefined
4026
+ }
4027
+ resolveLabel={builtin?.resolveLabel}
4080
4028
  loadSuggestions={
4081
4029
  typeof builtin?.loadOptions === 'function'
4082
4030
  ? async (query?: string) => {
@@ -14,7 +14,9 @@ import {
14
14
  SelectTrigger,
15
15
  SelectValue,
16
16
  } from '../primitives/select'
17
+ import { CompactSelectTrigger } from '../primitives/compact-select'
17
18
  import { Spinner } from '../primitives/spinner'
19
+ import { EmptyState } from '../primitives/empty-state'
18
20
  import { TooltipProvider } from '../primitives/tooltip'
19
21
  import { TruncatedCell } from './TruncatedCell'
20
22
  import { FilterBar, type FilterDef, type FilterValues } from './FilterBar'
@@ -1787,13 +1789,12 @@ export function DataTable<T>({
1787
1789
  scrollTableIntoView()
1788
1790
  }}
1789
1791
  >
1790
- <SelectTrigger
1791
- size="sm"
1792
+ <CompactSelectTrigger
1792
1793
  className="min-w-[4rem]"
1793
1794
  aria-label={t('ui.dataTable.pagination.rowsPerPage', 'Rows per page')}
1794
1795
  >
1795
1796
  <SelectValue />
1796
- </SelectTrigger>
1797
+ </CompactSelectTrigger>
1797
1798
  <SelectContent>
1798
1799
  {pageSizeOptions.map((size) => (
1799
1800
  <SelectItem key={size} value={String(size)}>{size}</SelectItem>
@@ -2612,8 +2613,19 @@ export function DataTable<T>({
2612
2613
  </>
2613
2614
  ) : (
2614
2615
  <TableRow>
2615
- <TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="h-24 text-center text-muted-foreground">
2616
- {emptyState ?? t('ui.dataTable.emptyState.default', 'No results.')}
2616
+ <TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="py-6">
2617
+ {emptyState && typeof emptyState !== 'string' ? (
2618
+ emptyState
2619
+ ) : (
2620
+ <EmptyState
2621
+ size="sm"
2622
+ title={
2623
+ typeof emptyState === 'string'
2624
+ ? emptyState
2625
+ : t('ui.dataTable.emptyState.default', 'No results.')
2626
+ }
2627
+ />
2628
+ )}
2617
2629
  </TableCell>
2618
2630
  </TableRow>
2619
2631
  )}