Merge pull request #6560 from menloresearch/fix/layout-downlod-management

fix: download management ui and double refresh model
This commit is contained in:
Faisal Amir 2025-09-24 09:48:42 +07:00 committed by GitHub
commit 5b59c7a18a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 281 additions and 191 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,12 @@
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
import {
describe,
it,
expect,
vi,
beforeEach,
beforeAll,
afterAll,
} from 'vitest'
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/vitest'
@ -11,7 +19,8 @@ vi.mock('@/i18n/react-i18next-compat', () => ({
t: (key: string, options?: Record<string, string>) => {
if (key === 'common:failedToLoadModels') return 'Failed to load models'
if (key === 'common:loading') return 'Loading'
if (key === 'common:noModelsFoundFor') return `No models found for "${options?.searchValue}"`
if (key === 'common:noModelsFoundFor')
return `No models found for "${options?.searchValue}"`
if (key === 'common:noModels') return 'No models available'
return key
},
@ -21,7 +30,7 @@ vi.mock('@/i18n/react-i18next-compat', () => ({
describe('ModelCombobox', () => {
const mockOnChange = vi.fn()
const mockOnRefresh = vi.fn()
const defaultProps = {
value: '',
onChange: mockOnChange,
@ -64,7 +73,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} />)
})
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
@ -74,7 +83,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} placeholder="Choose a model" />)
})
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'Choose a model')
})
@ -83,7 +92,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} />)
})
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
@ -92,7 +101,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} value="gpt-4" />)
})
const input = screen.getByDisplayValue('gpt-4')
expect(input).toBeInTheDocument()
})
@ -110,7 +119,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} disabled />)
})
const input = screen.getByRole('textbox')
const button = screen.getByRole('button')
@ -118,27 +127,19 @@ describe('ModelCombobox', () => {
expect(button).toBeDisabled()
})
it('shows loading spinner in trigger button', () => {
act(() => {
render(<ModelCombobox {...defaultProps} loading />)
})
const button = screen.getByRole('button')
const spinner = button.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('shows loading section when dropdown is opened during loading', async () => {
const user = userEvent.setup()
render(<ModelCombobox {...defaultProps} loading />)
// Click input to trigger dropdown opening
const input = screen.getByRole('textbox')
await user.click(input)
// Wait for dropdown to appear and check loading section
await waitFor(() => {
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
expect(screen.getByText('Loading')).toBeInTheDocument()
})
@ -179,7 +180,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} models={[]} />)
})
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
@ -188,7 +189,7 @@ describe('ModelCombobox', () => {
act(() => {
render(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
})
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
@ -259,7 +260,7 @@ describe('ModelCombobox', () => {
/>
)
})
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toBeDisabled()
@ -273,7 +274,9 @@ describe('ModelCombobox', () => {
await user.click(button)
await waitFor(() => {
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
})
})
@ -287,7 +290,9 @@ describe('ModelCombobox', () => {
expect(input).toHaveFocus()
await waitFor(() => {
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
})
})
@ -313,9 +318,11 @@ describe('ModelCombobox', () => {
await waitFor(() => {
// Dropdown should be open
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
// Should show GPT models
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
expect(screen.getByText('gpt-4')).toBeInTheDocument()
@ -344,10 +351,14 @@ describe('ModelCombobox', () => {
await waitFor(() => {
// Dropdown should be open
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
// Should show empty state message
expect(screen.getByText('No models found for "nonexistent"')).toBeInTheDocument()
expect(
screen.getByText('No models found for "nonexistent"')
).toBeInTheDocument()
})
})
@ -358,12 +369,12 @@ describe('ModelCombobox', () => {
const input = screen.getByRole('textbox')
await user.click(input)
await waitFor(() => {
const modelOption = screen.getByText('gpt-4')
expect(modelOption).toBeInTheDocument()
})
const modelOption = screen.getByText('gpt-4')
await user.click(modelOption)
@ -385,7 +396,9 @@ describe('ModelCombobox', () => {
it('displays error message in dropdown', async () => {
const user = userEvent.setup()
render(<ModelCombobox {...defaultProps} error="Network connection failed" />)
render(
<ModelCombobox {...defaultProps} error="Network connection failed" />
)
const input = screen.getByRole('textbox')
// Click input to open dropdown
@ -393,7 +406,9 @@ describe('ModelCombobox', () => {
await waitFor(() => {
// Dropdown should be open
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
// Error messages should be displayed
expect(screen.getByText('Failed to load models')).toBeInTheDocument()
@ -404,7 +419,13 @@ describe('ModelCombobox', () => {
it('calls onRefresh when refresh button is clicked', async () => {
const user = userEvent.setup()
const localMockOnRefresh = vi.fn()
render(<ModelCombobox {...defaultProps} error="Network error" onRefresh={localMockOnRefresh} />)
render(
<ModelCombobox
{...defaultProps}
error="Network error"
onRefresh={localMockOnRefresh}
/>
)
const input = screen.getByRole('textbox')
// Click input to open dropdown
@ -412,13 +433,19 @@ describe('ModelCombobox', () => {
await waitFor(() => {
// Dropdown should be open with error section
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
const refreshButton = document.querySelector(
'[aria-label="Refresh models"]'
)
expect(refreshButton).toBeInTheDocument()
})
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
const refreshButton = document.querySelector(
'[aria-label="Refresh models"]'
)
if (refreshButton) {
await user.click(refreshButton)
expect(localMockOnRefresh).toHaveBeenCalledTimes(1)
@ -435,7 +462,9 @@ describe('ModelCombobox', () => {
expect(input).toHaveFocus()
await waitFor(() => {
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
})
})
@ -446,16 +475,18 @@ describe('ModelCombobox', () => {
const input = screen.getByRole('textbox')
input.focus()
// ArrowDown should open dropdown
await user.keyboard('{ArrowDown}')
await waitFor(() => {
// Dropdown should be open
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
})
// Navigate to second item
await user.keyboard('{ArrowDown}')
@ -474,13 +505,15 @@ describe('ModelCombobox', () => {
const input = screen.getByRole('textbox')
// Type 'gpt' to open dropdown and filter models
await user.type(input, 'gpt')
await waitFor(() => {
// Dropdown should be open with filtered models
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
const dropdown = document.querySelector(
'[data-dropdown="model-combobox"]'
)
expect(dropdown).toBeInTheDocument()
})
// Navigate to highlight first model and select it
await user.keyboard('{ArrowDown}')
await user.keyboard('{Enter}')