@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.
- package/.turbo/turbo-build.log +1 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- 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'
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(() => {
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
3624
|
-
|
|
3625
|
-
|
|
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
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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
|
-
<
|
|
3974
|
-
|
|
3975
|
-
|
|
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
|
-
<
|
|
3984
|
-
|
|
3985
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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="
|
|
2616
|
-
{emptyState
|
|
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
|
)}
|