Repository: CherryHQ/cherry-studio
Pull request #5812: fix: SelectModelPopup scrolling behaviour
Author: login: alephpiece
Labels: name: UI/UX
Created: 2025-05-09T19:08:11Z | Merged: 2025-05-10T05:48:25Z

<!-- Template from https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->

### What this PR does

重构 SelectModelPopup 滚动状态管理，修复滚动行为。

Before this PR:

引入大量独立的 state/ref/useEffect 来管理滚动状态、触发滚动，存在一些问题。
尤其是触发滚动的 useEffect 同时依赖列表和聚焦项的变化，有时导致滚动触发时没有使用最新的值。

After this PR:

集中管理状态：
- 分离出 usePinnedModels hook 专门处理置顶模型。
- 分离出 useScrollState hook 专门集中处理滚动和聚焦，解决滚动没有正确触发的问题（尤其是在输入/删除搜索文本的时候）。

滚动行为：
- 存在搜索文本时，现在总是会尝试聚焦第一个模型。
- 没有搜索文本时，总是尝试聚焦选中的模型。

也许解决了热重载的问题（不确定）

<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*: -->

Fixes #

### Why we need it and why it was done in this way

The following tradeoffs were made:

The following alternatives were considered:

Links to places where the discussion took place: <!-- optional: slack, other GH issue, mailinglist, ... -->

### Breaking changes

<!-- optional -->

If this PR introduces breaking changes, please describe the changes and the impact on users.

### Special notes for your reviewer

<!-- optional -->

### Checklist

This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
Approvers are expected to review this list.

- [ ] PR: The PR description is expressive enough and will help future contributors
- [ ] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature.

### Release note

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. If no release note is required, just write "NONE".
-->

```release-note

```

## Summary by Sourcery

Refactor the scroll and focus management in SelectModelPopup to improve state handling and scrolling behavior

Enhancements:
- Centralized scroll and focus state management using a custom hook and reducer
- Separated concerns by extracting pinned models logic into a dedicated hook
- Improved scroll triggering logic for different scenarios

Chores:
- Restructured component file structure
- Extracted complex state logic into separate files

## Summary by Sourcery

Refactor the scroll and focus management in SelectModelPopup to improve state handling and scrolling behavior

New Features:
- Created a dedicated hook for managing pinned models
- Implemented a reducer-based approach for scroll state management

Enhancements:
- Centralized scroll and focus state management using a custom hook and reducer
- Separated concerns by extracting pinned models logic into a dedicated hook
- Improved scroll triggering logic for different scenarios

Chores:
- Restructured component file structure
- Extracted complex state logic into separate files


Diff:
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts
new file mode 100644
index 00000000000..93441acb21d
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts
@@ -0,0 +1,41 @@
+import { useMemo, useReducer } from 'react'
+
+import { initialScrollState, scrollReducer } from './reducer'
+import { FlatListItem, ScrollTrigger } from './types'
+
+/**
+ * 管理滚动和焦点状态的 hook
+ */
+export function useScrollState() {
+  const [state, dispatch] = useReducer(scrollReducer, initialScrollState)
+
+  const actions = useMemo(
+    () => ({
+      setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }),
+      setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }),
+      setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }),
+      setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }),
+      setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }),
+      focusNextItem: (modelItems: FlatListItem[], step: number) =>
+        dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }),
+      focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) =>
+        dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }),
+      searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }),
+      updateOnListChange: (modelItems: FlatListItem[]) =>
+        dispatch({ type: 'UPDATE_ON_LIST_CHANGE', payload: { modelItems } }),
+      initScroll: () => dispatch({ type: 'INIT_SCROLL' })
+    }),
+    []
+  )
+
+  return {
+    // 状态
+    focusedItemKey: state.focusedItemKey,
+    scrollTrigger: state.scrollTrigger,
+    lastScrollOffset: state.lastScrollOffset,
+    stickyGroup: state.stickyGroup,
+    isMouseOver: state.isMouseOver,
+    // 操作
+    ...actions
+  }
+}
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/index.ts b/src/renderer/src/components/Popups/SelectModelPopup/index.ts
new file mode 100644
index 00000000000..7a0b7fa8626
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/index.ts
@@ -0,0 +1,3 @@
+import { SelectModelPopup } from './popup'
+
+export default SelectModelPopup
diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
similarity index 72%
rename from src/renderer/src/components/Popups/SelectModelPopup.tsx
rename to src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
index c19c5def9c5..ffaac877ea5 100644
--- a/src/renderer/src/components/Popups/SelectModelPopup.tsx
+++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
@@ -1,7 +1,9 @@
 import { PushpinOutlined } from '@ant-design/icons'
+import { HStack } from '@renderer/components/Layout'
+import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
 import { TopView } from '@renderer/components/TopView'
 import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
-import db from '@renderer/databases'
+import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
 import { useProviders } from '@renderer/hooks/useProvider'
 import { getModelUniqId } from '@renderer/services/ModelService'
 import { Model } from '@renderer/types'
