Repository: CherryHQ/cherry-studio
Pull request #3302: feat: Highlighted search in chat page
Author: login: icinggslits
Labels: name: UI/UX
Created: 2025-03-13T17:17:17Z | Merged: 2025-05-17T13:14:04Z

- Resolve #1891 
- Resolve #4961 

![](https://github.com/user-attachments/assets/6cd3d049-964a-4afd-a6e5-34ab38ad18fd)

现在还没有添加快捷键，我想把默认占据`ctrl+f`的`search_message`迁移替换了，让给这个正文搜索，请拍板定案。

不管如何处理，希望先合并该分支。

## Summary by Sourcery

Add in-chat text search with result highlighting and filtering options.

New Features:
- Implement text search within the active chat conversation.
- Highlight all occurrences of the search term within messages.
- Provide an option to toggle the inclusion of user messages in search results.

Enhancements:
- Introduce a reusable `ContentSearch` component for the search UI and logic.
- Ensure search results update automatically when new messages arrive or content changes.

Chores:
- Add CSS styles for highlighting search terms and the currently selected match.
- Update keyboard shortcuts: Assign `Cmd/Ctrl+F` to the new in-chat search and reassign the previous search function (`search_message`) to `Cmd/Ctrl+Shift+F`. Includes store migration. Includes store migration.


Diff:
diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss
index 3b5d98e941d..e44a973fe80 100644
--- a/src/renderer/src/assets/styles/index.scss
+++ b/src/renderer/src/assets/styles/index.scss
@@ -53,6 +53,10 @@
 
   --modal-background: #1f1f1f;
 
+  --color-highlight: rgba(0, 0, 0, 1);
+  --color-background-highlight: rgba(255, 255, 0, 0.9);
+  --color-background-highlight-accent: rgba(255, 150, 50, 0.9);
+
   --navbar-background-mac: rgba(20, 20, 20, 0.55);
   --navbar-background: #1f1f1f;
 
@@ -127,6 +131,10 @@ body[theme-mode='light'] {
 
   --modal-background: var(--color-white);
 
+  --color-highlight: initial;
+  --color-background-highlight: rgba(255, 255, 0, 0.5);
+  --color-background-highlight-accent: rgba(255, 150, 50, 0.5);
+
   --navbar-background-mac: rgba(255, 255, 255, 0.55);
   --navbar-background: rgba(244, 244, 244);
 
@@ -291,3 +299,11 @@ body,
 .lucide {
   color: var(--color-icon);
 }
+
+span.highlight {
+  background-color: var(--color-background-highlight);
+  color: var(--color-highlight);
+}
+span.highlight.selected {
+  background-color: var(--color-background-highlight-accent);
+}
diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx
new file mode 100644
index 00000000000..1f207ce9db7
--- /dev/null
+++ b/src/renderer/src/components/ContentSearch.tsx
@@ -0,0 +1,710 @@
+import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
+import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
+import { Tooltip } from 'antd'
+import { debounce } from 'lodash'
+import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
+import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+const HIGHLIGHT_CLASS = 'highlight'
+const HIGHLIGHT_SELECT_CLASS = 'selected'
+
+interface Props {
+  children?: React.ReactNode
+  searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
+  /**
+   * 过滤`node`，`node`只会是`Node.TEXT_NODE`类型的文本节点
+   *
+   * 返回`true`表示该`node`会被搜索
+   */
+  filter: (node: Node) => boolean
+  includeUser?: boolean
+  onIncludeUserChange?: (value: boolean) => void
+}
+
+enum SearchCompletedState {
+  NotSearched,
+  FirstSearched
+}
+
+enum SearchTargetIndex {
+  Next,
+  Prev
+}
+
+export interface ContentSearchRef {
+  disable(): void
+  enable(initialText?: string): void
+  // 搜索下一个并定位
+  searchNext(): void
+  // 搜索上一个并定位
+  searchPrev(): void
+  // 搜索并定位
+  search(): void
+  // 搜索但不定位，或者说是更新
+  silentSearch(): void
+  focus(): void
+}
+
+interface MatchInfo {
+  index: number
+  length: number
+  text: string
+}
+
+const escapeRegExp = (string: string): string => {
+  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
+}
+
+const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
+  if (!elementList || elementList.length === 0) {
+    return null
+  }
+  let closestElementIndex: number | null = null
+  let minVerticalDistance = Infinity
+  const windowCenterY = window.innerHeight / 2
+  for (let i = 0; i < elementList.length; i++) {
+    const element = elementList[i]
+    if (!(element instanceof HTMLElement)) {
+      continue
+    }
+    const rect = element.getBoundingClientRect()
+    if (rect.bottom < 0 || rect.top > window.innerHeight) {
+      continue
+    }
+    const elementCenterY = rect.top + rect.height / 2
+    const verticalDistance = Math.abs(elementCenterY - windowCenterY)
+    if (verticalDistance < minVerticalDistance) {
+      minVerticalDistance = verticalDistance
+      closestElementIndex = i
+    }
+  }
+  return closestElementIndex
+}
+
+const highlightText = (
+  textNode: Node,
+  searchText: string,
+  highlightClass: string,
+  isCaseSensitive: boolean,
+  isWholeWord: boolean
+): HTMLSpanElement[] | null => {
+  const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
+  if (textNodeParentNode) {
+    if (textNodeParentNode.classList.contains(highlightClass)) {
+      return null
+    }
+  }
+  if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
+    return null
+  }
+
+  const textContent = textNode.textContent
+  const escapedSearchText = escapeRegExp(searchText)
+
+  // 检查搜索文本是否仅包含拉丁字母
+  const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
+
+  // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
+  const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
+  const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
+  const regex = new RegExp(regexPattern, regexFlags)
+
+  let match
+  const matches: MatchInfo[] = []
+  while ((match = regex.exec(textContent)) !== null) {
+    if (typeof match.index === 'number' && typeof match[0] === 'string') {
+      matches.push({ index: match.index, length: match[0].length, text: match[0] })
+    } else {
+      console.error('Unexpected match format:', match)
+    }
+  }
+
+  if (matches.length === 0) {
+    return null
+  }
+
+  const parentNode = textNode.parentNode
+  if (!parentNode) {
+    return null
+  }
+
+  const fragment = document.createDocumentFragment()
+  let currentIndex = 0
+  const highlightTextSet = new Set<HTMLSpanElement>()
+
+  matches.forEach(({ index, length, text }) => {
+    if (index > currentIndex) {
+      fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
+    }
+    const highlightSpan = document.createElement('span')
+    highlightSpan.className = highlightClass
+    highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
+    fragment.appendChild(highlightSpan)
+    highlightTextSet.add(highlightSpan)
+    currentIndex = index + length
+  })
+
+  if (currentIndex < textContent.length) {
+    fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
+  }
+
+  parentNode.replaceChild(fragment, textNode)
+  return [...highlightTextSet]
+}
+
+const mergeAdjacentTextNodes = (node: HTMLElement) => {
+  const children = Array.from(node.childNodes)
+  const groups: Array<Node | { text: string; nodes: Node[] }> = []
+  let currentTextGroup: { text: string; nodes: Node[] } | null = null
+
+  for (const child of children) {
+    if (child.nodeType === Node.TEXT_NODE) {
+      if (currentTextGroup === null) {
+        currentTextGroup = {
+          text: child.textContent ?? '',
+          nodes: [child]
+        }
+      } else {
+        currentTextGroup.text += child.textContent
+        currentTextGroup.nodes.push(child)
+      }
+    } else {
+      if (currentTextGroup !== null) {
+        groups.push(currentTextGroup!)
+        currentTextGroup = null
+      }
+      groups.push(child)
+    }
+  }
+
+  if (currentTextGroup !== null) {
+    groups.push(currentTextGroup)
+  }
+
+  const newChildren = groups.map((group) => {
+    if (group instanceof Node) {
+      return group
+    } else {
+      return document.createTextNode(group.text)
+    }
+  })
+
+  node.replaceChildren(...newChildren)
+}
+
+// eslint-disable-next-line @eslint-react/no-forward-ref
+export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
+  ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
+    const target: HTMLElement | null = (() => {
+      if (searchTarget instanceof HTMLElement) {
+        return searchTarget
+      } else {
+        return (searchTarget.current as HTMLElement) ?? null
+      }
+    })()
+    const containerRef = React.useRef<HTMLDivElement>(null)
+    const searchInputRef = React.useRef<HTMLInputElement>(null)
+    const [searchResultIndex, setSearchResultIndex] = useState(0)
+    const [totalCount, setTotalCount] = useState(0)
+    const [enableContentSearch, setEnableContentSearch] = useState(false)
+    const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
+    const [isCaseSensitive, setIsCaseSensitive] = useState(false)
+    const [isWholeWord, setIsWholeWord] = useState(false)
+    const [shouldScroll, setShouldScroll] = useState(false)
+    const highlightTextSet = useState(new Set<Node>())[0]
+    const prevSearchText = useRef('')
+    const { t } = useTranslation()
+
+    const locateByIndex = (index: number, shouldScroll = true) => {
+      if (target) {
+        const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
+        highlightTextNodes.sort((a, b) => {
+          const { top: aTop } = a.getBoundingClientRect()
+          const { top: bTop } = b.getBoundingClientRect()
+          return aTop - bTop
+        })
+        for (const node of highlightTextNodes) {
+          node.classList.remove(HIGHLIGHT_SELECT_CLASS)
+        }
+        setSearchResultIndex(index)
+        if (highlightTextNodes.length > 0) {
+          const highlightTextNode = highlightTextNodes[index] ?? null
+          if (highlightTextNode) {
+            highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
+            if (shouldScroll) {
+              highlightTextNode.scrollIntoView({
+                behavior: 'smooth',
+                block: 'center',
+                inline: 'center'
+              })
+            }
+          }
+        }
+      }
+    }
+
+    const restoreHighlight = () => {
+      const highlightTextParentNodeSet = new Set<HTMLElement>()
+      // Make a copy because the set might be modified during iteration indirectly
+      const nodesToRestore = [...highlightTextSet]
+      for (const highlightTextNode of nodesToRestore) {
+        if (highlightTextNode.textContent) {
+          const textNode = document.createTextNode(highlightTextNode.textContent)
+          const node = highlightTextNode as HTMLElement
+          if (node.parentNode) {
+            highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
+            node.replaceWith(textNode) // This removes the node from the DOM
+          }
+        }
+      }
+      highlightTextSet.clear() // Clear the original set after processing
+      for (const parentNode of highlightTextParentNodeSet) {
+        mergeAdjacentTextNodes(parentNode)
+      }
+      // highlightTextSet.clear() // Already cleared
+    }
+
+    const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
+      const searchText = searchInputRef.current?.value.trim() ?? null
+      if (target && searchText !== null && searchText !== '') {
+        restoreHighlight()
+        const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
+        let textNode: Node | null
+        const textNodeSet: Set<Node> = new Set()
+        while ((textNode = iter.nextNode())) {
+          if (filter(textNode)) {
+            textNodeSet.add(textNode)
+          }
+        }
+
+        const highlightTextSetTemp = new Set<HTMLSpanElement>()
+        for (const node of textNodeSet) {
+          const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
+          if (list) {
+            list.forEach((node) => highlightTextSetTemp.add(node))
+          }
+        }
+        const highlightTextList = [...highlightTextSetTemp]
+        setTotalCount(highlightTextList.length)
+        highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
+        const changeIndex = () => {
+          let index: number
+          switch (searchTargetIndex) {
+            case SearchTargetIndex.Next:
+              {
+                index = (searchResultIndex + 1) % highlightTextList.length
+              }
+              break
+            case SearchTargetIndex.Prev:
+              {
+                index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
+              }
+              break
+            default: {
+              index = searchResultIndex
+            }
+          }
+          return Math.max(index, 0)
+        }
+
+        const targetIndex = (() => {
+          switch (searchCompleted) {
+            case SearchCompletedState.NotSearched: {
+              setSearchCompleted(SearchCompletedState.FirstSearched)
+              const index = findWindowVerticalCenterElementIndex(highlightTextList)
+              if (index !== null) {
+                setSearchResultIndex(index)
+                return index
+              } else {
+                setSearchResultIndex(0)
+                return 0
+              }
+            }
+            case SearchCompletedState.FirstSearched: {
+              return changeIndex()
+            }
+            default: {
+              return null
+            }
+          }
+        })()
+
+        if (targetIndex === null) {
+          return null
+        } else {
+          const totalCount = highlightTextSet.size
+          if (targetIndex >= totalCount) {
+            return totalCount - 1
+          } else {
+            return targetIndex
+          }
+        }
+      } else {
+        return null
+      }
+    }
+
+    const _searchHandlerDebounce = debounce(() => {
+      implementation.search()
+    }, 300)
+    const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
+    const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
+      const value = event.target.value.trim()
+      if (value.length === 0) {
+        restoreHighlight()
+        setTotalCount(0)
+        setSearchResultIndex(0)
+        setSearchCompleted(SearchCompletedState.NotSearched)
+      } else {
+        // 用户输入时允许滚动
+        setShouldScroll(true)
+        searchHandler()
+      }
+      prevSearchText.current = value
+    }
+
+    const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
+      const { code, key, shiftKey } = event
+      if (key === 'Process') {
+        return
+      }
+
+      switch (code) {
+        case 'Enter':
+          {
+            if (shiftKey) {
+              implementation.searchPrev()
+            } else {
+              implementation.searchNext()
+            }
+            event.preventDefault()
+          }
+          break
+        case 'Escape':
+          {
+            implementation.disable()
+          }
+          break
+      }
+    }
+
+    const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
+
+    const userOutlinedButtonOnClick = () => {
+      if (onIncludeUserChange) {
+        onIncludeUserChange(!includeUser)
+      }
+      searchInputFocus()
+    }
+
+    const implementation = {
+      disable() {
+        setEnableContentSearch(false)
+        restoreHighlight()
+        setShouldScroll(false)
+      },
+      enable(initialText?: string) {
+        setEnableContentSearch(true)
+        setShouldScroll(false) // Default to false, search itself might set it to true
+        if (searchInputRef.current) {
+          const inputEl = searchInputRef.current
+          if (initialText && initialText.trim().length > 0) {
+            inputEl.value = initialText
+            // Trigger search after setting initial text
+            // Need to make sure search() uses the new value
+            // and also to focus and select
+            requestAnimationFrame(() => {
+              inputEl.focus()
+              inputEl.select()
+              setShouldScroll(true)
+              const targetIndex = search()
+              if (targetIndex !== null) {
+                locateByIndex(targetIndex, true) // Ensure scrolling
+              } else {
+                // If search returns null (e.g., empty input or no matches with initial text), clear state
+                restoreHighlight()
+                setTotalCount(0)
+                setSearchResultIndex(0)
+                setSearchCompleted(SearchCompletedState.NotSearched)
+              }
+            })
+          } else {
+            requestAnimationFrame(() => {
+              inputEl.focus()
+              inputEl.select()
+            })
+            // Only search if there's existing text and no new initialText
+            if (inputEl.value.trim()) {
+              const targetIndex = search()
+              if (targetIndex !== null) {
+                setSearchResultIndex(targetIndex)
+                // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
+              }
+            }
+          }
+        }
+      },
+      searchNext() {
+        if (enableContentSearch) {
+          const targetIndex = search(SearchTargetIndex.Next)
+          if (targetIndex !== null) {
+            locateByIndex(targetIndex)
+          }
+        }
+      },
+      searchPrev() {
+        if (enableContentSearch) {
+          const targetIndex = search(SearchTargetIndex.Prev)
+          if (targetIndex !== null) {
+            locateByIndex(targetIndex)
+          }
+        }
+      },
+      resetSearchState() {
+        if (enableContentSearch) {
+          setSearchCompleted(SearchCompletedState.NotSearched)
+          // Maybe also reset index? Depends on desired behavior
+          // setSearchResultIndex(0);
+        }
+      },
+      search() {
+        if (enableContentSearch) {
+          const targetIndex = search()
+          if (targetIndex !== null) {
+            locateByIndex(targetIndex, shouldScroll)
+          } else {
+            // If search returns null (e.g., empty input), clear state
+            restoreHighlight()
+            setTotalCount(0)
+            setSearchResultIndex(0)
+            setSearchCompleted(SearchCompletedState.NotSearched)
+          }
+        }
+      },
+      silentSearch() {
+        if (enableContentSearch) {
+          const targetIndex = search()
+          if (targetIndex !== null) {
+            // 只更新索引，不触发滚动
+            locateByIndex(targetIndex, false)
+          }
+        }
+      },
+      focus() {
+        searchInputFocus()
+      }
+    }
+
+    useImperativeHandle(ref, () => ({
+      disable() {
+        implementation.disable()
+      },
+      enable(initialText?: string) {
+        implementation.enable(initialText)
+      },
+      searchNext() {
+        implementation.searchNext()
+      },
+      searchPrev() {
+        implementation.searchPrev()
+      },
+      search() {
+        implementation.search()
+      },
+      silentSearch() {
+        implementation.silentSearch()
+      },
+      focus() {
+        implementation.focus()
+      }
+    }))
+
+    // Re-run search when options change and search is active
+    useEffect(() => {
+      if (enableContentSearch && searchInputRef.current?.value.trim()) {
+        implementation.search()
+      }
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency
+
+    const prevButtonOnClick = () => {
+      implementation.searchPrev()
+      searchInputFocus()
+    }
+
+    const nextButtonOnClick = () => {
+      implementation.searchNext()
+      searchInputFocus()
+    }
+
+    const closeButtonOnClick = () => {
+      implementation.disable()
+    }
+
+    const caseSensitiveButtonOnClick = () => {
+      setIsCaseSensitive(!isCaseSensitive)
+      searchInputFocus()
+    }
+
+    const wholeWordButtonOnClick = () => {
+      setIsWholeWord(!isWholeWord)
+      searchInputFocus()
+    }
+
+    return (
+      <Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
+        <NarrowLayout style={{ width: '100%' }}>
+          <SearchBarContainer>
+            <InputWrapper>
+              <Input ref={searchInputRef} onInput={userInputHandler} onKeyDown={keyDownHandler} />
+              <ToolBar>
+                <Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
+                  <ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
+                    <User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
+                  </ToolbarButton>
+                </Tooltip>
+                <Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
+                  <ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
+                    <CaseSensitive
+                      size={18}
+                      style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
+                    />
+                  </ToolbarButton>
+                </Tooltip>
+                <Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
+                  <ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
+                    <WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
+                  </ToolbarButton>
+                </Tooltip>
+              </ToolBar>
+            </InputWrapper>
+            <Separator></Separator>
+            <SearchResults>
+              {searchCompleted !== SearchCompletedState.NotSearched ? (
+                totalCount > 0 ? (
+                  <>
+                    <SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
+                    <SearchResultSeparator>/</SearchResultSeparator>
+                    <SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
+                  </>
+                ) : (
+                  <NoResults>{t('common.no_results')}</NoResults>
+                )
+              ) : (
+                <SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
+              )}
+            </SearchResults>
+            <ToolBar>
+              <ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
+                <ChevronUp size={18} />
+              </ToolbarButton>
+              <ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
+                <ChevronDown size={18} />
+              </ToolbarButton>
+              <ToolbarButton type="text" onClick={closeButtonOnClick}>
+                <X size={18} />
+              </ToolbarButton>
+            </ToolBar>
+          </SearchBarContainer>
+        </NarrowLayout>
+        <Placeholder />
+      </Container>
+    )
+  }
+)
+
+ContentSearch.displayName = 'ContentSearch'
+
+const Container = styled.div`
+  display: flex;
+  flex-direction: row;
+  z-index: 2;
+`
+
+const SearchBarContainer = styled.div`
+  border: 1px solid var(--color-border);
+  border-radius: 10px;
+  transition: all 0.2s ease;
+  position: relative;
+  margin: 5px 20px;
+  margin-bottom: 0;
+  padding: 6px 15px 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: var(--color-background-opacity);
+  flex: 1 1 auto; /* Take up input's previous space */
+`
+
+const Placeholder = styled.div`
+  width: 5px;
+`
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  flex: 1 1 auto; /* Take up input's previous space */
+`
+
+const Input = styled.input`
+  border: none;
+  color: var(--color-text);
+  background-color: transparent;
+  outline: none;
+  width: 100%;
+  padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
+  flex: 1; /* Allow input to grow */
+  font-size: 14px;
+  font-family: Ubuntu;
+`
+
+const ToolBar = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: tpx;
+`
+
+const Separator = styled.div`
+  width: 1px;
+  height: 1.5em;
+  background-color: var(--color-border);
+  margin-left: 2px;
+  margin-right: 2px;
+  flex: 0 0 auto;
+`
+
+const SearchResults = styled.div`
+  display: flex;
+  justify-content: center;
+  width: 80px;
+  margin: 0 2px;
+  flex: 0 0 auto;
+  color: var(--color-text-secondary);
+  font-size: 14px;
+  font-family: Ubuntu;
+`
+
+const SearchResultsPlaceholder = styled.span`
+  color: var(--color-text-secondary);
+  opacity: 0.5;
+`
+
+const NoResults = styled.span`
+  color: var(--color-text-secondary);
+`
+
+const SearchResultCount = styled.span`
+  color: var(--color-text);
+`
+
+const SearchResultSeparator = styled.span`
+  color: var(--color-text);
+  margin: 0 4px;
+`
+
+const SearchResultTotalCount = styled.span`
+  color: var(--color-text);
+`
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index 94ee6c07a94..8d50aae6435 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -131,7 +131,10 @@
       "manage": "Manage",
       "select_model": "Select Model",
       "show.all": "Show All",
