fix: download management ui and double refresh model

This commit is contained in:
Faisal Amir 2025-09-23 20:17:51 +07:00
parent 3f51c35229
commit d3fff154d4
3 changed files with 202 additions and 145 deletions

View File

@ -400,20 +400,23 @@ export function DownloadManagement() {
className="text-main-view-fg/70 cursor-pointer" className="text-main-view-fg/70 cursor-pointer"
title="Cancel download" title="Cancel download"
onClick={() => { onClick={() => {
serviceHub.models().abortDownload(download.name).then(() => { serviceHub
toast.info( .models()
t('common:toast.downloadCancelled.title'), .abortDownload(download.name)
{ .then(() => {
id: 'cancel-download', toast.info(
description: t( t('common:toast.downloadCancelled.title'),
'common:toast.downloadCancelled.description' {
), id: 'cancel-download',
description: t(
'common:toast.downloadCancelled.description'
),
}
)
if (downloadProcesses.length === 0) {
setIsPopoverOpen(false)
} }
) })
if (downloadProcesses.length === 0) {
setIsPopoverOpen(false)
}
})
}} }}
/> />
</div> </div>

View File

@ -35,7 +35,7 @@ import { toast } from 'sonner'
import { DownloadManagement } from '@/containers/DownloadManegement' import { DownloadManagement } from '@/containers/DownloadManegement'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
import { useClickOutside } from '@/hooks/useClickOutside' import { useClickOutside } from '@/hooks/useClickOutside'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import { DeleteAllThreadsDialog } from '@/containers/dialogs' import { DeleteAllThreadsDialog } from '@/containers/dialogs'
const mainMenus = [ const mainMenus = [
@ -122,7 +122,7 @@ const LeftPanel = () => {
) { ) {
if (currentIsSmallScreen && open) { if (currentIsSmallScreen && open) {
setLeftPanel(false) setLeftPanel(false)
} else if(!open) { } else if (!open) {
setLeftPanel(true) setLeftPanel(true)
} }
prevScreenSizeRef.current = currentIsSmallScreen prevScreenSizeRef.current = currentIsSmallScreen
@ -179,8 +179,6 @@ const LeftPanel = () => {
} }
}, [isSmallScreen, open]) }, [isSmallScreen, open])
const { downloads, localDownloadingModels } = useDownloadStore()
return ( return (
<> <>
{/* Backdrop overlay for small screens */} {/* Backdrop overlay for small screens */}
@ -262,15 +260,8 @@ const LeftPanel = () => {
)} )}
</div> </div>
<div className="flex flex-col justify-between overflow-hidden mt-0 !h-[calc(100%-42px)]"> <div className="flex flex-col justify-between overflow-hidden mt-0 !h-[calc(100%-42px)] ">
<div <div className={cn('flex flex-col !h-[calc(100%-200px)]')}>
className={cn(
'flex flex-col',
Object.keys(downloads).length > 0 || localDownloadingModels.size > 0
? 'h-[calc(100%-200px)]'
: 'h-[calc(100%-140px)]'
)}
>
{IS_MACOS && ( {IS_MACOS && (
<div <div
ref={searchContainerMacRef} ref={searchContainerMacRef}
@ -379,7 +370,9 @@ const LeftPanel = () => {
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
<DeleteAllThreadsDialog onDeleteAll={deleteAllThreads} /> <DeleteAllThreadsDialog
onDeleteAll={deleteAllThreads}
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -468,8 +461,9 @@ const LeftPanel = () => {
</Link> </Link>
) )
})} })}
<DownloadManagement />
</div> </div>
<DownloadManagement />
</div> </div>
</aside> </aside>
</> </>

View File