@@ -15,101 +17,61 @@ import { useTranslation } from 'react-i18next'
 import { FixedSizeList } from 'react-window'
 import styled from 'styled-components'
 
-import { HStack } from '../Layout'
-import ModelTagsWithLabel from '../ModelTagsWithLabel'
+import { useScrollState } from './hook'
+import { FlatListItem } from './types'
 
 const PAGE_SIZE = 9
 const ITEM_HEIGHT = 36
 
-// 列表项类型，组名也作为列表项
-type ListItemType = 'group' | 'model'
-
-// 滚动触发来源类型
-type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
-
-// 扁平化列表项接口
-interface FlatListItem {
-  key: string
-  type: ListItemType
-  icon?: React.ReactNode
-  name: React.ReactNode
-  tags?: React.ReactNode
-  model?: Model
-  isPinned?: boolean
-  isSelected?: boolean
-}
-
-interface Props {
+interface PopupParams {
   model?: Model
 }
 
-interface PopupContainerProps extends Props {
+interface Props extends PopupParams {
   resolve: (value: Model | undefined) => void
 }
 
-const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
+const PopupContainer: React.FC<Props> = ({ model, resolve }) => {
   const { t } = useTranslation()
   const { providers } = useProviders()
+  const { pinnedModels, togglePinnedModel, loading: loadingPinnedModels } = usePinnedModels()
   const [open, setOpen] = useState(true)
   const inputRef = useRef<InputRef>(null)
   const listRef = useRef<FixedSizeList>(null)
   const [_searchText, setSearchText] = useState('')
   const searchText = useDeferredValue(_searchText)
-  const [isMouseOver, setIsMouseOver] = useState(false)
-  const [pinnedModels, setPinnedModels] = useState<string[]>([])
-  const [_focusedItemKey, setFocusedItemKey] = useState<string>('')
-  const focusedItemKey = useDeferredValue(_focusedItemKey)
-  const [_stickyGroup, setStickyGroup] = useState<FlatListItem | null>(null)
-  const stickyGroup = useDeferredValue(_stickyGroup)
-  const firstGroupRef = useRef<FlatListItem | null>(null)
-  const scrollTriggerRef = useRef<ScrollTrigger>('initial')
-  const lastScrollOffsetRef = useRef(0)
 
   // 当前选中的模型ID
   const currentModelId = model ? getModelUniqId(model) : ''
 
-  // 加载置顶模型列表
-  useEffect(() => {
-    const loadPinnedModels = async () => {
-      const setting = await db.settings.get('pinned:models')
-      const savedPinnedModels = setting?.value || []
-
-      // Filter out invalid pinned models
-      const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
-      const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
+  // 管理滚动和焦点状态
+  const {
+    focusedItemKey,
+    scrollTrigger,
+    lastScrollOffset,
+    stickyGroup: _stickyGroup,
+    isMouseOver,
+    setFocusedItemKey,
+    setScrollTrigger,
+    setLastScrollOffset,
+    setStickyGroup,
+    setIsMouseOver,
+    focusNextItem,
+    focusPage,
+    searchChanged,
+    updateOnListChange,
+    initScroll
+  } = useScrollState()
 
-      // Update storage if there were invalid models
-      if (validPinnedModels.length !== savedPinnedModels.length) {
-        await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
-      }
-
-      setPinnedModels(sortBy(validPinnedModels))
-    }
-
-    try {
-      loadPinnedModels()
-    } catch (error) {
-      console.error('Failed to load pinned models', error)
-      setPinnedModels([])
-    }
-  }, [providers])
+  const stickyGroup = useDeferredValue(_stickyGroup)
+  const firstGroupRef = useRef<FlatListItem | null>(null)
 
   const togglePin = useCallback(
     async (modelId: string) => {
-      const newPinnedModels = pinnedModels.includes(modelId)
-        ? pinnedModels.filter((id) => id !== modelId)
-        : [...pinnedModels, modelId]
-
-      try {
-        await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
-        setPinnedModels(sortBy(newPinnedModels))
-        // Pin操作不触发滚动
-        scrollTriggerRef.current = 'none'
-      } catch (error) {
-        console.error('Failed to update pinned models', error)
-      }
+      await togglePinnedModel(modelId)
+      setScrollTrigger('none') // pin操作不触发滚动
     },
-    [pinnedModels]
+    [togglePinnedModel, setScrollTrigger]
   )
 
   // 根据输入的文本筛选模型
@@ -222,6 +184,16 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
     return items
   }, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem])
 
+  // 获取可选择的模型项（过滤掉分组标题）
+  const modelItems = useMemo(() => {
+    return listItems.filter((item) => item.type === 'model')
+  }, [listItems])
+
+  // 当搜索文本变化时更新滚动触发器
+  useEffect(() => {
+    searchChanged(searchText)
+  }, [searchText, searchChanged])
+
   // 基于滚动位置更新sticky分组标题
   const updateStickyGroup = useCallback(
     (scrollOffset?: number) => {
@@ -231,7 +203,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
       }
 
       // 基于滚动位置计算当前可见的第一个项的索引
-      const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffsetRef.current) / ITEM_HEIGHT)
+      const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT)
 
       // 从该索引向前查找最近的分组标题
       for (let i = estimatedIndex - 1; i >= 0; i--) {
@@ -242,9 +214,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
       }
 
       // 找不到则使用第一个分组标题