-      "update_available": "Update Available"
+      "update_available": "Update Available",
+      "includes_user_questions": "Include Your Questions",
+      "case_sensitive": "Case Sensitive",
+      "whole_word": "Whole Word"
     },
     "chat": {
       "add.assistant.title": "Add Assistant",
@@ -396,7 +399,8 @@
         "pinyin": "Sort by Pinyin",
         "pinyin.asc": "Sort by Pinyin (A-Z)",
         "pinyin.desc": "Sort by Pinyin (Z-A)"
-      }
+      },
+      "no_results": "No results"
     },
     "docs": {
       "title": "Docs"
@@ -1551,6 +1555,7 @@
         "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
         "reset_to_default": "Reset to Default",
         "search_message": "Search Message",
+        "search_message_in_chat": "Search Message in Current Chat",
         "show_app": "Show/Hide App",
         "show_settings": "Open Settings",
         "title": "Keyboard Shortcuts",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index fef52b7d58d..7b2f2f4a50a 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -131,7 +131,10 @@
       "manage": "管理",
       "select_model": "モデルを選択",
       "show.all": "すべて表示",
-      "update_available": "更新可能"
+      "update_available": "更新可能",
+      "includes_user_questions": "ユーザーからの質問を含む",
+      "case_sensitive": "大文字と小文字の区別",
+      "whole_word": "全語一致"
     },
     "chat": {
       "add.assistant.title": "アシスタントを追加",
@@ -396,7 +399,8 @@
         "pinyin": "ピンインでソート",
         "pinyin.asc": "ピンインで昇順ソート",
         "pinyin.desc": "ピンインで降順ソート"
-      }
+      },
+      "no_results": "検索結果なし"
     },
     "docs": {
       "title": "ドキュメント"
@@ -1547,6 +1551,7 @@
         "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか？",
         "reset_to_default": "デフォルトにリセット",
         "search_message": "メッセージを検索",
+        "search_message_in_chat": "現在のチャットでメッセージを検索",
         "show_app": "アプリを表示/非表示",
         "show_settings": "設定を開く",
         "title": "ショートカット",
@@ -1641,7 +1646,9 @@
         "title": "ページズーム",
         "reset": "リセット"
       },
