feat: Add page assist select component to header

This commit introduces a new `PageAssistSelect` component to the header, which replaces the previous `Select` component for selecting the active chat model. The new component provides improved functionality, including:

- Ability to display provider icons alongside the model name
- Truncation of long model names to ensure they fit within the available space
- Improved loading state handling
- Ability to refresh the model list on demand

These changes enhance the user experience and make it easier for users to quickly select the desired chat model.
This commit is contained in:
n4ze3m 2024-11-16 15:50:11 +05:30
parent 4292dc45ea
commit c4d9e3aeed
3 changed files with 350 additions and 25 deletions

View File

@ -22,6 +22,7 @@ import { getAllPrompts } from "@/db"
import { ShareBtn } from "~/components/Common/ShareBtn" import { ShareBtn } from "~/components/Common/ShareBtn"
import { ProviderIcons } from "../Common/ProviderIcon" import { ProviderIcons } from "../Common/ProviderIcon"
import { NewChat } from "./NewChat" import { NewChat } from "./NewChat"
import { PageAssistSelect } from "../Select"
type Props = { type Props = {
setSidebarOpen: (open: boolean) => void setSidebarOpen: (open: boolean) => void
setOpenModelSettings: (open: boolean) => void setOpenModelSettings: (open: boolean) => void
@ -49,14 +50,10 @@ export const Header: React.FC<Props> = ({
historyId, historyId,
temporaryChat temporaryChat
} = useMessageOption() } = useMessageOption()
const { const { data: models, isLoading: isModelsLoading, refetch } = useQuery({
data: models,
isLoading: isModelsLoading,
} = useQuery({
queryKey: ["fetchModel"], queryKey: ["fetchModel"],
queryFn: () => fetchChatModels({ returnEmpty: true }), queryFn: () => fetchChatModels({ returnEmpty: true }),
refetchInterval: 15_000, refetchIntervalInBackground: false,
refetchIntervalInBackground: true,
placeholderData: (prev) => prev placeholderData: (prev) => prev
}) })
@ -87,9 +84,10 @@ export const Header: React.FC<Props> = ({
} }
return ( return (
<div className={`sticky top-0 z-[999] flex h-16 p-3 bg-gray-50 border-b dark:bg-[#171717] dark:border-gray-600 ${ <div
temporaryChat && "!bg-gray-200 dark:!bg-black" className={`sticky top-0 z-[999] flex h-16 p-3 bg-gray-50 border-b dark:bg-[#171717] dark:border-gray-600 ${
}`}> temporaryChat && "!bg-gray-200 dark:!bg-black"
}`}>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{pathname !== "/" && ( {pathname !== "/" && (
<div> <div>
@ -107,41 +105,38 @@ export const Header: React.FC<Props> = ({
<PanelLeftIcon className="w-6 h-6" /> <PanelLeftIcon className="w-6 h-6" />
</button> </button>
</div> </div>
<NewChat <NewChat clearChat={clearChat} />
clearChat={clearChat}
/>
<span className="text-lg font-thin text-zinc-300 dark:text-zinc-600"> <span className="text-lg font-thin text-zinc-300 dark:text-zinc-600">
{"/"} {"/"}
</span> </span>
<div className="hidden lg:block"> <div className="hidden lg:block">
<Select <PageAssistSelect
className="w-80"
placeholder={t("common:selectAModel")}
value={selectedModel} value={selectedModel}
onChange={(e) => { onChange={(e) => {
setSelectedModel(e) setSelectedModel(e.value)
localStorage.setItem("selectedModel", e) localStorage.setItem("selectedModel", e.value)
}} }}
size="large" isLoading={isModelsLoading}
loading={isModelsLoading}
filterOption={(input, option) =>
option.label.key.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
showSearch
placeholder={t("common:selectAModel")}
className="w-72"
options={models?.map((model) => ({ options={models?.map((model) => ({
label: ( label: (
<span <span
key={model.model} key={model.model}
className="flex flex-row gap-3 items-center truncate"> className="flex flex-row gap-3 items-center ">
<ProviderIcons <ProviderIcons
provider={model?.provider} provider={model?.provider}
className="w-5 h-5" className="w-5 h-5"
/> />
<span className="truncate">{model.name}</span> <span className="line-clamp-2">{model.name}</span>
</span> </span>
), ),
value: model.model value: model.model
}))} }))}
onRefresh={() => {
refetch()
}}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">

View File

@ -0,0 +1,27 @@
import React from "react"
export const LoadingIndicator: React.FC<{ className?: string }> = ({
className = ""
}) => (
<div className={`animate-spin ${className}`}>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)

View File

@ -0,0 +1,303 @@
import React, { useState, useRef, useEffect, useMemo } from "react"
import { Search, RotateCw, ChevronDown } from "lucide-react"
import { LoadingIndicator } from "./LoadingIndicator"
import { Empty } from "antd"
export interface SelectOption {
label: string | JSX.Element
value: string
}
interface SelectProps {
options: SelectOption[]
value?: string
onChange: (option: SelectOption) => void
placeholder?: string
onRefresh?: () => void
className?: string
dropdownClassName?: string
optionClassName?: string
searchClassName?: string
disabled?: boolean
isLoading?: boolean
loadingText?: string
filterOption?: (input: string, option: SelectOption) => boolean
}
export const PageAssistSelect: React.FC<SelectProps> = ({
options = [],
value,
onChange,
placeholder = "Select an option",
onRefresh,
className = "",
dropdownClassName = "",
optionClassName = "",
searchClassName = "",
disabled = false,
isLoading = false,
loadingText = "Loading...",
filterOption
}) => {
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(-1)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
useEffect(() => {
if (!options) return
const filtered = options.filter((option) => {
if (!searchTerm) return true
if (filterOption) {
return filterOption(searchTerm, option)
}
if (typeof option.label === "string") {
return option.label.toLowerCase().includes(searchTerm.toLowerCase())
}
if (React.isValidElement(option.label)) {
const textContent = extractTextFromJSX(option.label)
return textContent.toLowerCase().includes(searchTerm.toLowerCase())
}
return false
})
setFilteredOptions(filtered)
setActiveIndex(-1)
}, [searchTerm, options, filterOption])
const extractTextFromJSX = (element: React.ReactElement): string => {
if (typeof element.props.children === "string") {
return element.props.children
}
if (Array.isArray(element.props.children)) {
return element.props.children
.map((child) => {
if (typeof child === "string") return child
if (React.isValidElement(child)) return extractTextFromJSX(child)
return ""
})
.join(" ")
}
if (React.isValidElement(element.props.children)) {
return extractTextFromJSX(element.props.children)
}
return ""
}
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation()
onRefresh?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled || isLoading) return
switch (e.key) {
case "Enter":
if (isOpen && activeIndex >= 0) {
e.preventDefault()
const selectedOption = filteredOptions[activeIndex]
if (selectedOption) {
onChange(selectedOption)
setIsOpen(false)
setSearchTerm("")
}
} else {
setIsOpen(!isOpen)
}
break
case " ":
if (!isOpen) {
e.preventDefault()
setIsOpen(true)
}
break
case "Escape":
setIsOpen(false)
break
case "ArrowDown":
e.preventDefault()
if (!isOpen) {
setIsOpen(true)
} else {
setActiveIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
)
}
break
case "ArrowUp":
e.preventDefault()
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
}
}
const defaultSelectClass = `
flex items-center justify-between p-2.5 rounded-lg border
${disabled || isLoading ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
${isOpen ? "ring-2 ring-blue-500" : ""}
bg-transparent border-gray-200 text-gray-900
transition-all duration-200
dark:text-white
dark:border-[#353534]
`
const defaultDropdownClass = `
absolute z-50 w-full mt-1 bg-white dark:bg-[#1e1e1f] dark:text-white rounded-lg shadow-lg
border border-gray-200 dark:border-[#353534]
`
const defaultSearchClass = `
w-full pl-8 pr-8 py-1.5 rounded-md
bg-gray-50 border border-gray-200
focus:outline-none focus:ring-2 focus:ring-gray-100
text-gray-900
dark:bg-[#1e1e1f] dark:text-white
dark:border-[#353534]
dark:focus:ring-gray-700
dark:focus:border-gray-700
dark:placeholder-gray-400
dark:bg-opacity-90
dark:hover:bg-opacity-100
dark:focus:bg-opacity-100
dark:hover:border-gray-700
dark:hover:bg-[#2a2a2b]
dark:focus:bg-[#2a2a2b]
`
const defaultOptionClass = `
p-2 cursor-pointer transition-colors duration-150
`
const selectedOption = useMemo(() => {
if (!value || !options) return null
return options?.find(opt => opt.value === value)
}, [value, options])
if (!options) {
return (
<div className={`relative w-full ${className}`}>
<div className={`${defaultSelectClass} ${className}`}>
<LoadingIndicator />
<span>{loadingText}</span>
</div>
</div>
)
}
return (
<div ref={containerRef} className={`relative w-full ${className}`}>
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="select-dropdown"
aria-label={placeholder}
tabIndex={disabled ? -1 : 0}
onClick={() => !disabled && !isLoading && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className={`${defaultSelectClass} ${className}`}>
<span className="!truncate flex items-center gap-2 ">
{isLoading && <LoadingIndicator />}
{isLoading ? loadingText : selectedOption ? selectedOption.label : <span className="dark:text-gray-400 text-sm">{placeholder}</span>}
</span>
<ChevronDown
aria-hidden="true"
className={`w-4 h-4 transition-transform duration-200 ${
isOpen ? "transform rotate-180" : ""
}`}
/>
</div>
{isOpen && (
<div
id="select-dropdown"
role="listbox"
className={`${defaultDropdownClass} ${dropdownClassName}`}>
<div className="p-2 border-b border-gray-200 dark:border-[#353534]">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className={`${defaultSearchClass} ${searchClassName}`}
disabled={isLoading}
aria-label="Search options"
/>
<Search aria-hidden="true" className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
{onRefresh && (
<button
onClick={handleRefresh}
disabled={isLoading}
aria-label="Refresh options"
className={`absolute right-2 top-1/2 transform -translate-y-1/2
hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200
${isLoading ? "cursor-not-allowed opacity-50" : ""}`}>
<RotateCw
aria-hidden="true"
className={`w-4 h-4 dark:text-gray-400 ${isLoading ? "animate-spin" : ""}`}
/>
</button>
)}{" "}
</div>
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{isLoading ? (
<div className="p-4 text-center text-gray-500 flex items-center justify-center gap-2">
<LoadingIndicator />
<span>{loadingText}</span>
</div>
) : filteredOptions.length === 0 ? (
<div className="p-6">
<Empty />
</div>
) : (
filteredOptions.map((option, index) => (
<div
key={option.value}
role="option"
aria-selected={value === option.value}
onClick={() => {
onChange(option)
setIsOpen(false)
setSearchTerm("")
}}
className={`
${defaultOptionClass}
${value === option.value ? "bg-blue-50 dark:bg-[#262627]" : "hover:bg-gray-100 dark:hover:bg-[#272728]"}
${activeIndex === index ? "bg-gray-100 dark:bg-[#272728]" : ""}
${optionClassName}`}>
{option.label}
</div>
))
)}
</div>
</div>
)}
</div>
)
}