-      setStickyGroup(firstGroupRef.current ?? null)
+      setStickyGroup(firstGroupRef.current)
     },
-    [listItems]
+    [listItems, lastScrollOffset, setStickyGroup]
   )
 
   // 在listItems变化时更新sticky group
@@ -255,67 +227,46 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
   // 处理列表滚动事件，更新lastScrollOffset并更新sticky分组
   const handleScroll = useCallback(
     ({ scrollOffset }) => {
-      lastScrollOffsetRef.current = scrollOffset
+      setLastScrollOffset(scrollOffset)
       updateStickyGroup(scrollOffset)
     },
-    [updateStickyGroup]
+    [updateStickyGroup, setLastScrollOffset]
   )
 
-  // 获取可选择的模型项（过滤掉分组标题）
-  const modelItems = useMemo(() => {
-    return listItems.filter((item) => item.type === 'model')
-  }, [listItems])
-
-  // 搜索文本变化时设置滚动来源
+  // 在列表项更新时，更新焦点项
   useEffect(() => {
-    if (searchText.trim() !== '') {
-      scrollTriggerRef.current = 'search'
-      setFocusedItemKey('')
-    }
-  }, [searchText])
-
-  // 设置初始聚焦项以触发滚动
-  useEffect(() => {
-    if (scrollTriggerRef.current === 'initial' || scrollTriggerRef.current === 'search') {
-      const selectedItem = modelItems.find((item) => item.isSelected)
-      if (selectedItem) {
-        setFocusedItemKey(selectedItem.key)
-      } else if (scrollTriggerRef.current === 'initial' && modelItems.length > 0) {
-        setFocusedItemKey(modelItems[0].key)
-      }
-      // 其余情况不设置focusedItemKey
-    }
-  }, [modelItems])
+    updateOnListChange(modelItems)
+  }, [modelItems, updateOnListChange])
 
   // 滚动到聚焦项
   useEffect(() => {
-    if (scrollTriggerRef.current === 'none' || !focusedItemKey) return
+    if (scrollTrigger === 'none' || !focusedItemKey) return
 
     const index = listItems.findIndex((item) => item.key === focusedItemKey)
     if (index < 0) return
 
     // 根据触发源决定滚动对齐方式
-    const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center'
+    const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center'
     listRef.current?.scrollToItem(index, alignment)
 
     // 滚动后重置触发器
-    scrollTriggerRef.current = 'none'
-  }, [focusedItemKey, listItems])
+    setScrollTrigger('none')
+  }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger])
 
   const handleItemClick = useCallback(
     (item: FlatListItem) => {
       if (item.type === 'model') {
-        scrollTriggerRef.current = 'none'
+        setScrollTrigger('initial')
         resolve(item.model)
         setOpen(false)
       }
     },
-    [resolve]
+    [resolve, setScrollTrigger]
   )
 
   // 处理键盘导航
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
+  const handleKeyDown = useCallback(
+    (e: KeyboardEvent) => {
       if (!open) return
 
       if (modelItems.length === 0) {
@@ -329,43 +280,21 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
         setIsMouseOver(false)
       }
 
-      const getCurrentIndex = (currentKey: string) => {
-        const currentIndex = modelItems.findIndex((item) => item.key === currentKey)
-        return currentIndex < 0 ? 0 : currentIndex
-      }
+      const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey)
+      const normalizedIndex = currentIndex < 0 ? 0 : currentIndex
 
       switch (e.key) {
         case 'ArrowUp':
-          scrollTriggerRef.current = 'keyboard'
-          setFocusedItemKey((prev) => {
-            const currentIndex = getCurrentIndex(prev)
-            const nextIndex = (currentIndex - 1 + modelItems.length) % modelItems.length
-            return modelItems[nextIndex].key
-          })
+          focusNextItem(modelItems, -1)
           break
         case 'ArrowDown':
-          scrollTriggerRef.current = 'keyboard'
-          setFocusedItemKey((prev) => {
-            const currentIndex = getCurrentIndex(prev)
-            const nextIndex = (currentIndex + 1) % modelItems.length
-            return modelItems[nextIndex].key
-          })
+          focusNextItem(modelItems, 1)
           break
         case 'PageUp':
-          scrollTriggerRef.current = 'keyboard'
-          setFocusedItemKey((prev) => {
-            const currentIndex = getCurrentIndex(prev)
-            const nextIndex = Math.max(currentIndex - PAGE_SIZE, 0)
-            return modelItems[nextIndex].key
-          })
+          focusPage(modelItems, normalizedIndex, -PAGE_SIZE)
           break
         case 'PageDown':
-          scrollTriggerRef.current = 'keyboard'
-          setFocusedItemKey((prev) => {
-            const currentIndex = getCurrentIndex(prev)
-            const nextIndex = Math.min(currentIndex + PAGE_SIZE, modelItems.length - 1)
-            return modelItems[nextIndex].key
-          })
+          focusPage(modelItems, normalizedIndex, PAGE_SIZE)
           break
         case 'Enter':
           if (focusedItemKey) {
@@ -377,34 +306,47 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
           break
         case 'Escape':
           e.preventDefault()
-          scrollTriggerRef.current = 'none'
+          setScrollTrigger('none')
           setOpen(false)
           resolve(undefined)
           break
       }
-    }
+    },
+    [
+      focusedItemKey,
+      modelItems,
+      handleItemClick,
+      open,
+      resolve,
+      setIsMouseOver,
+      focusNextItem,
+      focusPage,
+      setScrollTrigger
+    ]
+  )
 