-      "input.show_translate_confirm": "翻訳確認ダイアログを表示"
+      "input.show_translate_confirm": "翻訳確認ダイアログを表示",
+      "about.debug.title": "デバッグ",
+      "about.debug.open": "開く"
     },
     "translate": {
       "any.language": "任意の言語",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index bda4157737c..8e1d629b49c 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -131,7 +131,10 @@
       "manage": "Редактировать",
       "select_model": "Выбрать модель",
       "show.all": "Показать все",
-      "update_available": "Доступно обновление"
+      "update_available": "Доступно обновление",
+      "includes_user_questions": "Включает вопросы пользователей",
+      "case_sensitive": "Чувствительность к регистру",
+      "whole_word": "Полное слово"
     },
     "chat": {
       "add.assistant.title": "Добавить ассистента",
@@ -396,7 +399,8 @@
         "pinyin": "Сортировать по пиньинь",
         "pinyin.asc": "Сортировать по пиньинь (А-Я)",
         "pinyin.desc": "Сортировать по пиньинь (Я-А)"
-      }
+      },
+      "no_results": "Результатов не найдено"
     },
     "docs": {
       "title": "Документация"
@@ -1547,6 +1551,7 @@
         "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
         "reset_to_default": "Сбросить настройки по умолчанию",
         "search_message": "Поиск сообщения",
+        "search_message_in_chat": "Поиск сообщения в текущем диалоге",
         "show_app": "Показать/скрыть приложение",
         "show_settings": "Открыть настройки",
         "title": "Горячие клавиши",
@@ -1641,7 +1646,9 @@
         "title": "Масштаб страницы",
         "reset": "Сбросить"
       },
-      "input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода"
+      "input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода",
+      "about.debug.title": "Отладка",
+      "about.debug.open": "Открыть"
     },
     "translate": {
       "any.language": "Любой язык",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 6808ff9b8ac..ad8c8af3da8 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -131,7 +131,10 @@
       "manage": "管理",
       "select_model": "选择模型",
       "show.all": "显示全部",
-      "update_available": "有可用更新"
+      "update_available": "有可用更新",
+      "includes_user_questions": "包含用户提问",
+      "case_sensitive": "区分大小写",
+      "whole_word": "全字匹配"
     },
     "chat": {
       "add.assistant.title": "添加助手",
@@ -396,7 +399,8 @@
         "pinyin": "按拼音排序",
         "pinyin.asc": "按拼音升序",
         "pinyin.desc": "按拼音降序"
-      }
+      },
+      "no_results": "无结果"
     },
     "docs": {
       "title": "帮助文档"
@@ -752,13 +756,13 @@
       "type": {
         "embedding": "嵌入",
         "free": "免费",
-        "function_calling": "工具",
         "reasoning": "推理",
         "rerank": "重排",
         "select": "选择模型类型",
         "text": "文本",
-        "vision": "视觉",
-        "websearch": "联网"
+        "vision": "图像",
+        "function_calling": "函数调用",
+        "websearch": "[to be translated]:WebSearch"
       }
     },
     "navbar": {
@@ -1551,6 +1555,7 @@
         "reset_defaults_confirm": "确定要重置所有快捷键吗？",
         "reset_to_default": "重置为默认",
         "search_message": "搜索消息",
+        "search_message_in_chat": "在当前对话中搜索消息",
         "show_app": "显示/隐藏应用",
         "show_settings": "打开设置",
         "title": "快捷方式",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 02a18f08f50..2295baf4513 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -131,7 +131,10 @@
       "manage": "管理",
       "select_model": "選擇模型",
       "show.all": "顯示全部",
-      "update_available": "有可用更新"
+      "update_available": "有可用更新",
+      "includes_user_questions": "包含使用者提問",
+      "case_sensitive": "區分大小寫",
+      "whole_word": "全字匹配"
     },
     "chat": {
       "add.assistant.title": "新增助手",
@@ -396,7 +399,8 @@
         "pinyin": "按拼音排序",
         "pinyin.asc": "按拼音升序",
         "pinyin.desc": "按拼音降序"
-      }
+      },
+      "no_results": "沒有結果"
     },
     "docs": {
       "title": "說明文件"
@@ -1549,6 +1553,7 @@
         "reset_defaults_confirm": "確定要重設所有快捷鍵嗎？",
         "reset_to_default": "重設為預設",
         "search_message": "搜尋訊息",
+        "search_message_in_chat": "在當前對話中搜尋訊息",
         "show_app": "顯示/隱藏應用程式",
         "show_settings": "開啟設定",
         "title": "快速方式",
@@ -1557,7 +1562,8 @@
         "toggle_show_topics": "切換話題顯示",
         "zoom_in": "放大介面",
         "zoom_out": "縮小介面",
-        "zoom_reset": "重設縮放"
+        "zoom_reset": "重設縮放",
+        "exit_fullscreen": "退出全螢幕"
       },
       "theme.auto": "自動",
       "theme.dark": "深色",
diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx
index 07d89dedcbd..85166923097 100644
--- a/src/renderer/src/pages/home/Chat.tsx
+++ b/src/renderer/src/pages/home/Chat.tsx
@@ -1,10 +1,14 @@
+import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
 import { QuickPanelProvider } from '@renderer/components/QuickPanel'
 import { useAssistant } from '@renderer/hooks/useAssistant'
 import { useSettings } from '@renderer/hooks/useSettings'
+import { useShortcut } from '@renderer/hooks/useShortcuts'
 import { useShowTopics } from '@renderer/hooks/useStore'
 import { Assistant, Topic } from '@renderer/types'
 import { Flex } from 'antd'
-import { FC } from 'react'
+import { debounce } from 'lodash'
+import React, { FC, useMemo, useState } from 'react'
+import { useHotkeys } from 'react-hotkeys-hook'
 import styled from 'styled-components'
 
 import Inputbar from './Inputbar/Inputbar'