@ -7,8 +7,15 @@ import { cn } from '@/lib/utils'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
// Hook for the dropdown position // Hook for the dropdown position
function useDropdownPosition(open: boolean, containerRef: React.RefObject<HTMLDivElement | null>) { function useDropdownPosition(
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) open: boolean,
containerRef: React.RefObject<HTMLDivElement | null>
) {
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
})
const updateDropdownPosition = useCallback(() => { const updateDropdownPosition = useCallback(() => {
if (containerRef.current) { if (containerRef.current) {
@ -51,10 +58,18 @@ function useDropdownPosition(open: boolean, containerRef: React.RefObject<HTMLDi
} }
// Components for the different sections of the dropdown // Components for the different sections of the dropdown
const ErrorSection = ({ error, t }: { error: string; t: (key: string) => string }) => ( const ErrorSection = ({
error,
t,
}: {
error: string
t: (key: string) => string
}) => (
<div className="px-3 py-2 text-sm text-destructive"> <div className="px-3 py-2 text-sm text-destructive">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-destructive font-medium">{t('common:failedToLoadModels')}</span> <span className="text-destructive font-medium">
{t('common:failedToLoadModels')}
</span>
</div> </div>
<div className="text-xs text-main-view-fg/50 mt-0">{error}</div> <div className="text-xs text-main-view-fg/50 mt-0">{error}</div>
</div> </div>
@ -67,12 +82,20 @@ const LoadingSection = ({ t }: { t: (key: string) => string }) => (
</div> </div>
) )
const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record<string, string>) => string }) => ( const EmptySection = ({
inputValue,
t,
}: {
inputValue: string
t: (key: string, options?: Record<string, string>) => string
}) => (
<div className="px-3 py-3 text-sm text-main-view-fg/50 text-center"> <div className="px-3 py-3 text-sm text-main-view-fg/50 text-center">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
{inputValue.trim() ? ( {inputValue.trim() ? (
<span className="text-main-view-fg/50">{t('common:noModelsFoundFor', { searchValue: inputValue })}</span> <span className="text-main-view-fg/50">
{t('common:noModelsFoundFor', { searchValue: inputValue })}
</span>
) : ( ) : (
<span className="text-main-view-fg/50">{t('common:noModels')}</span> <span className="text-main-view-fg/50">{t('common:noModels')}</span>
)} )}
@ -86,7 +109,7 @@ const ModelsList = ({
value, value,
highlightedIndex, highlightedIndex,
onModelSelect, onModelSelect,
onHighlight onHighlight,
}: { }: {
filteredModels: string[] filteredModels: string[]
value: string value: string
@ -127,67 +150,95 @@ function useKeyboardNavigation(
onModelSelect: (model: string) => void, onModelSelect: (model: string) => void,
dropdownRef: React.RefObject<HTMLDivElement | null> dropdownRef: React.RefObject<HTMLDivElement | null>
) { ) {
// Scroll to the highlighted element // Scroll to the highlighted element
useEffect(() => { useEffect(() => {
if (highlightedIndex >= 0 && dropdownRef.current) { if (highlightedIndex >= 0 && dropdownRef.current) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const modelElements = dropdownRef.current?.querySelectorAll('[data-model]') const modelElements =
const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement dropdownRef.current?.querySelectorAll('[data-model]')
const highlightedElement = modelElements?.[
highlightedIndex
] as HTMLElement
if (highlightedElement) { if (highlightedElement) {
highlightedElement.scrollIntoView({ highlightedElement.scrollIntoView({
block: 'nearest', block: 'nearest',
behavior: 'auto' behavior: 'auto',
}) })
} }
}) })
} }
}, [highlightedIndex, dropdownRef]) }, [highlightedIndex, dropdownRef])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback(
// Open the dropdown with the arrows if closed (e: React.KeyboardEvent) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { // Open the dropdown with the arrows if closed
if (models.length > 0) { if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault() if (models.length > 0) {
setOpen(true) e.preventDefault()
setHighlightedIndex(0) setOpen(true)
} setHighlightedIndex(0)
return
}
if (!open) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1))
break
case 'Enter':
e.preventDefault()
if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) {
onModelSelect(filteredModels[highlightedIndex])
} }
break return
case 'Escape': }
e.preventDefault()
e.stopPropagation() if (!open) return
setOpen(false)
setHighlightedIndex(-1) switch (e.key) {
break case 'ArrowDown':
case 'PageUp': e.preventDefault()
e.preventDefault() setHighlightedIndex((prev: number) =>
setHighlightedIndex(0) filteredModels.length === 0
break ? 0
case 'PageDown': : prev < filteredModels.length - 1
e.preventDefault() ? prev + 1
setHighlightedIndex(filteredModels.length - 1) : 0
break )
} break
}, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect]) case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev: number) =>
filteredModels.length === 0
? 0
: prev > 0
? prev - 1
: filteredModels.length - 1
)
break
case 'Enter':
e.preventDefault()
if (
highlightedIndex >= 0 &&
highlightedIndex < filteredModels.length
) {
onModelSelect(filteredModels[highlightedIndex])
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
setHighlightedIndex(-1)
break
case 'PageUp':
e.preventDefault()
setHighlightedIndex(0)
break
case 'PageDown':
e.preventDefault()
setHighlightedIndex(filteredModels.length - 1)
break
}
},
[
open,
setOpen,
models.length,
filteredModels,
highlightedIndex,
setHighlightedIndex,
onModelSelect,
]
)
return { handleKeyDown } return { handleKeyDown }
} }
@ -266,13 +317,18 @@ export function ModelCombobox({
} }
const events = ['mousedown', 'touchstart'] const events = ['mousedown', 'touchstart']
events.forEach(eventType => { events.forEach((eventType) => {
document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true }) document.addEventListener(eventType, handleClickOutside, {
capture: true,
passive: true,
})
}) })
return () => { return () => {
events.forEach(eventType => { events.forEach((eventType) => {
document.removeEventListener(eventType, handleClickOutside, { capture: true }) document.removeEventListener(eventType, handleClickOutside, {
capture: true,
})
}) })
} }
}, [open]) }, [open])
@ -286,26 +342,32 @@ export function ModelCombobox({
}, []) }, [])
// Handler for the input change // Handler for the input change
const handleInputChange = useCallback((newValue: string) => { const handleInputChange = useCallback(
setInputValue(newValue) (newValue: string) => {
onChange(newValue) setInputValue(newValue)
onChange(newValue)
// Open the dropdown if the user types and there are models // Open the dropdown if the user types and there are models
if (newValue.trim() && models.length > 0) { if (newValue.trim() && models.length > 0) {
setOpen(true) setOpen(true)
} else { } else {
setOpen(false) setOpen(false)
} }
}, [onChange, models.length]) },
[onChange, models.length]
)
// Handler for the model selection // Handler for the model selection
const handleModelSelect = useCallback((model: string) => { const handleModelSelect = useCallback(
setInputValue(model) (model: string) => {
onChange(model) setInputValue(model)
setOpen(false) onChange(model)
setHighlightedIndex(-1) setOpen(false)
inputRef.current?.focus() setHighlightedIndex(-1)
}, [onChange]) inputRef.current?.focus()
},
[onChange]
)
// Hook for the keyboard navigation // Hook for the keyboard navigation
const { handleKeyDown } = useKeyboardNavigation( const { handleKeyDown } = useKeyboardNavigation(
@ -376,54 +438,52 @@ export function ModelCombobox({
onClick={handleDropdownToggle} onClick={handleDropdownToggle}
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10" className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10"
> >
{loading ? ( <IconChevronDown className="h-3 w-3 opacity-50" />
<IconLoader2 className="h-3 w-3 animate-spin" />
) : (
<IconChevronDown className="h-3 w-3 opacity-50" />
)}
</Button> </Button>
</div> </div>
{/* Custom dropdown rendered as portal */} {/* Custom dropdown rendered as portal */}
{open && dropdownPosition.width > 0 && createPortal( {open &&
<div dropdownPosition.width > 0 &&
ref={dropdownRef} createPortal(
className="fixed z-[9999] bg-main-view border border-main-view-fg/10 rounded-md shadow-lg max-h-[300px] overflow-y-auto text-main-view-fg animate-in fade-in-0 zoom-in-95 duration-200" <div
style={{ ref={dropdownRef}
top: dropdownPosition.top, className="fixed z-[9999] bg-main-view border border-main-view-fg/10 rounded-md shadow-lg max-h-[300px] overflow-y-auto text-main-view-fg animate-in fade-in-0 zoom-in-95 duration-200"
left: dropdownPosition.left, style={{
width: dropdownPosition.width, top: dropdownPosition.top,
minWidth: dropdownPosition.width, left: dropdownPosition.left,
maxWidth: dropdownPosition.width, width: dropdownPosition.width,
pointerEvents: 'auto', minWidth: dropdownPosition.width,
}} maxWidth: dropdownPosition.width,
data-dropdown="model-combobox" pointerEvents: 'auto',
onPointerDown={(e) => e.stopPropagation()} }}
onWheel={(e) => e.stopPropagation()} data-dropdown="model-combobox"
> onPointerDown={(e) => e.stopPropagation()}
{/* Error state */} onWheel={(e) => e.stopPropagation()}
{error && <ErrorSection error={error} t={t} />} >
{/* Error state */}
{error && <ErrorSection error={error} t={t} />}
{/* Loading state */} {/* Loading state */}
{loading && <LoadingSection t={t} />} {loading && <LoadingSection t={t} />}
{/* Models list */} {/* Models list */}
{!loading && !error && ( {!loading &&
filteredModels.length === 0 ? ( !error &&
<EmptySection inputValue={inputValue} t={t} /> (filteredModels.length === 0 ? (
) : ( <EmptySection inputValue={inputValue} t={t} />
<ModelsList ) : (
filteredModels={filteredModels} <ModelsList
value={value} filteredModels={filteredModels}
highlightedIndex={highlightedIndex} value={value}
onModelSelect={handleModelSelect} highlightedIndex={highlightedIndex}
onHighlight={setHighlightedIndex} onModelSelect={handleModelSelect}
/> onHighlight={setHighlightedIndex}
) />
)} ))}
</div>, </div>,
document.body document.body
)} )}
</div> </div>
</div> </div>
) )