+  useEffect(() => {
     window.addEventListener('keydown', handleKeyDown)
     return () => window.removeEventListener('keydown', handleKeyDown)
-  }, [focusedItemKey, modelItems, handleItemClick, open, resolve])
+  }, [handleKeyDown])
 
   const onCancel = useCallback(() => {
-    scrollTriggerRef.current = 'none'
+    setScrollTrigger('initial')
     setOpen(false)
-  }, [])
+  }, [setScrollTrigger])
 
   const onClose = useCallback(async () => {
-    scrollTriggerRef.current = 'none'
+    setScrollTrigger('initial')
     resolve(undefined)
     SelectModelPopup.hide()
-  }, [resolve])
+  }, [resolve, setScrollTrigger])
 
+  // 初始化焦点和滚动位置
   useEffect(() => {
-    if (!open) return
+    if (!open || loadingPinnedModels) return
     setTimeout(() => inputRef.current?.focus(), 0)
-    scrollTriggerRef.current = 'initial'
-    lastScrollOffsetRef.current = 0
-  }, [open])
+    initScroll()
+  }, [open, initScroll, loadingPinnedModels])
 
   const RowData = useMemo(
     (): VirtualizedRowData => ({
@@ -415,7 +357,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
       handleItemClick,
       togglePin
     }),
-    [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin]
+    [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey]
   )
 
   const listHeight = useMemo(() => {
@@ -470,7 +412,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
       <Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
 
       {listItems.length > 0 ? (
-        <ListContainer onMouseMove={() => setIsMouseOver(true)}>
+        <ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
           {/* Sticky Group Banner，它会替换第一个分组名称 */}
           <StickyGroupBanner>{stickyGroup?.name}</StickyGroupBanner>
           <FixedSizeList
@@ -685,14 +627,26 @@ const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?:
   }
 `
 
-export default class SelectModelPopup {
+const TopViewKey = 'SelectModelPopup'
+
+export class SelectModelPopup {
+  static topviewId = 0
   static hide() {
-    TopView.hide('SelectModelPopup')
+    TopView.hide(TopViewKey)
   }
 
-  static show(params: Props) {
+  static show(params: PopupParams) {
     return new Promise<Model | undefined>((resolve) => {
-      TopView.show(<PopupContainer {...params} resolve={resolve} />, 'SelectModelPopup')
+      TopView.show(
+        <PopupContainer
+          {...params}
+          resolve={(v) => {
+            resolve(v)
+            TopView.hide(TopViewKey)
+          }}
+        />,
+        TopViewKey
+      )
     })
   }
 }
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts
new file mode 100644
index 00000000000..45e3390ea83
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts
@@ -0,0 +1,109 @@
+import { ScrollAction, ScrollState } from './types'
+
+/**
+ * 初始状态
+ */
+export const initialScrollState: ScrollState = {
+  focusedItemKey: '',
+  scrollTrigger: 'initial',
+  lastScrollOffset: 0,
+  stickyGroup: null,
+  isMouseOver: false
+}
+
+/**
+ * 滚动状态的 reducer，用于避免复杂依赖可能带来的状态更新问题
+ * @param state 当前状态
+ * @param action 动作
+ * @returns 新的状态
+ */
+export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => {
+  switch (action.type) {
+    case 'SET_FOCUSED_ITEM_KEY':
+      return { ...state, focusedItemKey: action.payload }
+
+    case 'SET_SCROLL_TRIGGER':
+      return { ...state, scrollTrigger: action.payload }
+
+    case 'SET_LAST_SCROLL_OFFSET':
+      return { ...state, lastScrollOffset: action.payload }
+
+    case 'SET_STICKY_GROUP':
+      return { ...state, stickyGroup: action.payload }
+
+    case 'SET_IS_MOUSE_OVER':
+      return { ...state, isMouseOver: action.payload }
+
+    case 'FOCUS_NEXT_ITEM': {
+      const { modelItems, step } = action.payload
+
+      if (modelItems.length === 0) {
+        return {
+          ...state,
+          focusedItemKey: '',
+          scrollTrigger: 'keyboard'
+        }
+      }
+
+      const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey)
+      const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length
+
+      return {
+        ...state,
+        focusedItemKey: modelItems[nextIndex].key,
+        scrollTrigger: 'keyboard'
+      }
+    }
+
+    case 'FOCUS_PAGE': {
+      const { modelItems, currentIndex, step } = action.payload
+      const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1))
+
+      return {
+        ...state,
+        focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '',
+        scrollTrigger: 'keyboard'
+      }
+    }
+
+    case 'SEARCH_CHANGED':
+      return {
+        ...state,
+        scrollTrigger: action.payload.searchText ? 'search' : 'initial'
+      }
+
+    case 'UPDATE_ON_LIST_CHANGE': {
+      const { modelItems } = action.payload
+
+      // 在列表变化时尝试聚焦一个模型：
+      // - 如果是 initial 状态，先尝试聚焦当前选中的模型
+      // - 如果是 search 状态，尝试聚焦第一个模型
+      let newFocusedKey = ''
+      if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') {
+        const selectedItem = modelItems.find((item) => item.isSelected)
+        if (selectedItem && state.scrollTrigger === 'initial') {
+          newFocusedKey = selectedItem.key
+        } else if (modelItems.length > 0) {
+          newFocusedKey = modelItems[0].key
+        }
+      } else {
+        newFocusedKey = state.focusedItemKey
+      }
+
+      return {
+        ...state,
+        focusedItemKey: newFocusedKey
+      }
+    }
+
+    case 'INIT_SCROLL':
+      return {
+        ...state,
+        scrollTrigger: 'initial',
+        lastScrollOffset: 0
+      }
+
+    default:
+      return state
+  }
+}
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts
new file mode 100644
index 00000000000..41ec04c5837
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts
@@ -0,0 +1,42 @@
+import { Model } from '@renderer/types'
+import { ReactNode } from 'react'
+
+// 列表项类型，组名也作为列表项
+export type ListItemType = 'group' | 'model'
+
+// 滚动触发来源类型
+export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none'
+
+// 扁平化列表项接口
+export interface FlatListItem {
+  key: string
+  type: ListItemType
+  icon?: ReactNode
+  name: ReactNode
+  tags?: ReactNode
+  model?: Model
+  isPinned?: boolean
+  isSelected?: boolean
+}
+
+// 滚动和焦点相关的状态类型
+export interface ScrollState {
+  focusedItemKey: string
+  scrollTrigger: ScrollTrigger
+  lastScrollOffset: number
+  stickyGroup: FlatListItem | null
+  isMouseOver: boolean
+}
+
+// 滚动和焦点相关的 action 类型
+export type ScrollAction =
+  | { type: 'SET_FOCUSED_ITEM_KEY'; payload: string }
+  | { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger }
+  | { type: 'SET_LAST_SCROLL_OFFSET'; payload: number }
+  | { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null }
+  | { type: 'SET_IS_MOUSE_OVER'; payload: boolean }
+  | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } }
+  | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } }
+  | { type: 'SEARCH_CHANGED'; payload: { searchText: string } }
+  | { type: 'UPDATE_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } }
+  | { type: 'INIT_SCROLL'; payload?: void }
diff --git a/src/renderer/src/hooks/usePinnedModels.ts b/src/renderer/src/hooks/usePinnedModels.ts
new file mode 100644
index 00000000000..74337872a40
--- /dev/null
+++ b/src/renderer/src/hooks/usePinnedModels.ts
@@ -0,0 +1,63 @@
+import db from '@renderer/databases'
+import { getModelUniqId } from '@renderer/services/ModelService'
+import { sortBy } from 'lodash'
+import { useCallback, useEffect, useState } from 'react'
+
+import { useProviders } from './useProvider'
+
+export const usePinnedModels = () => {
+  const [pinnedModels, setPinnedModels] = useState<string[]>([])
+  const [loading, setLoading] = useState(true)
+  const { providers } = useProviders()
+
+  useEffect(() => {
+    const loadPinnedModels = async () => {
+      setLoading(true)
+      const setting = await db.settings.get('pinned:models')
+      const savedPinnedModels = setting?.value || []
+
+      // Filter out invalid pinned models
+      const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
+      const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
+
+      // Update storage if there were invalid models
+      if (validPinnedModels.length !== savedPinnedModels.length) {
+        await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
+      }
+
+      setPinnedModels(sortBy(validPinnedModels))
+      setLoading(false)
+    }
+
+    loadPinnedModels().catch((error) => {
+      console.error('Failed to load pinned models', error)
+      setPinnedModels([])
+      setLoading(false)
+    })
+  }, [providers])
+
+  const updatePinnedModels = useCallback(async (models: string[]) => {
+    await db.settings.put({ id: 'pinned:models', value: models })
+    setPinnedModels(sortBy(models))
+  }, [])
+
+  /**
+   * Toggle a single pinned model
+   * @param modelId - The ID string of the model to toggle
+   */
+  const togglePinnedModel = useCallback(
+    async (modelId: string) => {
+      try {
+        const newPinnedModels = pinnedModels.includes(modelId)
+          ? pinnedModels.filter((id) => id !== modelId)
+          : [...pinnedModels, modelId]
+        await updatePinnedModels(newPinnedModels)
+      } catch (error) {
+        console.error('Failed to toggle pinned model', error)
+      }
+    },
+    [pinnedModels, updatePinnedModels]
+  )
+
+  return { pinnedModels, updatePinnedModels, togglePinnedModel, loading }
+}


Reviews:
user: login: sourcery-ai[bot]
body: Hey @alephpiece - I've reviewed your changes - here's some feedback:

- The search input now uses the deferred `searchText` for its `value`; consider using the non-deferred `_searchText` to ensure the input field updates immediately as the user types.
- The `SelectModelPopup.hide()` call within the `PopupContainer`'s `onClose` callback appears redundant, as `resolve(undefined)` should now also trigger hiding via its wrapper in `SelectModelPopup.show`.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 2 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T19:10:02Z
user: login: sourcery-ai[bot]
body: Hey @alephpiece - I've reviewed your changes - here's some feedback:

- The `resolve` callback now handles hiding `TopView`; the `SelectModelPopup.hide()` call in `onClose` might be redundant.
- The static property `SelectModelPopup.topviewId` appears unused and could potentially be removed.
- Consider renaming `SelectModelPopup/popup.tsx` to `SelectModelPopup/SelectModelPopup.tsx` to align the filename with the primary `SelectModelPopup` class it defines and exports.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T05:22:51Z
user: login: DeJeune
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-10T05:48:15Z

Review comments:
diff_hunk: @@ -0,0 +1,61 @@
+import db from '@renderer/databases'
+import { getModelUniqId } from '@renderer/services/ModelService'
+import { sortBy } from 'lodash'
+import { useCallback, useEffect, useState } from 'react'
+
+import { useProviders } from './useProvider'
+
+export const usePinnedModels = () => {
+  const [pinnedModels, setPinnedModels] = useState<string[]>([])
+  const { providers } = useProviders()
+
+  useEffect(() => {
+    const loadPinnedModels = async () => {
+      const setting = await db.settings.get('pinned:models')
+      const savedPinnedModels = setting?.value || []
+
+      // Filter out invalid pinned models
+      const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
+      const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
+
+      // Update storage if there were invalid models
path: src/renderer/src/hooks/usePinnedModels.ts
user: login: sourcery-ai[bot]
body: **issue (bug_risk):** Asynchronous errors in loadPinnedModels may not be caught by the try/catch.

Await the loadPinnedModels() call or chain a .catch() so rejections don’t bypass the try/catch.
created_at: 2025-05-09T19:10:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+import { ScrollAction, ScrollState } from './types'
+
+/**
+ * 初始状态
+ */
+export const initialScrollState: ScrollState = {
+  focusedItemKey: '',
+  scrollTrigger: 'initial',
+  lastScrollOffset: 0,
+  stickyGroup: null,
+  isMouseOver: false
+}
+
+/**
+ * 滚动状态的 reducer，用于避免复杂依赖可能带来的状态更新问题
+ * @param state 当前状态
+ * @param action 动作
+ * @returns 新的状态
+ */
+export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => {
+  switch (action.type) {
+    case 'SET_FOCUSED_ITEM':
+      return { ...state, focusedItemKey: action.payload }
+
+    case 'SET_SCROLL_TRIGGER':
+      return { ...state, scrollTrigger: action.payload }
+
+    case 'SET_LAST_SCROLL_OFFSET':
+      return { ...state, lastScrollOffset: action.payload }
+
+    case 'SET_STICKY_GROUP':
+      return { ...state, stickyGroup: action.payload }
+
+    case 'SET_IS_MOUSE_OVER':
+      return { ...state, isMouseOver: action.payload }
+
+    case 'FOCUS_NEXT_ITEM': {
+      const { modelItems, step } = action.payload
+      const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey)
path: src/renderer/src/components/Popups/SelectModelPopup/reducer.ts
user: login: sourcery-ai[bot]
body: **suggestion (bug_risk):** Review the index arithmetic in 'FOCUS_NEXT_ITEM' reducer case.

Guard against empty modelItems to avoid modulo-by-zero and validate step to prevent unexpected index calculations.

```suggestion
    case 'FOCUS_NEXT_ITEM': {
      const { modelItems, step } = action.payload

      if (!Array.isArray(modelItems) || modelItems.length === 0) {
        console.warn('FOCUS_NEXT_ITEM: modelItems is empty');
        return state;
      }

      if (typeof step !== 'number' || isNaN(step)) {
        console.warn('FOCUS_NEXT_ITEM: invalid step provided');
        return state;
      }

      const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey)
```
created_at: 2025-05-09T19:10:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-05-09T19:08:16Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide

This pull request refactors the `SelectModelPopup` component by centralizing scroll and focus state management using a new `useScrollState` hook with a reducer, and by extracting pinned model logic into a `usePinnedModels` hook. These changes replace multiple scattered state variables and effects. The component's files were also restructured into a dedicated directory, and popup dismissal behavior was made more robust.

#### Sequence Diagram: Search Text Input and Focus Update

```mermaid
sequenceDiagram
    actor User
    participant Input
    participant PopupContainer
    participant useScrollState
    participant scrollReducer
    participant FixedSizeList

    User->>Input: Types search text (_searchText)
    Input->>PopupContainer: setSearchText(newText)
    PopupContainer->>useScrollState: searchChanged(deferredSearchText)
    note right of PopupContainer: useEffect [searchText, searchChanged]
    useScrollState->>scrollReducer: dispatch({type: 'SEARCH_CHANGED', payload: {searchText}})
    activate scrollReducer
    scrollReducer-->>useScrollState: Returns new state (scrollTrigger='search' or 'initial')
    deactivate scrollReducer

    PopupContainer->>useScrollState: updateOnListChange(modelItems)
    note right of PopupContainer: useEffect [modelItems, updateOnListChange]
    useScrollState->>scrollReducer: dispatch({type: 'UPDATE_ON_LIST_CHANGE', payload: {modelItems}})
    activate scrollReducer
    scrollReducer-->>useScrollState: Returns new state (updated focusedItemKey)
    deactivate scrollReducer

    PopupContainer->>FixedSizeList: scrollToItem(indexOfNewFocusedItem, 'center')
    note right of PopupContainer: useEffect [focusedItemKey, scrollTrigger, listItems]
    PopupContainer->>useScrollState: setScrollTrigger('none')
    useScrollState->>scrollReducer: dispatch({type: 'SET_SCROLL_TRIGGER', payload: 'none'})
    activate scrollReducer
    scrollReducer-->>useScrollState: Returns new state (scrollTrigger='none')
    deactivate scrollReducer
```

#### Sequence Diagram: Keyboard Navigation (ArrowDown)

```mermaid
sequenceDiagram
    actor User
    participant PopupContainer
    participant useScrollState
    participant scrollReducer
    participant FixedSizeList

    User->>PopupContainer: Presses ArrowDown key
    note right of PopupContainer: handleKeyDown(event) called
    PopupContainer->>useScrollState: focusNextItem(modelItems, 1)
    useScrollState->>scrollReducer: dispatch({type: 'FOCUS_NEXT_ITEM', payload: {modelItems, step:1}})
    activate scrollReducer
    scrollReducer-->>useScrollState: Returns new state (updated focusedItemKey, scrollTrigger='keyboard')
    deactivate scrollReducer

    PopupContainer->>FixedSizeList: scrollToItem(indexOfNewFocusedItem, 'auto')
    note right of PopupContainer: useEffect [focusedItemKey, scrollTrigger, listItems]
    PopupContainer->>useScrollState: setScrollTrigger('none')
    useScrollState->>scrollReducer: dispatch({type: 'SET_SCROLL_TRIGGER', payload: 'none'})
    activate scrollReducer
    scrollReducer-->>useScrollState: Returns new state (scrollTrigger='none')
    deactivate scrollReducer
```

#### Class Diagram: SelectModelPopup Core Refactoring with Hooks

```mermaid
classDiagram
    PopupContainer --> useScrollState : uses
    PopupContainer --> usePinnedModels : uses

    class PopupContainer {
      +model?: Model
      +resolve(Model | undefined): void
      +handleItemClick(item: FlatListItem)
      +handleKeyDown(event: KeyboardEvent)
      +handleScroll(scrollOffset: number)
      +updateStickyGroup(scrollOffset?: number)
      #searchText: string
      #listItems: FlatListItem[]
      #modelItems: FlatListItem[]
    }

    class useScrollState {
      +focusedItemKey: string
      +scrollTrigger: ScrollTrigger
      +lastScrollOffset: number
      +stickyGroup: FlatListItem | null
      +isMouseOver: boolean
      +setFocusedItemKey(key: string): void
      +setScrollTrigger(trigger: ScrollTrigger): void
      +setLastScrollOffset(offset: number): void
      +setStickyGroup(group: FlatListItem | null): void
      +setIsMouseOver(isMouseOver: boolean): void
      +focusNextItem(modelItems: FlatListItem[], step: number): void
      +focusPage(modelItems: FlatListItem[], currentIndex: number, step: number): void
      +searchChanged(searchText: string): void
      +updateOnListChange(modelItems: FlatListItem[]): void
      +initScroll(): void
    }
    note for useScrollState "Manages scroll and focus state via a reducer."

    class usePinnedModels {
      +pinnedModels: string[]
      +loading: boolean
      +togglePinnedModel(modelId: string): Promise~void~
      +updatePinnedModels(models: string[]): Promise~void~
    }
    note for usePinnedModels "Manages pinned models, interacts with db.settings."

    class FlatListItem {
        +key: string
        +type: ListItemType
        +model?: Model
    }
    PopupContainer o-- FlatListItem : manipulates / uses
```

#### Class Diagram: SelectModelPopup Class API Changes

```mermaid
classDiagram
  class SelectModelPopup {
    <<static>> +show(params: PopupParams): Promise~Model | undefined~
    <<static>> +hide(): void
  }
  class PopupParams {
    +model?: Model
  }
  SelectModelPopup ..> PopupParams : uses
  note for SelectModelPopup "The show() method now internally handles hiding the popup on resolve."
```

#### State Diagram: ScrollTrigger State Transitions

```mermaid
stateDiagram-v2
    [*] --> initial : On Popup Open (initScroll)
    initial --> search : searchChanged(with text)
    search --> initial : searchChanged(empty text)

    initial --> keyboard : focusNextItem() / focusPage()
    search --> keyboard : focusNextItem() / focusPage()

    keyboard --> none : Scroll Performed (via setScrollTrigger('none'))

    initial --> none : togglePin() / Escape Key (via setScrollTrigger('none'))
    search --> none : togglePin() / Escape Key (via setScrollTrigger('none'))

    none --> initial : itemClick() / onCancel() / onClose() / initScroll()

    note left of initial
      Default state after initialization
      or when search is cleared.
    end note
    note right of search
      Active when user is typing
      in the search input.
    end note
    note right of keyboard
      Active during keyboard
      navigation (arrows, pageup/down).
    end note
    note right of none
      Temporarily set after a scroll
      or specific actions to prevent
      immediate re-scrolls.
      Usually transitions quickly to another state.
    end note
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Centralized state for scroll, focus, and pinned models via new custom hooks (`useScrollState` with a reducer, `usePinnedModels`). | <ul><li>Replaced multiple `useState`/`useRef`/`useEffect` instances with `useScrollState` for unified scroll/focus state management.</li><li>Encapsulated pinned model fetching, validation, and updates within the `usePinnedModels` hook.</li><li>Defined new state (`ScrollState`) and action (`ScrollAction`) types for scrolling in `types.ts`.</li><li>Implemented `scrollReducer` in `reducer.ts` to handle scroll state transitions based on dispatched actions.</li></ul> | `src/renderer/src/components/Popups/SelectModelPopup/popup.tsx`<br/>`src/renderer/src/components/Popups/SelectModelPopup/hook.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/reducer.ts`<br/>`src/renderer/src/hooks/usePinnedModels.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/types.ts` |
| Refined scrolling behavior and keyboard navigation logic by leveraging the new centralized state from `useScrollState`. | <ul><li>Updated scroll trigger conditions (e.g., 'search', 'initial', 'keyboard') managed by the reducer.</li><li>Adjusted focus targeting logic: focus first item on search, selected item when no search, handled via `UPDATE_ON_LIST_CHANGE` action.</li><li>Keyboard navigation (arrow keys, page up/down, enter) now dispatches actions (`FOCUS_NEXT_ITEM`, `FOCUS_PAGE`) to `useScrollState`.</li></ul> | `src/renderer/src/components/Popups/SelectModelPopup/reducer.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/popup.tsx` |
| Reorganized component files by moving `SelectModelPopup` and its associated logic into a dedicated directory. | <ul><li>Moved main component logic from `SelectModelPopup.tsx` to `Popups/SelectModelPopup/popup.tsx`.</li><li>Co-located new hooks (`hook.ts`), reducer (`reducer.ts`), and types (`types.ts`) within the `Popups/SelectModelPopup/` directory.</li><li>Updated component export through `Popups/SelectModelPopup/index.ts`.</li></ul> | `src/renderer/src/components/Popups/SelectModelPopup/popup.tsx`<br/>`src/renderer/src/components/Popups/SelectModelPopup/index.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/hook.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/reducer.ts`<br/>`src/renderer/src/components/Popups/SelectModelPopup/types.ts` |
| Ensured consistent popup dismissal by explicitly calling `TopView.hide` when the `SelectModelPopup.show` promise resolves. | <ul><li>Added `TopView.hide(TopViewKey)` within the `resolve` callback in the static `SelectModelPopup.show` method.</li></ul> | `src/renderer/src/components/Popups/SelectModelPopup/popup.tsx` |

### Possibly linked issues

- **#0**: PR refactors SelectModelPopup state to fix scrolling and focus issues reported in the issue.

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: alephpiece
created_at: 2025-05-10T05:24:50Z
body: @DeJeune 

之前启动时没有正常滚动有一些情况是因为 pinned models 异步加载没有完成就滚动了，结果加载后这个滚动位置就错了
还有搜索的时候没有聚焦、清空搜索的时候聚焦项和滚动位置不同步，我在这个pr里都尝试修复了一下
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-10T05:37:53Z
body: 我测试一下
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-10T05:47:15Z
body: 没出现之前的问题了
author_association: COLLABORATOR