@@ -20,18 +24,103 @@ interface Props {
 
 const Chat: FC<Props> = (props) => {
   const { assistant } = useAssistant(props.assistant.id)
-  const { topicPosition, messageStyle } = useSettings()
+  const { topicPosition, messageStyle, showAssistants } = useSettings()
   const { showTopics } = useShowTopics()
+  const mainRef = React.useRef<HTMLDivElement>(null)
+  const contentSearchRef = React.useRef<ContentSearchRef>(null)
+  const [filterIncludeUser, setFilterIncludeUser] = useState(false)
+
+  const maxWidth = useMemo(() => {
+    const showRightTopics = showTopics && topicPosition === 'right'
+    const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
+    const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
+    return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)`
+  }, [showAssistants, showTopics, topicPosition])
+
+  useHotkeys('esc', () => {
+    contentSearchRef.current?.disable()
+  })
+
+  useShortcut('search_message_in_chat', () => {
+    try {
+      const selectedText = window.getSelection()?.toString().trim()
+      contentSearchRef.current?.enable(selectedText)
+    } catch (error) {
+      console.error('Error enabling content search:', error)
+    }
+  })
+
+  const contentSearchFilter = (node: Node): boolean => {
+    if (node.parentNode) {
+      let parentNode: HTMLElement | null = node.parentNode as HTMLElement
+      while (parentNode?.parentNode) {
+        if (parentNode.classList.contains('MessageFooter')) {
+          return false
+        }
+
+        if (filterIncludeUser) {
+          if (parentNode?.classList.contains('message-content-container')) {
+            return true
+          }
+        } else {
+          if (parentNode?.classList.contains('message-content-container-assistant')) {
+            return true
+          }
+        }
+        parentNode = parentNode.parentNode as HTMLElement
+      }
+      return false
+    } else {
+      return false
+    }
+  }
+
+  const userOutlinedItemClickHandler = () => {
+    setFilterIncludeUser(!filterIncludeUser)
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        setTimeout(() => {
+          contentSearchRef.current?.search()
+          contentSearchRef.current?.focus()
+        }, 0)
+      })
+    })
+  }
+
+  let firstUpdateCompleted = false
+  const firstUpdateOrNoFirstUpdateHandler = debounce(() => {
+    contentSearchRef.current?.silentSearch()
+  }, 10)
+  const messagesComponentUpdateHandler = () => {
+    if (firstUpdateCompleted) {
+      firstUpdateOrNoFirstUpdateHandler()
+    }
+  }
+  const messagesComponentFirstUpdateHandler = () => {
+    setTimeout(() => (firstUpdateCompleted = true), 300)
+    firstUpdateOrNoFirstUpdateHandler()
+  }
 
   return (
     <Container id="chat" className={messageStyle}>
-      <Main id="chat-main" vertical flex={1} justify="space-between">
-        <Messages
-          key={props.activeTopic.id}
-          assistant={assistant}
-          topic={props.activeTopic}
-          setActiveTopic={props.setActiveTopic}
+      <Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
+        <ContentSearch
+          ref={contentSearchRef}
+          searchTarget={mainRef as React.RefObject<HTMLElement>}
+          filter={contentSearchFilter}
+          includeUser={filterIncludeUser}
+          onIncludeUserChange={userOutlinedItemClickHandler}
         />
+        <MessagesContainer>
+          <Messages
+            key={props.activeTopic.id}
+            assistant={assistant}
+            topic={props.activeTopic}
+            setActiveTopic={props.setActiveTopic}
+            onComponentUpdate={messagesComponentUpdateHandler}
+            onFirstUpdate={messagesComponentFirstUpdateHandler}
+          />
+        </MessagesContainer>
         <QuickPanelProvider>
           <Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
         </QuickPanelProvider>
@@ -49,18 +138,25 @@ const Chat: FC<Props> = (props) => {
   )
 }
 
+const MessagesContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  flex: 1;
+`
+
 const Container = styled.div`
   display: flex;
   flex-direction: row;
   height: 100%;
   flex: 1;
-  justify-content: space-between;
 `
 
 const Main = styled(Flex)`
   height: calc(100vh - var(--navbar-height));
   // 设置为containing block，方便子元素fixed定位
   transform: translateZ(0);
+  position: relative;
 `
 
 export default Chat
diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
index 39621cc85d9..7affffbff3f 100644
--- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
+++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
@@ -1097,7 +1097,7 @@ const ToolbarMenu = styled.div`
   gap: 6px;
 `
 
-const ToolbarButton = styled(Button)`
+export const ToolbarButton = styled(Button)`
   width: 30px;
   height: 30px;
   font-size: 16px;
diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx
index 9ae2da00b24..86d5d22c2bb 100644
--- a/src/renderer/src/pages/home/Messages/Message.tsx
+++ b/src/renderer/src/pages/home/Messages/Message.tsx
@@ -10,7 +10,7 @@ import { Assistant, Topic } from '@renderer/types'
 import type { Message } from '@renderer/types/newMessage'
 import { classNames } from '@renderer/utils'
 import { Divider } from 'antd'
-import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'
+import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import styled from 'styled-components'
 
@@ -106,13 +106,20 @@ const MessageItem: FC<Props> = ({
       <ContextMenu>
         <MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
         <MessageContentContainer
-          className="message-content-container"
+          className={
+            message.role === 'user'
+              ? 'message-content-container message-content-container-user'
+              : message.role === 'assistant'
+                ? 'message-content-container message-content-container-assistant'
+                : 'message-content-container'
+          }
           style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
           <MessageErrorBoundary>
             <MessageContent message={message} />
           </MessageErrorBoundary>
           {showMenubar && (
             <MessageFooter
+              className="MessageFooter"
               style={{
                 border: messageBorder,
                 flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx
index 3d95dc609b4..678e848e22d 100644
--- a/src/renderer/src/pages/home/Messages/Messages.tsx
+++ b/src/renderer/src/pages/home/Messages/Messages.tsx
@@ -26,7 +26,7 @@ import { updateCodeBlock } from '@renderer/utils/markdown'
 import { getMainTextContent } from '@renderer/utils/messageUtils/find'
 import { isTextLikeBlock } from '@renderer/utils/messageUtils/is'
 import { last } from 'lodash'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import InfiniteScroll from 'react-infinite-scroll-component'
 import styled from 'styled-components'
@@ -41,9 +41,11 @@ interface MessagesProps {
   assistant: Assistant
   topic: Topic
   setActiveTopic: (topic: Topic) => void
+  onComponentUpdate?(): void
+  onFirstUpdate?(): void
 }
 
-const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
+const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => {
   const { t } = useTranslation()
   const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
   const { updateTopic, addTopic } = useAssistant(assistant.id)
@@ -224,8 +226,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
         tokensCount: await estimateHistoryTokens(assistant, messages),
         contextCount: getContextCount(assistant, messages)
       })
-    })
-  }, [assistant, messages])
+    }).then(() => onFirstUpdate?.())
+  }, [assistant, messages, onFirstUpdate])
 
   const loadMoreMessages = useCallback(() => {
     if (!hasMore || isLoadingMore) return
@@ -249,6 +251,10 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
     }
   })
 
+  useEffect(() => {
+    requestAnimationFrame(() => onComponentUpdate?.())
+  }, [])
+
   const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
   return (
     <Container
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index afa1a1da011..d02e0d5e712 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
   {
     key: 'cherry-studio',
     storage,
-    version: 102,
+    version: 103,
     blacklist: ['runtime', 'messages', 'messageBlocks'],
     migrate
   },
diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts
index 5ae2a875060..a3e73692e4a 100644
--- a/src/renderer/src/store/migrate.ts
+++ b/src/renderer/src/store/migrate.ts
@@ -1377,6 +1377,34 @@ const migrateConfig = {
     } catch (error) {
       return state
     }
+  },
+  '103': (state: RootState) => {
+    try {
+      if (state.shortcuts) {
+        if (!state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message_in_chat')) {
+          state.shortcuts.shortcuts.push({
+            key: 'search_message_in_chat',
+            shortcut: [isMac ? 'Command' : 'Ctrl', 'F'],
+            editable: true,
+            enabled: true,
+            system: false
+          })
+        }
+        const searchMessageShortcut = state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message')
+        const targetShortcut = [isMac ? 'Command' : 'Ctrl', 'F']
+        if (
+          searchMessageShortcut &&
+          Array.isArray(searchMessageShortcut.shortcut) &&
+          searchMessageShortcut.shortcut.length === targetShortcut.length &&
+          searchMessageShortcut.shortcut.every((v, i) => v === targetShortcut[i])
+        ) {
+          searchMessageShortcut.shortcut = [isMac ? 'Command' : 'Ctrl', 'Shift', 'F']
+        }
+      }
+      return state
+    } catch (error) {
+      return state
+    }
   }
 }
 
diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts
index cafe278856b..93080f4f3d5 100644
--- a/src/renderer/src/store/shortcuts.ts
+++ b/src/renderer/src/store/shortcuts.ts
@@ -60,12 +60,19 @@ const initialState: ShortcutsState = {
       system: false
     },
     {
-      key: 'search_message',
+      key: 'search_message_in_chat',
       shortcut: [isMac ? 'Command' : 'Ctrl', 'F'],
       editable: true,
       enabled: true,
       system: false
     },
