@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,248 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ChevronDown, Cpu } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { Button } from '../primitives/button'
|
|
8
|
+
|
|
9
|
+
export interface ModelPickerProviderModel {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
contextWindow?: number | null
|
|
13
|
+
tags?: string[]
|
|
14
|
+
isDefault: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModelPickerProvider {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
isDefault: boolean
|
|
21
|
+
models: ModelPickerProviderModel[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ModelPickerValue {
|
|
25
|
+
providerId: string
|
|
26
|
+
modelId: string
|
|
27
|
+
baseURL?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ModelPickerProps {
|
|
31
|
+
agentId: string
|
|
32
|
+
value: ModelPickerValue | null
|
|
33
|
+
onChange: (value: ModelPickerValue | null) => void
|
|
34
|
+
availableProviders: ModelPickerProvider[]
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
compact?: boolean
|
|
37
|
+
defaultLabel?: string | null
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stateless dropdown for selecting a provider + model override in the chat UI.
|
|
43
|
+
*
|
|
44
|
+
* - Renders provider name + model id + "(default)" badge for the agent's resolved default.
|
|
45
|
+
* - Keyboard-accessible: trigger opens a floating list, arrow keys navigate, Enter selects,
|
|
46
|
+
* Escape closes.
|
|
47
|
+
* - Does NOT expose a baseURL input (R6 mitigation: only provider+model selection from the
|
|
48
|
+
* curated catalog is exposed to end users).
|
|
49
|
+
* - localStorage persistence keyed by agentId is handled by the consumer (AiChat / 4b.2).
|
|
50
|
+
* - When allowRuntimeModelOverride is false the consumer should not render this component
|
|
51
|
+
* at all; this component itself does no gating.
|
|
52
|
+
*/
|
|
53
|
+
export function ModelPicker({
|
|
54
|
+
agentId,
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
availableProviders,
|
|
58
|
+
disabled,
|
|
59
|
+
compact = false,
|
|
60
|
+
defaultLabel,
|
|
61
|
+
className,
|
|
62
|
+
}: ModelPickerProps) {
|
|
63
|
+
const t = useT()
|
|
64
|
+
const [open, setOpen] = React.useState(false)
|
|
65
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
66
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
67
|
+
|
|
68
|
+
const selectedLabel = React.useMemo(() => {
|
|
69
|
+
if (!value) {
|
|
70
|
+
return defaultLabel
|
|
71
|
+
? t('ai_assistant.modelPicker.defaultWithModelLabel', 'Default: {{model}}').replace('{{model}}', defaultLabel)
|
|
72
|
+
: t('ai_assistant.modelPicker.defaultLabel', 'Model: Default')
|
|
73
|
+
}
|
|
74
|
+
const provider = availableProviders.find((p) => p.id === value.providerId)
|
|
75
|
+
const model = provider?.models.find((m) => m.id === value.modelId)
|
|
76
|
+
const modelLabel = model?.name ?? value.modelId
|
|
77
|
+
const providerLabel = provider?.name ?? value.providerId
|
|
78
|
+
return `${providerLabel} / ${modelLabel}`
|
|
79
|
+
}, [value, availableProviders, defaultLabel, t])
|
|
80
|
+
|
|
81
|
+
const handleToggle = React.useCallback(() => {
|
|
82
|
+
if (disabled) return
|
|
83
|
+
setOpen((prev) => !prev)
|
|
84
|
+
}, [disabled])
|
|
85
|
+
|
|
86
|
+
const handleSelect = React.useCallback(
|
|
87
|
+
(providerId: string, modelId: string) => {
|
|
88
|
+
onChange({ providerId, modelId })
|
|
89
|
+
setOpen(false)
|
|
90
|
+
triggerRef.current?.focus()
|
|
91
|
+
},
|
|
92
|
+
[onChange],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const handleClearDefault = React.useCallback(() => {
|
|
96
|
+
onChange(null)
|
|
97
|
+
setOpen(false)
|
|
98
|
+
triggerRef.current?.focus()
|
|
99
|
+
}, [onChange])
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (!open) return
|
|
103
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
104
|
+
if (event.key === 'Escape') {
|
|
105
|
+
setOpen(false)
|
|
106
|
+
triggerRef.current?.focus()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
110
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
111
|
+
}, [open])
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
if (!open) return
|
|
115
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
116
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
117
|
+
setOpen(false)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
121
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
122
|
+
}, [open])
|
|
123
|
+
|
|
124
|
+
if (availableProviders.length === 0) return null
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div ref={containerRef} className={cn('relative', className)} data-ai-model-picker={agentId}>
|
|
128
|
+
<Button
|
|
129
|
+
ref={triggerRef}
|
|
130
|
+
type="button"
|
|
131
|
+
variant="outline"
|
|
132
|
+
size="sm"
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
onClick={handleToggle}
|
|
135
|
+
aria-haspopup="listbox"
|
|
136
|
+
aria-expanded={open}
|
|
137
|
+
aria-label={t('ai_assistant.modelPicker.triggerAriaLabel', 'Select AI model')}
|
|
138
|
+
title={selectedLabel}
|
|
139
|
+
data-ai-model-picker-trigger
|
|
140
|
+
className={cn(
|
|
141
|
+
'font-normal',
|
|
142
|
+
compact ? 'w-8 gap-0 px-0' : 'gap-1.5 px-2.5',
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<Cpu className="size-3.5 shrink-0" aria-hidden />
|
|
146
|
+
{!compact ? (
|
|
147
|
+
<span className="max-w-36 truncate text-xs">{selectedLabel}</span>
|
|
148
|
+
) : null}
|
|
149
|
+
<ChevronDown
|
|
150
|
+
className={cn(
|
|
151
|
+
'size-3 shrink-0 transition-transform',
|
|
152
|
+
compact && 'hidden',
|
|
153
|
+
open && 'rotate-180',
|
|
154
|
+
)}
|
|
155
|
+
aria-hidden
|
|
156
|
+
/>
|
|
157
|
+
</Button>
|
|
158
|
+
|
|
159
|
+
{open ? (
|
|
160
|
+
<div
|
|
161
|
+
role="listbox"
|
|
162
|
+
aria-label={t('ai_assistant.modelPicker.listAriaLabel', 'Available models')}
|
|
163
|
+
className={cn(
|
|
164
|
+
'absolute bottom-full left-0 z-50 mb-1 max-h-72 min-w-56 overflow-y-auto',
|
|
165
|
+
'rounded-md border border-border bg-popover shadow-md',
|
|
166
|
+
)}
|
|
167
|
+
data-ai-model-picker-dropdown
|
|
168
|
+
>
|
|
169
|
+
<div
|
|
170
|
+
role="option"
|
|
171
|
+
aria-selected={value === null}
|
|
172
|
+
tabIndex={0}
|
|
173
|
+
className={cn(
|
|
174
|
+
'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
|
|
175
|
+
'flex items-center gap-2',
|
|
176
|
+
value === null && 'bg-accent/50',
|
|
177
|
+
)}
|
|
178
|
+
onClick={handleClearDefault}
|
|
179
|
+
onKeyDown={(event) => {
|
|
180
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
handleClearDefault()
|
|
183
|
+
}
|
|
184
|
+
}}
|
|
185
|
+
data-ai-model-picker-default-option
|
|
186
|
+
>
|
|
187
|
+
<span className="flex-1">
|
|
188
|
+
{defaultLabel
|
|
189
|
+
? t('ai_assistant.modelPicker.useDefaultWithModel', 'Use agent default: {{model}}').replace('{{model}}', defaultLabel)
|
|
190
|
+
: t('ai_assistant.modelPicker.useDefault', 'Use agent default')}
|
|
191
|
+
</span>
|
|
192
|
+
{value === null ? (
|
|
193
|
+
<span className="rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
|
|
194
|
+
{t('ai_assistant.modelPicker.activeBadge', 'active')}
|
|
195
|
+
</span>
|
|
196
|
+
) : null}
|
|
197
|
+
</div>
|
|
198
|
+
{availableProviders.map((provider) => (
|
|
199
|
+
<div key={provider.id} data-ai-model-picker-provider={provider.id}>
|
|
200
|
+
<div className="px-3 py-1.5 text-overline font-semibold uppercase tracking-wider text-muted-foreground">
|
|
201
|
+
{provider.name}
|
|
202
|
+
</div>
|
|
203
|
+
{provider.models.map((model) => {
|
|
204
|
+
const isSelected =
|
|
205
|
+
value?.providerId === provider.id && value?.modelId === model.id
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
key={model.id}
|
|
209
|
+
role="option"
|
|
210
|
+
aria-selected={isSelected}
|
|
211
|
+
tabIndex={0}
|
|
212
|
+
className={cn(
|
|
213
|
+
'cursor-pointer px-3 py-2 text-xs hover:bg-accent hover:text-accent-foreground',
|
|
214
|
+
'flex items-center gap-2 pl-5',
|
|
215
|
+
isSelected && 'bg-accent/50',
|
|
216
|
+
)}
|
|
217
|
+
onClick={() => handleSelect(provider.id, model.id)}
|
|
218
|
+
onKeyDown={(event) => {
|
|
219
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
220
|
+
event.preventDefault()
|
|
221
|
+
handleSelect(provider.id, model.id)
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
data-ai-model-picker-model={`${provider.id}:${model.id}`}
|
|
225
|
+
>
|
|
226
|
+
<span className="flex-1 truncate">{model.name}</span>
|
|
227
|
+
{model.isDefault ? (
|
|
228
|
+
<span className="shrink-0 rounded bg-muted px-1 py-0.5 text-overline text-muted-foreground">
|
|
229
|
+
{t('ai_assistant.modelPicker.defaultBadge', 'default')}
|
|
230
|
+
</span>
|
|
231
|
+
) : null}
|
|
232
|
+
{isSelected ? (
|
|
233
|
+
<span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-overline font-medium text-primary">
|
|
234
|
+
{t('ai_assistant.modelPicker.activeBadge', 'active')}
|
|
235
|
+
</span>
|
|
236
|
+
) : null}
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
})}
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default ModelPicker
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
9
|
+
import { AiAssistantLauncher, AI_ASSISTANT_LAUNCHER_OPEN_EVENT } from '../AiAssistantLauncher'
|
|
10
|
+
|
|
11
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
12
|
+
apiCall: jest.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
16
|
+
|
|
17
|
+
describe('<AiAssistantLauncher>', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
apiCallMock.mockReset()
|
|
20
|
+
apiCallMock.mockImplementation(async (url: string) => {
|
|
21
|
+
if (url === '/api/ai_assistant/health') {
|
|
22
|
+
return { ok: true, result: { healthy: true } }
|
|
23
|
+
}
|
|
24
|
+
if (url === '/api/ai_assistant/ai/agents') {
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
result: {
|
|
28
|
+
aiConfigured: true,
|
|
29
|
+
agents: [
|
|
30
|
+
{
|
|
31
|
+
id: 'catalog.catalog_assistant',
|
|
32
|
+
label: 'Catalog Assistant',
|
|
33
|
+
description: 'Explore catalog data',
|
|
34
|
+
mutationPolicy: 'read-only',
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Unexpected apiCall: ${url}`)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('opens the assistants picker when the global launcher event is dispatched', async () => {
|
|
45
|
+
renderWithProviders(<AiAssistantLauncher />)
|
|
46
|
+
|
|
47
|
+
await waitFor(() => {
|
|
48
|
+
expect(screen.getAllByRole('button', { name: 'Open AI assistant' }).length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
window.dispatchEvent(new CustomEvent(AI_ASSISTANT_LAUNCHER_OPEN_EVENT))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(await screen.findByRole('dialog', { name: 'AI assistants' })).toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('Catalog Assistant')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -158,3 +158,19 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
|
|
|
158
158
|
expect(parsedBody.conversationId).toBe('conv-body-xyz')
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
// <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
|
|
163
|
+
// on first render via apiCall. Stub apiCall with a no-providers response so this files
|
|
164
|
+
// chat-flow assertions remain scoped to the dispatcher mock above.
|
|
165
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
166
|
+
apiCall: jest.fn(async () => ({
|
|
167
|
+
ok: true,
|
|
168
|
+
status: 200,
|
|
169
|
+
result: {
|
|
170
|
+
allowRuntimeModelOverride: false,
|
|
171
|
+
defaultProviderId: 'openai',
|
|
172
|
+
defaultModelId: 'gpt-5-mini',
|
|
173
|
+
providers: [],
|
|
174
|
+
},
|
|
175
|
+
})),
|
|
176
|
+
}))
|
|
@@ -150,3 +150,19 @@ describe('<AiChat> debug panel (Step 4.6)', () => {
|
|
|
150
150
|
).toBeInTheDocument()
|
|
151
151
|
})
|
|
152
152
|
})
|
|
153
|
+
|
|
154
|
+
// <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
|
|
155
|
+
// on first render via apiCall. Stub apiCall with a no-providers response so this files
|
|
156
|
+
// chat-flow assertions remain scoped to the dispatcher mock above.
|
|
157
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
158
|
+
apiCall: jest.fn(async () => ({
|
|
159
|
+
ok: true,
|
|
160
|
+
status: 200,
|
|
161
|
+
result: {
|
|
162
|
+
allowRuntimeModelOverride: false,
|
|
163
|
+
defaultProviderId: 'openai',
|
|
164
|
+
defaultModelId: 'gpt-5-mini',
|
|
165
|
+
providers: [],
|
|
166
|
+
},
|
|
167
|
+
})),
|
|
168
|
+
}))
|
|
@@ -211,3 +211,19 @@ describe('<AiChat> × UI-part registry', () => {
|
|
|
211
211
|
expect(screen.getByTestId('global-card')).toBeInTheDocument()
|
|
212
212
|
})
|
|
213
213
|
})
|
|
214
|
+
|
|
215
|
+
// <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
|
|
216
|
+
// on first render via apiCall. Stub apiCall with a no-providers response so this files
|
|
217
|
+
// chat-flow assertions remain scoped to the dispatcher mock above.
|
|
218
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
219
|
+
apiCall: jest.fn(async () => ({
|
|
220
|
+
ok: true,
|
|
221
|
+
status: 200,
|
|
222
|
+
result: {
|
|
223
|
+
allowRuntimeModelOverride: false,
|
|
224
|
+
defaultProviderId: 'openai',
|
|
225
|
+
defaultModelId: 'gpt-5-mini',
|
|
226
|
+
providers: [],
|
|
227
|
+
},
|
|
228
|
+
})),
|
|
229
|
+
}))
|
|
@@ -36,9 +36,54 @@ jest.mock('../../backend/utils/api', () => ({
|
|
|
36
36
|
apiFetch: jest.fn(),
|
|
37
37
|
}))
|
|
38
38
|
|
|
39
|
+
// <AiChat> mounts `useAgentModels` (Phase 4b) which calls apiCall against
|
|
40
|
+
// /api/ai_assistant/ai/agents/<id>/models on first render. The chat-flow
|
|
41
|
+
// tests in this file don't exercise the picker, so stub apiCall with a
|
|
42
|
+
// no-providers response — keeps `apiFetch.mock.calls` scoped to the
|
|
43
|
+
// dispatcher and lets the existing `mockResolvedValueOnce` setup drive the
|
|
44
|
+
// assertion path without having to special-case the models endpoint.
|
|
45
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
46
|
+
apiCall: jest.fn(async () => ({
|
|
47
|
+
ok: true,
|
|
48
|
+
status: 200,
|
|
49
|
+
result: {
|
|
50
|
+
agentId: 'customers.account_assistant',
|
|
51
|
+
allowRuntimeModelOverride: false,
|
|
52
|
+
defaultProviderId: 'openai',
|
|
53
|
+
defaultModelId: 'gpt-5-mini',
|
|
54
|
+
providers: [],
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
}))
|
|
58
|
+
|
|
39
59
|
import { apiFetch } from '../../backend/utils/api'
|
|
60
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
40
61
|
import { AiChat } from '../AiChat'
|
|
41
62
|
|
|
63
|
+
let lastResizeObserver: MockResizeObserver | null = null
|
|
64
|
+
|
|
65
|
+
class MockResizeObserver {
|
|
66
|
+
observe = jest.fn()
|
|
67
|
+
disconnect = jest.fn()
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly callback: ResizeObserverCallback,
|
|
71
|
+
) {
|
|
72
|
+
lastResizeObserver = this
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
trigger(width: number) {
|
|
76
|
+
this.callback(
|
|
77
|
+
[
|
|
78
|
+
{
|
|
79
|
+
contentRect: { width },
|
|
80
|
+
} as ResizeObserverEntry,
|
|
81
|
+
],
|
|
82
|
+
this as unknown as ResizeObserver,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
const dict = {
|
|
43
88
|
'ai_assistant.chat.assistantRoleLabel': 'Assistant',
|
|
44
89
|
'ai_assistant.chat.cancel': 'Cancel streaming response',
|
|
@@ -108,6 +153,7 @@ describe('<AiChat>', () => {
|
|
|
108
153
|
beforeEach(() => {
|
|
109
154
|
jest.clearAllMocks()
|
|
110
155
|
window.localStorage.clear()
|
|
156
|
+
lastResizeObserver = null
|
|
111
157
|
})
|
|
112
158
|
|
|
113
159
|
it('renders the composer with the i18n placeholder and region labels', () => {
|
|
@@ -120,6 +166,140 @@ describe('<AiChat>', () => {
|
|
|
120
166
|
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled()
|
|
121
167
|
})
|
|
122
168
|
|
|
169
|
+
it('replaces a stale stored model picker value with the first available model', async () => {
|
|
170
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
171
|
+
apiCallMock.mockResolvedValueOnce({
|
|
172
|
+
ok: true,
|
|
173
|
+
status: 200,
|
|
174
|
+
result: {
|
|
175
|
+
agentId: 'customers.account_assistant',
|
|
176
|
+
allowRuntimeModelOverride: true,
|
|
177
|
+
defaultProviderId: 'openai',
|
|
178
|
+
defaultModelId: 'gpt-5-mini',
|
|
179
|
+
providers: [
|
|
180
|
+
{
|
|
181
|
+
id: 'openai',
|
|
182
|
+
name: 'OpenAI',
|
|
183
|
+
isDefault: true,
|
|
184
|
+
models: [
|
|
185
|
+
{
|
|
186
|
+
id: 'gpt-5-mini',
|
|
187
|
+
name: 'GPT-5 Mini',
|
|
188
|
+
isDefault: true,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
window.localStorage.setItem(
|
|
196
|
+
'om-ai-model-picker:customers.account_assistant',
|
|
197
|
+
JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' }),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
|
|
201
|
+
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveAttribute(
|
|
204
|
+
'title',
|
|
205
|
+
'OpenAI / GPT-5 Mini',
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBe(
|
|
209
|
+
JSON.stringify({ providerId: 'openai', modelId: 'gpt-5-mini' }),
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('renders a compact footer before a constrained host is measured', async () => {
|
|
214
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
215
|
+
apiCallMock.mockResolvedValueOnce({
|
|
216
|
+
ok: true,
|
|
217
|
+
status: 200,
|
|
218
|
+
result: {
|
|
219
|
+
agentId: 'customers.account_assistant',
|
|
220
|
+
allowRuntimeModelOverride: true,
|
|
221
|
+
defaultProviderId: 'openai',
|
|
222
|
+
defaultModelId: 'gpt-5-mini',
|
|
223
|
+
providers: [
|
|
224
|
+
{
|
|
225
|
+
id: 'openai',
|
|
226
|
+
name: 'OpenAI',
|
|
227
|
+
isDefault: true,
|
|
228
|
+
models: [
|
|
229
|
+
{
|
|
230
|
+
id: 'gpt-5-mini',
|
|
231
|
+
name: 'GPT-5 Mini',
|
|
232
|
+
isDefault: true,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" defaultCompactFooter />, { dict })
|
|
241
|
+
|
|
242
|
+
const footer = document.querySelector('[data-ai-chat-footer=""]')
|
|
243
|
+
expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'true')
|
|
244
|
+
expect(screen.getByText('Press Cmd/Ctrl+Enter to send, Escape to cancel.')).toHaveClass('hidden')
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(screen.getByRole('button', { name: 'Select AI model' })).toHaveClass('w-8')
|
|
248
|
+
})
|
|
249
|
+
expect(screen.getByRole('button', { name: 'Send message' })).toHaveClass('w-8')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('lets a default compact footer expand after resize measurement', async () => {
|
|
253
|
+
const apiCallMock = apiCall as unknown as jest.Mock
|
|
254
|
+
apiCallMock.mockResolvedValueOnce({
|
|
255
|
+
ok: true,
|
|
256
|
+
status: 200,
|
|
257
|
+
result: {
|
|
258
|
+
agentId: 'customers.account_assistant',
|
|
259
|
+
allowRuntimeModelOverride: true,
|
|
260
|
+
defaultProviderId: 'openai',
|
|
261
|
+
defaultModelId: 'gpt-5-mini',
|
|
262
|
+
providers: [
|
|
263
|
+
{
|
|
264
|
+
id: 'openai',
|
|
265
|
+
name: 'OpenAI',
|
|
266
|
+
isDefault: true,
|
|
267
|
+
models: [
|
|
268
|
+
{
|
|
269
|
+
id: 'gpt-5-mini',
|
|
270
|
+
name: 'GPT-5 Mini',
|
|
271
|
+
isDefault: true,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
const originalResizeObserver = globalThis.ResizeObserver
|
|
279
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
renderWithProviders(<AiChat agent="customers.account_assistant" defaultCompactFooter />, { dict })
|
|
283
|
+
|
|
284
|
+
const footer = document.querySelector('[data-ai-chat-footer=""]')
|
|
285
|
+
expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'true')
|
|
286
|
+
|
|
287
|
+
await act(async () => {
|
|
288
|
+
lastResizeObserver?.trigger(720)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await waitFor(() => {
|
|
292
|
+
expect(footer).toHaveAttribute('data-ai-chat-footer-compact', 'false')
|
|
293
|
+
})
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(screen.getByRole('button', { name: 'Select AI model' })).not.toHaveClass('w-8')
|
|
296
|
+
})
|
|
297
|
+
expect(screen.getByRole('button', { name: 'Send message' })).not.toHaveClass('w-8')
|
|
298
|
+
} finally {
|
|
299
|
+
globalThis.ResizeObserver = originalResizeObserver
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
123
303
|
it('submits the message on Cmd+Enter and streams assistant text into the transcript', async () => {
|
|
124
304
|
const fetchMock = apiFetch as unknown as jest.Mock
|
|
125
305
|
fetchMock.mockResolvedValueOnce(
|
|
@@ -293,3 +293,19 @@ describe('<AiChat> unmount-mid-stream persistence (issue #1816)', () => {
|
|
|
293
293
|
unmountB()
|
|
294
294
|
})
|
|
295
295
|
})
|
|
296
|
+
|
|
297
|
+
// <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models
|
|
298
|
+
// on first render via apiCall. Stub apiCall with a no-providers response so this files
|
|
299
|
+
// chat-flow assertions remain scoped to the dispatcher mock above.
|
|
300
|
+
jest.mock('../../backend/utils/apiCall', () => ({
|
|
301
|
+
apiCall: jest.fn(async () => ({
|
|
302
|
+
ok: true,
|
|
303
|
+
status: 200,
|
|
304
|
+
result: {
|
|
305
|
+
allowRuntimeModelOverride: false,
|
|
306
|
+
defaultProviderId: 'openai',
|
|
307
|
+
defaultModelId: 'gpt-5-mini',
|
|
308
|
+
providers: [],
|
|
309
|
+
},
|
|
310
|
+
})),
|
|
311
|
+
}))
|
|
@@ -11,10 +11,11 @@ import { AiDockProvider, useAiDock, type AiDockedAssistant } from '../AiDock'
|
|
|
11
11
|
jest.mock('../AiChat', () => {
|
|
12
12
|
const ReactModule = require('react') as typeof import('react')
|
|
13
13
|
return {
|
|
14
|
-
AiChat: ({ agent }: { agent: string }) =>
|
|
14
|
+
AiChat: ({ agent, defaultCompactFooter }: { agent: string; defaultCompactFooter?: boolean }) =>
|
|
15
15
|
ReactModule.createElement('div', {
|
|
16
16
|
'data-testid': 'ai-chat',
|
|
17
17
|
'data-agent': agent,
|
|
18
|
+
'data-default-compact-footer': defaultCompactFooter ? 'true' : 'false',
|
|
18
19
|
}),
|
|
19
20
|
}
|
|
20
21
|
})
|
|
@@ -121,4 +122,14 @@ describe('<AiDockProvider>', () => {
|
|
|
121
122
|
})
|
|
122
123
|
expect(document.querySelector('[data-ai-dock-panel=""]')).not.toBeInTheDocument()
|
|
123
124
|
})
|
|
125
|
+
|
|
126
|
+
it('starts the chat footer compact inside the dock', async () => {
|
|
127
|
+
renderWithProviders(<Harness />)
|
|
128
|
+
|
|
129
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(screen.getByTestId('ai-chat')).toHaveAttribute('data-default-compact-footer', 'true')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
124
135
|
})
|