enhance: tabs component in model selection (#3730)
* ui: tabs-model-selection * chore: updat tabs variant * test: update test and render correct tab
This commit is contained in:
parent
acd3be3a2a
commit
886b1cbc54
@ -96,4 +96,20 @@ describe('@joi/core/Tabs', () => {
|
|||||||
'Disabled tab'
|
'Disabled tab'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies the tabStyle if provided', () => {
|
||||||
|
render(
|
||||||
|
<Tabs
|
||||||
|
data-testid="segmented-style"
|
||||||
|
options={mockOptions}
|
||||||
|
value="tab1"
|
||||||
|
onValueChange={() => {}}
|
||||||
|
tabStyle="segmented"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabsContainer = screen.getByTestId('segmented-style')
|
||||||
|
expect(tabsContainer).toHaveClass('tabs')
|
||||||
|
expect(tabsContainer).toHaveClass('tabs--segmented')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { Tooltip } from '../Tooltip'
|
|||||||
import './styles.scss'
|
import './styles.scss'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
type TabStyles = 'segmented'
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
options: {
|
options: {
|
||||||
name: string
|
name: string
|
||||||
@ -14,8 +16,10 @@ type TabsProps = {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
tooltipContent?: string
|
tooltipContent?: string
|
||||||
}[]
|
}[]
|
||||||
children: ReactNode
|
children?: ReactNode
|
||||||
|
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
|
tabStyle?: TabStyles
|
||||||
value: string
|
value: string
|
||||||
onValueChange?: (value: string) => void
|
onValueChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
@ -40,15 +44,18 @@ const TabsContent = ({ value, children, className }: TabsContentProps) => {
|
|||||||
const Tabs = ({
|
const Tabs = ({
|
||||||
options,
|
options,
|
||||||
children,
|
children,
|
||||||
|
tabStyle,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
...props
|
||||||
}: TabsProps) => (
|
}: TabsProps) => (
|
||||||
<TabsPrimitive.Root
|
<TabsPrimitive.Root
|
||||||
className="tabs"
|
className={twMerge('tabs', tabStyle && `tabs--${tabStyle}`)}
|
||||||
value={value}
|
value={value}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<TabsPrimitive.List className="tabs__list">
|
<TabsPrimitive.List className="tabs__list">
|
||||||
{options.map((option, i) => {
|
{options.map((option, i) => {
|
||||||
|
|||||||
@ -3,6 +3,27 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&--segmented {
|
||||||
|
background-color: hsla(var(--secondary-bg));
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 33px;
|
||||||
|
|
||||||
|
.tabs__list {
|
||||||
|
border: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 33px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs__trigger[data-state='active'] {
|
||||||
|
background-color: hsla(var(--app-bg));
|
||||||
|
border: none;
|
||||||
|
height: 25px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -14,9 +35,11 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
color: hsla(var(--text-secondary));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: medium;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
&:focus {
|
&:focus {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -38,4 +61,5 @@
|
|||||||
.tabs__trigger[data-state='active'] {
|
.tabs__trigger[data-state='active'] {
|
||||||
border-bottom: 1px solid hsla(var(--primary-bg));
|
border-bottom: 1px solid hsla(var(--primary-bg));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: hsla(var(--text-primary));
|
||||||
}
|
}
|
||||||
|
|||||||
101
web/containers/ModelDropdown/index.test.tsx
Normal file
101
web/containers/ModelDropdown/index.test.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { useAtomValue, useAtom, useSetAtom } from 'jotai'
|
||||||
|
import ModelDropdown from './index'
|
||||||
|
import useRecommendedModel from '@/hooks/useRecommendedModel'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ResizeObserver = ResizeObserverMock
|
||||||
|
|
||||||
|
jest.mock('jotai', () => {
|
||||||
|
const originalModule = jest.requireActual('jotai')
|
||||||
|
return {
|
||||||
|
...originalModule,
|
||||||
|
useAtom: jest.fn(),
|
||||||
|
useAtomValue: jest.fn(),
|
||||||
|
useSetAtom: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.mock('@/containers/ModelLabel')
|
||||||
|
jest.mock('@/hooks/useRecommendedModel')
|
||||||
|
|
||||||
|
describe('ModelDropdown', () => {
|
||||||
|
const remoteModel = {
|
||||||
|
metadata: { tags: ['Featured'], size: 100 },
|
||||||
|
name: 'Test Model',
|
||||||
|
engine: 'openai',
|
||||||
|
}
|
||||||
|
|
||||||
|
const localModel = {
|
||||||
|
metadata: { tags: ['Local'], size: 100 },
|
||||||
|
name: 'Local Model',
|
||||||
|
engine: 'nitro',
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredModels = [remoteModel, localModel]
|
||||||
|
|
||||||
|
const mockConfiguredModel = configuredModels
|
||||||
|
const selectedModel = { id: 'selectedModel', name: 'selectedModel' }
|
||||||
|
const setSelectedModel = jest.fn()
|
||||||
|
const showEngineListModel = ['nitro']
|
||||||
|
const showEngineListModelAtom = jest.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
;(useAtom as jest.Mock).mockReturnValue([selectedModel, setSelectedModel])
|
||||||
|
;(useAtom as jest.Mock).mockReturnValue([
|
||||||
|
showEngineListModel,
|
||||||
|
showEngineListModelAtom,
|
||||||
|
])
|
||||||
|
;(useAtomValue as jest.Mock).mockReturnValue(mockConfiguredModel)
|
||||||
|
;(useRecommendedModel as jest.Mock).mockReturnValue({
|
||||||
|
recommendedModel: { id: 'model1', parameters: [], settings: [] },
|
||||||
|
downloadedModels: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the ModelDropdown component', async () => {
|
||||||
|
render(<ModelDropdown />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the ModelDropdown component as disabled', async () => {
|
||||||
|
render(<ModelDropdown disabled />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-selector')).toHaveClass(
|
||||||
|
'pointer-events-none'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the ModelDropdown component as badge for chat Input', async () => {
|
||||||
|
render(<ModelDropdown chatInputMode />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-selector-badge')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-selector-badge')).toHaveClass('badge')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Tab correctly', async () => {
|
||||||
|
render(<ModelDropdown />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('On-device'))
|
||||||
|
expect(screen.getByText('Cloud'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Select,
|
Tabs,
|
||||||
useClickOutside,
|
useClickOutside,
|
||||||
} from '@janhq/joi'
|
} from '@janhq/joi'
|
||||||
|
|
||||||
@ -70,8 +70,8 @@ const ModelDropdown = ({
|
|||||||
strictedThread = true,
|
strictedThread = true,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { downloadModel } = useDownloadModel()
|
const { downloadModel } = useDownloadModel()
|
||||||
const [searchFilter, setSearchFilter] = useState('all')
|
|
||||||
const [filterOptionsOpen, setFilterOptionsOpen] = useState(false)
|
const [searchFilter, setSearchFilter] = useState('local')
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const activeThread = useAtomValue(activeThreadAtom)
|
const activeThread = useAtomValue(activeThreadAtom)
|
||||||
@ -92,10 +92,7 @@ const ModelDropdown = ({
|
|||||||
)
|
)
|
||||||
const { updateThreadMetadata } = useCreateNewThread()
|
const { updateThreadMetadata } = useCreateNewThread()
|
||||||
|
|
||||||
useClickOutside(() => !filterOptionsOpen && setOpen(false), null, [
|
useClickOutside(() => setOpen(false), null, [dropdownOptions, toggle])
|
||||||
dropdownOptions,
|
|
||||||
toggle,
|
|
||||||
])
|
|
||||||
|
|
||||||
const [showEngineListModel, setShowEngineListModel] = useAtom(
|
const [showEngineListModel, setShowEngineListModel] = useAtom(
|
||||||
showEngineListModelAtom
|
showEngineListModelAtom
|
||||||
@ -115,9 +112,6 @@ const ModelDropdown = ({
|
|||||||
e.name.toLowerCase().includes(searchText.toLowerCase().trim())
|
e.name.toLowerCase().includes(searchText.toLowerCase().trim())
|
||||||
)
|
)
|
||||||
.filter((e) => {
|
.filter((e) => {
|
||||||
if (searchFilter === 'all') {
|
|
||||||
return e.engine
|
|
||||||
}
|
|
||||||
if (searchFilter === 'local') {
|
if (searchFilter === 'local') {
|
||||||
return localEngines.includes(e.engine)
|
return localEngines.includes(e.engine)
|
||||||
}
|
}
|
||||||
@ -152,9 +146,9 @@ const ModelDropdown = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeThread) return
|
if (!activeThread) return
|
||||||
let model = downloadedModels.find(
|
const modelId = activeThread?.assistants?.[0]?.model?.id
|
||||||
(model) => model.id === activeThread.assistants[0].model.id
|
|
||||||
)
|
let model = downloadedModels.find((model) => model.id === modelId)
|
||||||
if (!model) {
|
if (!model) {
|
||||||
model = recommendedModel
|
model = recommendedModel
|
||||||
}
|
}
|
||||||
@ -309,10 +303,14 @@ const ModelDropdown = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={twMerge('relative', disabled && 'pointer-events-none')}>
|
<div
|
||||||
|
className={twMerge('relative', disabled && 'pointer-events-none')}
|
||||||
|
data-testid="model-selector"
|
||||||
|
>
|
||||||
<div ref={setToggle}>
|
<div ref={setToggle}>
|
||||||
{chatInputMode ? (
|
{chatInputMode ? (
|
||||||
<Badge
|
<Badge
|
||||||
|
data-testid="model-selector-badge"
|
||||||
theme="secondary"
|
theme="secondary"
|
||||||
variant={open ? 'solid' : 'outline'}
|
variant={open ? 'solid' : 'outline'}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
@ -341,19 +339,30 @@ const ModelDropdown = ({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'w=80 absolute right-0 z-20 mt-2 max-h-80 w-full overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-sm',
|
'absolute right-0 z-20 mt-2 max-h-80 w-full overflow-hidden rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] shadow-sm',
|
||||||
open ? 'flex' : 'hidden',
|
open ? 'flex' : 'hidden',
|
||||||
chatInputMode && 'bottom-8 left-0 w-72'
|
chatInputMode && 'bottom-8 left-0 w-72'
|
||||||
)}
|
)}
|
||||||
ref={setDropdownOptions}
|
ref={setDropdownOptions}
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="relative">
|
<div className="p-2 pb-0">
|
||||||
|
<Tabs
|
||||||
|
options={[
|
||||||
|
{ name: 'On-device', value: 'local' },
|
||||||
|
{ name: 'Cloud', value: 'remote' },
|
||||||
|
]}
|
||||||
|
tabStyle="segmented"
|
||||||
|
value={searchFilter as string}
|
||||||
|
onValueChange={(value) => setSearchFilter(value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative border-b border-[hsla(var(--app-border))] py-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
className="rounded-none border-x-0 border-t-0 focus-within:ring-0 hover:border-b-[hsla(var(--app-border))]"
|
className="rounded-none border-x-0 border-b-0 border-t-0 focus-within:ring-0 "
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
suffixIcon={
|
suffixIcon={
|
||||||
searchText.length > 0 && (
|
searchText.length > 0 && (
|
||||||
@ -365,26 +374,8 @@ const ModelDropdown = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
'absolute right-2 top-1/2 -translate-y-1/2',
|
|
||||||
searchText.length && 'hidden'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={searchFilter}
|
|
||||||
className="h-6 gap-1 px-2"
|
|
||||||
options={[
|
|
||||||
{ name: 'All', value: 'all' },
|
|
||||||
{ name: 'On-device', value: 'local' },
|
|
||||||
{ name: 'Cloud', value: 'remote' },
|
|
||||||
]}
|
|
||||||
onValueChange={(value) => setSearchFilter(value)}
|
|
||||||
onOpenChange={(open) => setFilterOptionsOpen(open)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-[calc(100%-36px)] w-full">
|
<ScrollArea className="h-[calc(100%-90px)] w-full">
|
||||||
{groupByEngine.map((engine, i) => {
|
{groupByEngine.map((engine, i) => {
|
||||||
const apiKey = !localEngines.includes(engine)
|
const apiKey = !localEngines.includes(engine)
|
||||||
? extensionHasSettings.filter((x) => x.provider === engine)[0]
|
? extensionHasSettings.filter((x) => x.provider === engine)[0]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user