+    {
+      key: 'search_message',
+      shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'F'],
+      editable: true,
+      enabled: true,
+      system: false
+    },
     {
       key: 'clear_topic',
       shortcut: [isMac ? 'Command' : 'Ctrl', 'L'],


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

**Overall Comments**:
- Consider moving more of the search state management and update triggering logic from `Chat.tsx` into the `ContentSearch` component or a dedicated hook.
- The combination of `requestAnimationFrame`, `setTimeout`, and `debounce` for triggering search updates in `Chat.tsx` seems complex; explore if this timing logic can be simplified.

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

- 🟡 **General issues**: 3 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 2 issues found
- 🟢 **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-04-27T10:52:54Z
user: login: DeJeune
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-16T19:40:11Z
user: login: 0xfullex
body: 现在搜索UI还比较丑（甚至两边padding都不一致），包括出现消失都比较生硬，还需要调整下
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-17T05:45:34Z
user: login: 0xfullex
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-17T13:12:00Z
user: login: purefkh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T03:45:16Z
user: login: DeJeune
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-18T04:43:33Z

Review comments:
diff_hunk: @@ -42,6 +42,7 @@ export const useShortcut = (
   useHotkeys(
     shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
     (e) => {
+      console.log('shortcut is invoked', shortcutConfig?.shortcut)
path: src/renderer/src/hooks/useShortcuts.ts
user: login: sourcery-ai[bot]
body: **nitpick:** Remove or adjust debug logging in the shortcut hook.

The console.log statement appears to be temporary debugging code. Consider removing it or using a configurable logger interface if this level of logging is intended for production.
created_at: 2025-04-27T10:52:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,570 @@
+import { debounce } from 'lodash'
+import { ChevronDown, ChevronUp, X } from 'lucide-react'
+import React, { useCallback, useImperativeHandle, useState } from 'react'
+import styled from 'styled-components'
+
+const HIGHLIGHT_CLASS = 'highlight'
+const HIGHLIGHT_SELECT_CLASS = 'selected'
+
+interface Props {
+  children?: React.ReactNode
+  searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
+  /**
+   * 过滤`node`，`node`只会是`Node.TEXT_NODE`类型的文本节点
+   *
+   * 返回`true`表示该`node`会被搜索
+   */
+  filter: (node: Node) => boolean
+}
+
+enum SearchCompletedState {
+  NotSearched,
+  FirstSearched
+}
+
+enum SearchTargetIndex {
+  Next,
+  Prev
+}
+
+export interface ContentSearchRef {
+  disable(): void
+  enable(): void
+  // 搜索下一个并定位
+  searchNext(): void
+  // 搜索上一个并定位
+  searchPrev(): void
+  // 搜索并定位
+  search(): void
+  // 搜索但不定位，或者说是更新
+  silentSearch(): void
+  focus(): void
+}
+
+const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
+  if (!elementList || elementList.length === 0) {
+    return null
+  }
+  let closestElementIndex: number | null = null
+  let minVerticalDistance = Infinity
+  const windowCenterY = window.innerHeight / 2
+  for (let i = 0; i < elementList.length; i++) {
+    const element = elementList[i]
+    if (!(element instanceof HTMLElement)) {
+      continue
+    }
+    const rect = element.getBoundingClientRect()
+    if (rect.bottom < 0 || rect.top > window.innerHeight) {
+      continue
+    }
+    const elementCenterY = rect.top + rect.height / 2
+    const verticalDistance = Math.abs(elementCenterY - windowCenterY)
+    if (verticalDistance < minVerticalDistance) {
+      minVerticalDistance = verticalDistance
+      closestElementIndex = i
+    }
+  }
+  return closestElementIndex
+}
+
+const highlightText = (textNode: Node, searchText: string, highlightClass: string) => {
+  const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
+  if (textNodeParentNode) {
+    if (textNodeParentNode.classList.contains(highlightClass)) {
+      return null
+    }
+  }
+  const highlightText = searchText.toLowerCase()
+  if (textNode.nodeType !== Node.TEXT_NODE) {
+    return null
+  }
+  const textContent = textNode.textContent!.toLowerCase()
+  let index = textContent.indexOf(highlightText)
+  if (index === -1) {
+    return null
+  }
+
+  const parentNode = textNode.parentNode
+  if (!parentNode) {
+    return null
+  }
+
+  const fragment = document.createDocumentFragment()
+  let currentIndex = 0
+  let lastIndex = 0
+  const highlightTextSet = new Set<HTMLSpanElement>()
+  while (index !== -1) {
+    if (index > lastIndex) {
+      fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, index)))
+    }
+
+    const highlightSpan = document.createElement('span')
+    highlightSpan.className = highlightClass
+    highlightSpan.textContent = highlightText
+    fragment.appendChild(highlightSpan)
+    highlightTextSet.add(highlightSpan)
+    currentIndex = index + highlightText.length
+    lastIndex = currentIndex
+    index = textContent.indexOf(highlightText, currentIndex)
+  }
+
+  if (lastIndex < textContent.length) {
+    fragment.appendChild(document.createTextNode(textContent.substring(lastIndex)))
+  }
+
+  parentNode.replaceChild(fragment, textNode)
+  return [...highlightTextSet]
+}
+
+const mergeAdjacentTextNodes = (node: HTMLElement) => {
+  const children = Array.from(node.childNodes)
+  const groups: Array<Node | { text: string; nodes: Node[] }> = []
+  let currentTextGroup: { text: string; nodes: Node[] } | null = null
+
+  for (const child of children) {
+    if (child.nodeType === Node.TEXT_NODE) {
+      if (currentTextGroup === null) {
+        currentTextGroup = {
+          text: child.textContent ?? '',
+          nodes: [child]
+        }
+      } else {
+        currentTextGroup.text += child.textContent
+        currentTextGroup.nodes.push(child)
+      }
+    } else {
+      if (currentTextGroup !== null) {
+        groups.push(currentTextGroup!)
+        currentTextGroup = null
+      }
+      groups.push(child)
+    }
+  }
+
+  if (currentTextGroup !== null) {
+    groups.push(currentTextGroup)
+  }
+
+  const newChildren = groups.map((group) => {
+    if (group instanceof Node) {
+      return group
+    } else {
+      return document.createTextNode(group.text)
+    }
+  })
+
+  node.replaceChildren(...newChildren)
+}
+
+// eslint-disable-next-line @eslint-react/no-forward-ref
+export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(({ children, searchTarget, filter }, ref) => {
+  const target: HTMLElement | null = (() => {
+    if (searchTarget instanceof HTMLElement) {
+      return searchTarget
+    } else {
+      return (searchTarget.current as HTMLElement) ?? null
+    }
+  })()
+  const containerRef = React.useRef<HTMLDivElement>(null)
+  const searchInputRef = React.useRef<HTMLInputElement>(null)
+  const [searchResultIndex, setSearchResultIndex] = useState(0)
+  const [totalCount, setTotalCount] = useState(0)
+  const [enableContentSearch, setEnableContentSearch] = useState(false)
+  const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
+  const highlightTextSet = useState(new Set<Node>())[0]
+
+  const locateByIndex = (index: number) => {
+    if (target) {
+      const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
+      highlightTextNodes.sort((a, b) => {
+        const { top: aTop } = a.getBoundingClientRect()
+        const { top: bTop } = b.getBoundingClientRect()
+        return aTop - bTop
+      })
+      for (const node of highlightTextNodes) {
+        node.classList.remove(HIGHLIGHT_SELECT_CLASS)
+      }
+      setSearchResultIndex(index)
+      if (highlightTextNodes.length > 0) {
+        const highlightTextNode = highlightTextNodes[index] ?? null
+        if (highlightTextNode) {
+          highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
+          highlightTextNode.scrollIntoView({
+            behavior: 'smooth',
+            block: 'center',
+            inline: 'center'
+          })
+        }
+      }
+    }
+  }
+
+  const restoreHighlight = () => {
+    const highlightTextParentNodeSet = new Set<HTMLElement>()
+    for (const highlightTextNode of highlightTextSet) {
+      if (highlightTextNode.textContent) {
+        const textNode = document.createTextNode(highlightTextNode.textContent)
+        const node = highlightTextNode as HTMLElement
+        if (node.parentNode) {
+          highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
+        }
+        node.replaceWith(textNode)
+      }
+    }
+    for (const parentNode of highlightTextParentNodeSet) {
+      mergeAdjacentTextNodes(parentNode)
+    }
+    highlightTextSet.clear()
+  }
+
+  const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
+    const searchText = searchInputRef.current?.value.trim() ?? null
+    if (target && searchText !== null && searchText !== '') {
+      restoreHighlight()
+      const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
+      let textNode: Node | null
+      const textNodeSet: Set<Node> = new Set()
+      while ((textNode = iter.nextNode())) {
+        if (filter(textNode)) {
+          textNodeSet.add(textNode)
+        }
+      }
+
+      const highlightTextSetTemp = new Set<HTMLSpanElement>()
+      for (const node of textNodeSet) {
+        const list = highlightText(node, searchText, HIGHLIGHT_CLASS)
+        if (list) {
+          list.forEach((node) => highlightTextSetTemp.add(node))
+        }
+      }
+      const highlightTextList = [...highlightTextSetTemp]
+      setTotalCount(highlightTextList.length)
+      highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
+      const changeIndex = () => {
+        let index: number
+        switch (searchTargetIndex) {
+          case SearchTargetIndex.Next:
+            {
+              index = (searchResultIndex + 1) % highlightTextList.length
+            }
+            break
+          case SearchTargetIndex.Prev:
+            {
+              index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
+            }
+            break
+          default: {
+            index = searchResultIndex
+          }
+        }
+        return Math.max(index, 0)
+      }
+
+      const targetIndex = (() => {
+        switch (searchCompleted) {
+          case SearchCompletedState.NotSearched: {
+            setSearchCompleted(SearchCompletedState.FirstSearched)
+            const index = findWindowVerticalCenterElementIndex(highlightTextList)
+            if (index) {
path: src/renderer/src/components/ContentSearch.tsx
user: login: sourcery-ai[bot]
body: **suggestion (bug_risk):** Explicitly compare the returned index against null.

Since findWindowVerticalCenterElementIndex can return 0 (a falsy value) or null, using an explicit null check (e.g. 'if (index !== null)') would prevent potential misunderstandings and avoid accidental misinterpretation of a valid index of 0.

```suggestion
            if (index !== null) {
```
created_at: 2025-04-27T10:52:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,570 @@
+import { debounce } from 'lodash'
+import { ChevronDown, ChevronUp, X } from 'lucide-react'
+import React, { useCallback, useImperativeHandle, useState } from 'react'
+import styled from 'styled-components'
+
+const HIGHLIGHT_CLASS = 'highlight'
+const HIGHLIGHT_SELECT_CLASS = 'selected'
+
+interface Props {
+  children?: React.ReactNode
+  searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
+  /**
+   * 过滤`node`，`node`只会是`Node.TEXT_NODE`类型的文本节点
+   *
+   * 返回`true`表示该`node`会被搜索
+   */
+  filter: (node: Node) => boolean
+}
+
+enum SearchCompletedState {
+  NotSearched,
+  FirstSearched
+}
+
+enum SearchTargetIndex {
+  Next,
+  Prev
+}
+
+export interface ContentSearchRef {
+  disable(): void
+  enable(): void
+  // 搜索下一个并定位
+  searchNext(): void
+  // 搜索上一个并定位
+  searchPrev(): void
+  // 搜索并定位
+  search(): void
+  // 搜索但不定位，或者说是更新
+  silentSearch(): void
+  focus(): void
+}
+
+const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
+  if (!elementList || elementList.length === 0) {
+    return null
+  }
+  let closestElementIndex: number | null = null
+  let minVerticalDistance = Infinity
+  const windowCenterY = window.innerHeight / 2
+  for (let i = 0; i < elementList.length; i++) {
+    const element = elementList[i]
+    if (!(element instanceof HTMLElement)) {
+      continue
+    }
+    const rect = element.getBoundingClientRect()
+    if (rect.bottom < 0 || rect.top > window.innerHeight) {
+      continue
+    }
+    const elementCenterY = rect.top + rect.height / 2
+    const verticalDistance = Math.abs(elementCenterY - windowCenterY)
+    if (verticalDistance < minVerticalDistance) {
+      minVerticalDistance = verticalDistance
+      closestElementIndex = i
+    }
+  }
+  return closestElementIndex
+}
+
+const highlightText = (textNode: Node, searchText: string, highlightClass: string) => {
+  const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
+  if (textNodeParentNode) {
+    if (textNodeParentNode.classList.contains(highlightClass)) {
+      return null
+    }
+  }
+  const highlightText = searchText.toLowerCase()
+  if (textNode.nodeType !== Node.TEXT_NODE) {
+    return null
+  }
+  const textContent = textNode.textContent!.toLowerCase()
+  let index = textContent.indexOf(highlightText)
+  if (index === -1) {
+    return null
+  }
+
+  const parentNode = textNode.parentNode
+  if (!parentNode) {
+    return null
+  }
+
+  const fragment = document.createDocumentFragment()
+  let currentIndex = 0
+  let lastIndex = 0
+  const highlightTextSet = new Set<HTMLSpanElement>()
+  while (index !== -1) {
+    if (index > lastIndex) {
+      fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, index)))
+    }
+
+    const highlightSpan = document.createElement('span')
+    highlightSpan.className = highlightClass
+    highlightSpan.textContent = highlightText
+    fragment.appendChild(highlightSpan)
+    highlightTextSet.add(highlightSpan)
+    currentIndex = index + highlightText.length
+    lastIndex = currentIndex
+    index = textContent.indexOf(highlightText, currentIndex)
+  }
+
+  if (lastIndex < textContent.length) {
+    fragment.appendChild(document.createTextNode(textContent.substring(lastIndex)))
+  }
+
+  parentNode.replaceChild(fragment, textNode)
+  return [...highlightTextSet]
+}
+
+const mergeAdjacentTextNodes = (node: HTMLElement) => {
+  const children = Array.from(node.childNodes)
+  const groups: Array<Node | { text: string; nodes: Node[] }> = []
+  let currentTextGroup: { text: string; nodes: Node[] } | null = null
+
+  for (const child of children) {
+    if (child.nodeType === Node.TEXT_NODE) {
+      if (currentTextGroup === null) {
+        currentTextGroup = {
+          text: child.textContent ?? '',
+          nodes: [child]
+        }
+      } else {
+        currentTextGroup.text += child.textContent
+        currentTextGroup.nodes.push(child)
+      }
+    } else {
+      if (currentTextGroup !== null) {
+        groups.push(currentTextGroup!)
+        currentTextGroup = null
+      }
+      groups.push(child)
+    }
+  }
+
+  if (currentTextGroup !== null) {
+    groups.push(currentTextGroup)
+  }
+
+  const newChildren = groups.map((group) => {
+    if (group instanceof Node) {
+      return group
+    } else {
+      return document.createTextNode(group.text)
+    }
+  })
+
+  node.replaceChildren(...newChildren)
+}
+
+// eslint-disable-next-line @eslint-react/no-forward-ref
+export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(({ children, searchTarget, filter }, ref) => {
+  const target: HTMLElement | null = (() => {
+    if (searchTarget instanceof HTMLElement) {
+      return searchTarget
+    } else {
+      return (searchTarget.current as HTMLElement) ?? null
+    }
+  })()
+  const containerRef = React.useRef<HTMLDivElement>(null)
+  const searchInputRef = React.useRef<HTMLInputElement>(null)
+  const [searchResultIndex, setSearchResultIndex] = useState(0)
+  const [totalCount, setTotalCount] = useState(0)
+  const [enableContentSearch, setEnableContentSearch] = useState(false)
+  const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
+  const highlightTextSet = useState(new Set<Node>())[0]
+
+  const locateByIndex = (index: number) => {
+    if (target) {
+      const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
+      highlightTextNodes.sort((a, b) => {
+        const { top: aTop } = a.getBoundingClientRect()
+        const { top: bTop } = b.getBoundingClientRect()
+        return aTop - bTop
+      })
+      for (const node of highlightTextNodes) {
+        node.classList.remove(HIGHLIGHT_SELECT_CLASS)
+      }
+      setSearchResultIndex(index)
+      if (highlightTextNodes.length > 0) {
+        const highlightTextNode = highlightTextNodes[index] ?? null
+        if (highlightTextNode) {
+          highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
+          highlightTextNode.scrollIntoView({
+            behavior: 'smooth',
+            block: 'center',
+            inline: 'center'
+          })
+        }
+      }
+    }
+  }
+
+  const restoreHighlight = () => {
+    const highlightTextParentNodeSet = new Set<HTMLElement>()
+    for (const highlightTextNode of highlightTextSet) {
+      if (highlightTextNode.textContent) {
+        const textNode = document.createTextNode(highlightTextNode.textContent)
+        const node = highlightTextNode as HTMLElement
+        if (node.parentNode) {
+          highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
+        }
+        node.replaceWith(textNode)
+      }
+    }
+    for (const parentNode of highlightTextParentNodeSet) {
+      mergeAdjacentTextNodes(parentNode)
+    }
+    highlightTextSet.clear()
+  }
+
+  const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
+    const searchText = searchInputRef.current?.value.trim() ?? null
+    if (target && searchText !== null && searchText !== '') {
+      restoreHighlight()
+      const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
+      let textNode: Node | null
+      const textNodeSet: Set<Node> = new Set()
+      while ((textNode = iter.nextNode())) {
+        if (filter(textNode)) {
+          textNodeSet.add(textNode)
+        }
+      }
+
+      const highlightTextSetTemp = new Set<HTMLSpanElement>()
+      for (const node of textNodeSet) {
+        const list = highlightText(node, searchText, HIGHLIGHT_CLASS)
+        if (list) {
+          list.forEach((node) => highlightTextSetTemp.add(node))
+        }
+      }
+      const highlightTextList = [...highlightTextSetTemp]
+      setTotalCount(highlightTextList.length)
+      highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
+      const changeIndex = () => {
+        let index: number
+        switch (searchTargetIndex) {
+          case SearchTargetIndex.Next:
+            {
+              index = (searchResultIndex + 1) % highlightTextList.length
+            }
+            break
+          case SearchTargetIndex.Prev:
+            {
+              index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
+            }
+            break
+          default: {
+            index = searchResultIndex
+          }
+        }
+        return Math.max(index, 0)
+      }
+
+      const targetIndex = (() => {
+        switch (searchCompleted) {
+          case SearchCompletedState.NotSearched: {
+            setSearchCompleted(SearchCompletedState.FirstSearched)
+            const index = findWindowVerticalCenterElementIndex(highlightTextList)
+            if (index) {
+              setSearchResultIndex(index)
+              return index
+            } else {
+              setSearchResultIndex(0)
+              return 0
+            }
+          }
+          case SearchCompletedState.FirstSearched: {
+            return changeIndex()
+          }
+          default: {
+            return null
+          }
+        }
+      })()
+
+      if (targetIndex === null) {
+        return null
+      } else {
+        const totalCount = highlightTextSet.size
+        if (targetIndex >= totalCount) {
+          return totalCount - 1
+        } else {
+          return targetIndex
+        }
+      }
+    } else {
+      return null
+    }
+  }
+
+  const _searchHandlerDebounce = debounce(() => {
+    implementation.search()
+  }, 300)
+  const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
path: src/renderer/src/components/ContentSearch.tsx
user: login: sourcery-ai[bot]
body: **suggestion (performance):** Memoize the debounced search handler.

The debounced function is recreated on each render, which may affect performance. Consider using useMemo or moving the debounce creation outside the render scope so that the debounce instance persists across renders.

```suggestion
  const searchHandler = useMemo(() => debounce(() => {
    implementation.search()
  }, 300), [implementation])
```
created_at: 2025-04-27T10:52:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,19 +28,99 @@ const Chat: FC<Props> = (props) => {
   const { assistant } = useAssistant(props.assistant.id)
   const { topicPosition, messageStyle } = useSettings()
   const { showTopics } = useShowTopics()
+  const mainRef = React.useRef<HTMLDivElement>(null)
+  const contentSearchRef = React.useRef<ContentSearchRef>(null)
+  const [filterIncludeUser, setFilterIncludeUser] = useState(false)
+
+  useHotkeys('esc', () => {
+    contentSearchRef.current?.disable()
+  })
+
+  useShortcut('search_message_in_chat', () => {
+    try {
+      contentSearchRef.current?.enable()
+    } catch (error) {
+      console.error('Error enabling content search:', error)
+    }
+  })
+
+  const contentSearchFilter = (node: Node): boolean => {
+    if (node.parentNode) {
+      let parentNode: HTMLElement | null = node.parentNode as HTMLElement
+      while (parentNode?.parentNode) {
+        if (parentNode.classList.contains('MessageFooter')) {
+          return false
+        }
+
+        if (filterIncludeUser) {
+          if (parentNode?.classList.contains('message-content-container')) {
+            return true
+          }
+        } else {
+          if (parentNode?.classList.contains('message-content-container-assistant')) {
+            return true
+          }
+        }
+        parentNode = parentNode.parentNode as HTMLElement
+      }
+      return false
+    } else {
+      return false
+    }
+  }
+
+  const userOutlinedItemClickHandler = () => {
+    setFilterIncludeUser(!filterIncludeUser)
+    requestAnimationFrame(() => {
path: src/renderer/src/pages/home/Chat.tsx
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the nested async logic and debounced search logic into custom hooks to improve readability and maintainability.

```markdown
It might help to extract the nested async logic (double requestAnimationFrame and setTimeout) and the debounce search logic into a custom hook to reduce inline complexity. For example, you could create a hook like this:

```tsx
// useDelayedSearch.ts
import { useCallback } from 'react';

export function useDelayedSearch(contentSearchRef: React.RefObject<{ search: () => void; focus: () => void }>) {
  const triggerDelayedSearch = useCallback(() => {
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        setTimeout(() => {
          contentSearchRef.current?.search();
          contentSearchRef.current?.focus();
        }, 0);
      });
    });
  }, [contentSearchRef]);

  return { triggerDelayedSearch };
}
```

Then in your component, you could simplify the handler as:

```tsx
const { triggerDelayedSearch } = useDelayedSearch(contentSearchRef);

const userOutlinedItemClickHandler = () => {
  setFilterIncludeUser(prev => !prev);
  triggerDelayedSearch();
};
```

Additionally, consider encapsulating the debounced search logic into its own custom hook.

This refactor keeps functionality intact while flattening the control flow for easier readability and maintenance.
```
created_at: 2025-04-27T10:52:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,570 @@
+import { debounce } from 'lodash'
+import { ChevronDown, ChevronUp, X } from 'lucide-react'
+import React, { useCallback, useImperativeHandle, useState } from 'react'
+import styled from 'styled-components'
+
+const HIGHLIGHT_CLASS = 'highlight'
+const HIGHLIGHT_SELECT_CLASS = 'selected'
+
+interface Props {
+  children?: React.ReactNode
+  searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
+  /**
+   * 过滤`node`，`node`只会是`Node.TEXT_NODE`类型的文本节点
+   *
+   * 返回`true`表示该`node`会被搜索
+   */
+  filter: (node: Node) => boolean
+}
+
+enum SearchCompletedState {
+  NotSearched,
+  FirstSearched
+}
+
+enum SearchTargetIndex {
+  Next,
+  Prev
+}
+
+export interface ContentSearchRef {
+  disable(): void
+  enable(): void
+  // 搜索下一个并定位
+  searchNext(): void
+  // 搜索上一个并定位
+  searchPrev(): void
+  // 搜索并定位
+  search(): void
+  // 搜索但不定位，或者说是更新
+  silentSearch(): void
+  focus(): void
+}
+
+const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
+  if (!elementList || elementList.length === 0) {
+    return null
+  }
+  let closestElementIndex: number | null = null
+  let minVerticalDistance = Infinity
+  const windowCenterY = window.innerHeight / 2
+  for (let i = 0; i < elementList.length; i++) {
+    const element = elementList[i]
+    if (!(element instanceof HTMLElement)) {
+      continue
+    }
+    const rect = element.getBoundingClientRect()
+    if (rect.bottom < 0 || rect.top > window.innerHeight) {
+      continue
+    }
+    const elementCenterY = rect.top + rect.height / 2
+    const verticalDistance = Math.abs(elementCenterY - windowCenterY)
+    if (verticalDistance < minVerticalDistance) {
+      minVerticalDistance = verticalDistance
+      closestElementIndex = i
+    }
+  }
+  return closestElementIndex
+}
+
+const highlightText = (textNode: Node, searchText: string, highlightClass: string) => {
+  const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
+  if (textNodeParentNode) {
+    if (textNodeParentNode.classList.contains(highlightClass)) {
+      return null
+    }
+  }
+  const highlightText = searchText.toLowerCase()
+  if (textNode.nodeType !== Node.TEXT_NODE) {
+    return null
+  }
+  const textContent = textNode.textContent!.toLowerCase()
+  let index = textContent.indexOf(highlightText)
+  if (index === -1) {
+    return null
+  }
+
+  const parentNode = textNode.parentNode
+  if (!parentNode) {
+    return null
+  }
+
+  const fragment = document.createDocumentFragment()
+  let currentIndex = 0
+  let lastIndex = 0
+  const highlightTextSet = new Set<HTMLSpanElement>()
+  while (index !== -1) {
+    if (index > lastIndex) {
+      fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, index)))
+    }
+
+    const highlightSpan = document.createElement('span')
+    highlightSpan.className = highlightClass
+    highlightSpan.textContent = highlightText
+    fragment.appendChild(highlightSpan)
+    highlightTextSet.add(highlightSpan)
+    currentIndex = index + highlightText.length
+    lastIndex = currentIndex
+    index = textContent.indexOf(highlightText, currentIndex)
+  }
+
+  if (lastIndex < textContent.length) {
+    fragment.appendChild(document.createTextNode(textContent.substring(lastIndex)))
+  }
+
+  parentNode.replaceChild(fragment, textNode)
+  return [...highlightTextSet]
+}
+
+const mergeAdjacentTextNodes = (node: HTMLElement) => {
+  const children = Array.from(node.childNodes)
+  const groups: Array<Node | { text: string; nodes: Node[] }> = []
+  let currentTextGroup: { text: string; nodes: Node[] } | null = null
+
+  for (const child of children) {
+    if (child.nodeType === Node.TEXT_NODE) {
+      if (currentTextGroup === null) {
+        currentTextGroup = {
+          text: child.textContent ?? '',
+          nodes: [child]
+        }
+      } else {
+        currentTextGroup.text += child.textContent
+        currentTextGroup.nodes.push(child)
+      }
+    } else {
+      if (currentTextGroup !== null) {
+        groups.push(currentTextGroup!)
+        currentTextGroup = null
+      }
+      groups.push(child)
+    }
+  }
+
+  if (currentTextGroup !== null) {
+    groups.push(currentTextGroup)
+  }
+
+  const newChildren = groups.map((group) => {
+    if (group instanceof Node) {
+      return group
+    } else {
+      return document.createTextNode(group.text)
+    }
+  })
+
+  node.replaceChildren(...newChildren)
+}
+
+// eslint-disable-next-line @eslint-react/no-forward-ref
path: src/renderer/src/components/ContentSearch.tsx
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the text highlighting and node merging logic into separate modules to improve component readability and separation of concerns.

Consider extracting separate modules for text highlighting and node merging to reduce the nested logic in your component. For example, you could move the text processing logic into its own helper:

```tsx
// textHighlighting.ts
export const highlightText = (
  textNode: Node,
  searchText: string,
  highlightClass: string
): HTMLSpanElement[] | null => {
  const parent = textNode.parentNode as HTMLElement;
  if (!parent || parent.classList.contains(highlightClass) || textNode.nodeType !== Node.TEXT_NODE) {
    return null;
  }
  const lowerText = textNode.textContent!.toLowerCase();
  const searchLower = searchText.toLowerCase();
  let index = lowerText.indexOf(searchLower);
  if (index === -1) return null;

  const fragment = document.createDocumentFragment();
  const highlightedSpans: HTMLSpanElement[] = [];
  let lastIndex = 0;

  while (index !== -1) {
    if (index > lastIndex) {
      fragment.appendChild(document.createTextNode(textNode.textContent!.substring(lastIndex, index)));
    }
    const span = document.createElement('span');
    span.className = highlightClass;
    span.textContent = textNode.textContent!.substr(index, searchLower.length);
    fragment.appendChild(span);
    highlightedSpans.push(span);
    lastIndex = index + searchLower.length;
    index = lowerText.indexOf(searchLower, lastIndex);
  }
  if (lastIndex < textNode.textContent!.length) {
    fragment.appendChild(document.createTextNode(textNode.textContent!.substring(lastIndex)));
  }
  parent.replaceChild(fragment, textNode);
  return highlightedSpans;
};

export const mergeAdjacentTextNodes = (node: HTMLElement): void => {
  const groups: Array<Node | { text: string; nodes: Node[] }> = [];
  let currentGroup: { text: string; nodes: Node[] } | null = null;
  Array.from(node.childNodes).forEach(child => {
    if (child.nodeType === Node.TEXT_NODE) {
      if (!currentGroup) {
        currentGroup = { text: child.textContent ?? '', nodes: [child] };
      } else {
        currentGroup.text += child.textContent;
        currentGroup.nodes.push(child);
      }
    } else {
      if (currentGroup) {
        groups.push(currentGroup);
        currentGroup = null;
      }
      groups.push(child);
    }
  });
  if (currentGroup) groups.push(currentGroup);

  const newNodes = groups.map(group =>
    group instanceof Node ? group : document.createTextNode(group.text)
  );
  node.replaceChildren(...newNodes);
};
```

Then, update your `ContentSearch` component by importing these functions:

```tsx
import { highlightText, mergeAdjacentTextNodes } from './textHighlighting';
```

This approach separates concerns clearly and keeps the component focused on its UI and high-level state management rather than on low-level DOM operations.
created_at: 2025-04-27T10:52:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,6 +53,10 @@
 
   --modal-background: #1f1f1f;
 
+  --color-heightlight: rgba(0, 0, 0, 1);
path: src/renderer/src/assets/styles/index.scss
user: login: 0xfullex
body: 这里一系列变量名列错了
created_at: 2025-05-17T05:40:19Z
author_association: MEMBER
diff_hunk: @@ -752,13 +756,13 @@
       "type": {
         "embedding": "嵌入",
         "free": "免费",
-        "function_calling": "工具",
         "reasoning": "推理",
         "rerank": "重排",
         "select": "选择模型类型",
         "text": "文本",
-        "vision": "视觉",
-        "websearch": "联网"
+        "vision": "图像",
+        "function_calling": "函数调用",
+        "websearch": "[to be translated]:WebSearch"
path: src/renderer/src/i18n/locales/zh-cn.json
user: login: purefkh
body: websearch 的翻译没了。其他语言中，function_calling 是“工具”，vision “视觉”，现在不统一了
created_at: 2025-05-18T03:45:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -752,13 +756,13 @@
       "type": {
         "embedding": "嵌入",
         "free": "免费",
-        "function_calling": "工具",
         "reasoning": "推理",
         "rerank": "重排",
         "select": "选择模型类型",
         "text": "文本",
-        "vision": "视觉",
-        "websearch": "联网"
+        "vision": "图像",
+        "function_calling": "函数调用",
+        "websearch": "[to be translated]:WebSearch"
path: src/renderer/src/i18n/locales/zh-cn.json
user: login: DeJeune
body: 就还是联网呗
created_at: 2025-05-18T04:43:33Z
author_association: COLLABORATOR

PR comments:
user: login: ousugo
created_at: 2025-03-13T17:41:29Z
body: 全局搜索可以用 ctrl+shift+f，这个用 ctrl+f
author_association: COLLABORATOR
user: login: kangfenmao
created_at: 2025-03-14T01:16:41Z
body: 1. 有错误无法启动应用
2. 不知道如何触发这个搜索
author_association: COLLABORATOR
user: login: icinggslits
created_at: 2025-03-14T01:44:05Z
body: 加了个f3启动搜索
author_association: CONTRIBUTOR
user: login: kangfenmao
created_at: 2025-03-14T01:55:49Z
body: 发现一些小问题

<img width="388" alt="image" src="https://github.com/user-attachments/assets/36ec7614-e3d4-49f0-9dd0-7bca219b0e73" />

<img width="810" alt="image" src="https://github.com/user-attachments/assets/88211533-221c-48e2-8bcf-279ab5ba63da" />

author_association: COLLABORATOR
user: login: icinggslits
created_at: 2025-03-14T03:25:17Z
body: > 发现一些小问题
> 
> <img alt="image" width="388" src="https://private-user-images.githubusercontent.com/8253512/422601382-36ec7614-e3d4-49f0-9dd0-7bca219b0e73.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDE5MjI5NjEsIm5iZiI6MTc0MTkyMjY2MSwicGF0aCI6Ii84MjUzNTEyLzQyMjYwMTM4Mi0zNmVjNzYxNC1lM2Q0LTQ5ZjAtOWRkMC03YmNhMjE5YjBlNzMucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI1MDMxNCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNTAzMTRUMDMyNDIxWiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9Nzg0M2U3MTNhODBiY2I1ODFhN2JjY2Q4ZjdhZjMyNWUwN2I4MGFkZDIwZGYyYzYzYjZiOWYzYWM0Zjk3MWYzNiZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QifQ.7tQKsQXTzBOx3XQdvZzV-nCol_tY3FDIHnXKI7QQGGY"> <img alt="image" width="810" src="https://private-user-images.githubusercontent.com/8253512/422601634-88211533-221c-48e2-8bcf-279ab5ba63da.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDE5MjI5NjEsIm5iZiI6MTc0MTkyMjY2MSwicGF0aCI6Ii84MjUzNTEyLzQyMjYwMTYzNC04ODIxMTUzMy0yMjFjLTQ4ZTItOGJjZi0yNzlhYjViYTYzZGEucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI1MDMxNCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNTAzMTRUMDMyNDIxWiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9ZjQwNzFmMGEzNjMzZGQ0MTRhZDg3N2U5ODJkNDg3ZTAxNzk4M2QyYjUzODg3ZTZhOTA3ZmFiNmI2MjUxZTcwOCZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QifQ.d57XKkdS3V9AIObv-zTw30GYkMrjVeu6fDC2Hlv9dOg">

图1我复现不了，试试现在还有这问题吗
author_association: CONTRIBUTOR
user: login: kangfenmao
created_at: 2025-03-14T04:38:42Z
body: 我空下来的时候继续帮忙测试
author_association: COLLABORATOR
user: login: purefkh
created_at: 2025-04-14T06:01:34Z
body: 需要这个功能，现在的搜索功能很难准确定位到位置
author_association: CONTRIBUTOR
user: login: DeJeune
created_at: 2025-04-27T07:25:20Z
body: 我来解决冲突，然后看看
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-04-27T10:51:09Z
body: @sourcery-ai review
author_association: COLLABORATOR
user: login: sourcery-ai[bot]
created_at: 2025-04-27T10:52:53Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request implements a highlighted text search feature directly within the chat message content area. It introduces a new `ContentSearch` component that handles the search logic, DOM manipulation for highlighting, and result navigation. The feature is integrated into the chat page, allowing users to search message content using keyboard shortcuts (`Ctrl+F` to enable, 'Escape' to disable, 'Enter' to navigate results). The PR also includes a migration step to remap existing shortcut configurations and adds CSS styles for the highlighting.

_No diagrams generated as the changes look simple and do not need a visual representation._

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Implemented the core highlighted text search functionality. | <ul><li>Created a reusable React component `ContentSearch`.</li><li>Added logic to find and highlight text matches within a target DOM element.</li><li>Implemented navigation between highlighted search results.</li><li>Included filtering capabilities to specify which text nodes are searchable.</li><li>Exposed component methods (enable, disable, search, etc.) via an imperative handle.</li></ul> | `src/renderer/src/components/ContentSearch.tsx` |
| Integrated the content search feature into the chat page. | <ul><li>Added the `ContentSearch` component to the chat view.</li><li>Configured the search target to be the main message area.</li><li>Implemented a filter function to include/exclude user messages based on a state toggle.</li><li>Added keyboard shortcut handlers (`Ctrl+F`, `Escape`) to control the search component.</li><li>Added a button to toggle including user messages in the search.</li><li>Added props to the `Messages` component to notify the parent about rendering updates.</li><li>Added specific CSS classes to message content containers and footers to support search filtering.</li></ul> | `src/renderer/src/pages/home/Chat.tsx`<br/>`src/renderer/src/pages/home/Messages/Messages.tsx`<br/>`src/renderer/src/pages/home/Messages/Message.tsx`<br/>`src/renderer/src/i18n/locales/en-us.json`<br/>`src/renderer/src/i18n/locales/ja-jp.json`<br/>`src/renderer/src/i18n/locales/ru-ru.json`<br/>`src/renderer/src/i18n/locales/zh-cn.json`<br/>`src/renderer/src/i18n/locales/zh-tw.json` |
| Updated shortcut configurations and added a migration step. | <ul><li>Remapped the default `Ctrl+F` shortcut to enable the new content search (`search_message_in_chat`).</li><li>Assigned the previous 'search message' functionality (`search_message`) to `Ctrl+Shift+F`.</li><li>Added a migration step ('98') to update existing user shortcut configurations.</li><li>Updated the store version to trigger the migration.</li></ul> | `src/renderer/src/store/shortcuts.ts`<br/>`src/renderer/src/store/migrate.ts`<br/>`src/renderer/src/store/index.ts`<br/>`src/renderer/src/hooks/useShortcuts.ts` |
| Added CSS styles for search highlighting and the filter button. | <ul><li>Defined CSS variables for highlight colors in both light and dark themes.</li><li>Added styles for the `.highlight` and `.highlight.selected` classes.</li><li>Styled the new `UserOutlinedItem` component used for the filter toggle.</li></ul> | `src/renderer/src/assets/styles/index.scss`<br/>`src/renderer/src/pages/home/Chat.tsx` |

### Assessment against linked issues

| Issue | Objective | Addressed | Explanation |
| ------ | ------- | ----- | ----- |
| #4961 | Implement a local search feature within the current conversation. | ✅ |  |
| #4961 | Visually highlight search terms within the chat. | ✅ |  |
| #4961 | Provide navigation (arrows) to move between search results. | ✅ |  |

### Possibly linked issues

- **#4961**: PR adds highlighted text search to chat, resolving issue #4961 about chat search.
- **#4961**: The PR implements the chat keyword highlighting feature requested 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: teojs
created_at: 2025-04-28T02:27:56Z
body: 以下是一些建议：
- 1、把按钮“包含用户提问”放在“区分大小写”旁边；
- 2、预留匹配项数字“0/0”的宽度，避免在匹配到数据之后宽度发生变化出现抖动；
- 3、再次唤出搜索框时，会有上次搜索的结果，此时会立马滚动到匹配的第一项，体验不好；
- 4、跟第3条一样，在切换话题时，搜索框也没有关掉，会导致立马滚动到匹配的第一项，建议是在用户输入时才触发跳转，其他情况下不建议直接跳；
- 5、匹配不到时，上下箭头是否可以灰掉；
- 6、再次唤出搜索框时，又或者重复按快捷键触发时，如果已经有上次搜索内容，是否可以自动选中搜索内容，便于直接输入替换掉；

一些问题：
- 出现NaN/1，匹配不到是否可以直接显示：“无结果”；
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-17T04:01:11Z
body: > 以下是一些建议：
> 
> * 1、把按钮“包含用户提问”放在“区分大小写”旁边；
> * 2、预留匹配项数字“0/0”的宽度，避免在匹配到数据之后宽度发生变化出现抖动；
> * 3、再次唤出搜索框时，会有上次搜索的结果，此时会立马滚动到匹配的第一项，体验不好；
> * 4、跟第3条一样，在切换话题时，搜索框也没有关掉，会导致立马滚动到匹配的第一项，建议是在用户输入时才触发跳转，其他情况下不建议直接跳；
> * 5、匹配不到时，上下箭头是否可以灰掉；
> * 6、再次唤出搜索框时，又或者重复按快捷键触发时，如果已经有上次搜索内容，是否可以自动选中搜索内容，便于直接输入替换掉；
> 
> 一些问题：
> 
> * 出现NaN/1，匹配不到是否可以直接显示：“无结果”；

已经全部解决了
author_association: COLLABORATOR
user: login: 0xfullex
created_at: 2025-05-17T05:46:44Z
body: > 现在搜索UI还比较丑（甚至两边padding都不一致），包括出现消失都比较生硬，还需要调整下

![image](https://github.com/user-attachments/assets/71325634-0fc4-43ba-a13c-673733bef119)

author_association: MEMBER
user: login: DeJeune
created_at: 2025-05-17T09:09:03Z
body: @0xfullex 改好了
author_association: COLLABORATOR
user: login: 0xfullex
created_at: 2025-05-17T11:45:59Z
body: 这里有白边还挺奇怪的

![image](https://github.com/user-attachments/assets/c78a1b60-76cf-4ec1-baf5-c49b14791e45)

author_association: MEMBER
user: login: DeJeune
created_at: 2025-05-17T13:01:57Z
body: 把margin-bottom改为0了
author_association: COLLABORATOR
user: login: kvn1351
created_at: 2025-05-18T17:56:49Z
body: Maybe I'm missing something but it doesn't seem to be part of v1.3.6.
author_association: NONE
user: login: DeJeune
created_at: 2025-05-18T18:08:24Z
body: > Maybe I'm missing something but it doesn't seem to be part of v1.3.6.

This commit is still undergoing some stability-related testing on the dev branch.
author_association: COLLABORATOR