From faa37e616c9f6cfb03587a1432667e099a50fc99 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Sat, 30 Aug 2025 20:40:08 +0800 Subject: [PATCH 1/2] Add search functionality to Independent Panel chat history, close #550 Add a sidebar search with debounced, diacritic-insensitive filtering across conversation titles and content. Include i18n strings for the search UI. Keep the panel responsive and accessible: precompute a normalized per-session index to keep typing smooth on long lists, improve keyboard navigation and shortcuts to focus the search and temporarily expand the sidebar, and move sidebar styling into SCSS. Persist and reliably sync the sidebar's collapsed state across reloads, handling storage failures without blocking the UI. Harden deletion and clear flows, sanitize inputs, and guard async updates to keep state consistent. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: qodo-merge-pro[bot] <151058649+qodo-merge-pro[bot]@users.noreply.github.com> --- src/_locales/de/main.json | 3 + src/_locales/en/main.json | 3 + src/_locales/es/main.json | 3 + src/_locales/fr/main.json | 3 + src/_locales/in/main.json | 3 + src/_locales/it/main.json | 3 + src/_locales/ja/main.json | 3 + src/_locales/ko/main.json | 3 + src/_locales/pt/main.json | 3 + src/_locales/ru/main.json | 3 + src/_locales/tr/main.json | 3 + src/_locales/zh-hans/main.json | 3 + src/_locales/zh-hant/main.json | 3 + src/components/DeleteButton/index.jsx | 39 +++- src/config/index.mjs | 1 + src/pages/IndependentPanel/App.jsx | 288 +++++++++++++++++++++---- src/pages/IndependentPanel/styles.scss | 105 ++++++++- 17 files changed, 425 insertions(+), 47 deletions(-) diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 450f97e8..b1bcc266 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Konversation löschen", "Clear conversations": "Konversationen löschen", "Settings": "Einstellungen", + "Search": "Suchen", + "Search conversations...": "In Gesprächen suchen...", + "No conversations found": "Keine passenden Konversationen gefunden", "Feature Pages": "Funktionsseiten", "Keyboard Shortcuts": "Tastenkombinationen", "Open Conversation Page": "Konversationsseite öffnen", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 174f99af..5c3ade13 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Delete Conversation", "Clear conversations": "Clear conversations", "Settings": "Settings", + "Search": "Search", + "Search conversations...": "Search conversations...", + "No conversations found": "No conversations found", "Feature Pages": "Feature Pages", "Keyboard Shortcuts": "Keyboard Shortcuts", "Open Conversation Page": "Open Conversation Page", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index df4c8a4a..79540876 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Eliminar conversación", "Clear conversations": "Borrar todas las conversaciones", "Settings": "Configuración", + "Search": "Buscar", + "Search conversations...": "Buscar en las conversaciones...", + "No conversations found": "No se encontraron conversaciones coincidentes", "Feature Pages": "Páginas de características", "Keyboard Shortcuts": "Atajos de teclado", "Open Conversation Page": "Abrir página de conversación independiente", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index c8e76ca4..6ba47b37 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Supprimer la conversation", "Clear conversations": "Effacer les conversations", "Settings": "Paramètres", + "Search": "Rechercher", + "Search conversations...": "Rechercher des conversations...", + "No conversations found": "Aucune conversation correspondante", "Feature Pages": "Pages de fonctionnalités", "Keyboard Shortcuts": "Raccourcis clavier", "Open Conversation Page": "Ouvrir la page de conversation", diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 064372ff..cb4fd4d9 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Hapus Percakapan", "Clear conversations": "Hapus Percakapan", "Settings": "Pengaturan", + "Search": "Cari", + "Search conversations...": "Cari di percakapan...", + "No conversations found": "Tidak ditemukan percakapan yang cocok", "Feature Pages": "Halaman Fitur", "Keyboard Shortcuts": "Pintasan Keyboard", "Open Conversation Page": "Buka Halaman Percakapan", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 87c9e46c..e5e5f90f 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Elimina la conversazione", "Clear conversations": "Pulisci le conversazioni", "Settings": "Impostazioni", + "Search": "Cerca", + "Search conversations...": "Cerca nelle conversazioni...", + "No conversations found": "Nessuna conversazione corrispondente", "Feature Pages": "Pagine delle funzionalità", "Keyboard Shortcuts": "Scorciatoie da tastiera", "Open Conversation Page": "Apri la pagina della conversazione", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 4f6ebf80..7d148bea 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "会話を削除", "Clear conversations": "会話をクリア", "Settings": "設定", + "Search": "検索", + "Search conversations...": "会話内を検索...", + "No conversations found": "一致する会話が見つかりません", "Feature Pages": "機能ページ", "Keyboard Shortcuts": "キーボードショートカット", "Open Conversation Page": "会話ページを開く", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 92fe01a2..39861db2 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "대화 삭제", "Clear conversations": "대화 기록 지우기", "Settings": "설정", + "Search": "검색", + "Search conversations...": "대화에서 검색...", + "No conversations found": "일치하는 대화가 없습니다", "Feature Pages": "기능 페이지", "Keyboard Shortcuts": "키보드 단축키 설정", "Open Conversation Page": "대화 페이지 열기", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index 1cb7ef46..2454fa36 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Excluir Conversa", "Clear conversations": "Limpar conversas", "Settings": "Configurações", + "Search": "Pesquisar", + "Search conversations...": "Pesquisar nas conversas...", + "No conversations found": "Nenhuma conversa correspondente", "Feature Pages": "Páginas de Recursos", "Keyboard Shortcuts": "Atalhos de Teclado", "Open Conversation Page": "Abrir Página de Conversa", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index 08b701e3..9f4ef9c7 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Удалить беседу", "Clear conversations": "Очистить историю бесед", "Settings": "Настройки", + "Search": "Поиск", + "Search conversations...": "Искать в беседах...", + "No conversations found": "Подходящих бесед не найдено", "Feature Pages": "Страницы функций", "Keyboard Shortcuts": "Горячие клавиши", "Open Conversation Page": "Открыть страницу бесед", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 7ecad89d..bba69020 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "Konuşmayı Sil", "Clear conversations": "Konuşmaları temizle", "Settings": "Ayarlar", + "Search": "Ara", + "Search conversations...": "Konuşmalarda ara...", + "No conversations found": "Eşleşen konuşma bulunamadı", "Feature Pages": "Özellik Sayfaları", "Keyboard Shortcuts": "Klavye Kısayolları", "Open Conversation Page": "Konuşma Sayfasını Aç", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index 80d06c85..9233964a 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "删除对话", "Clear conversations": "清空记录", "Settings": "设置", + "Search": "搜索", + "Search conversations...": "搜索对话内容...", + "No conversations found": "未找到匹配的聊天记录", "Feature Pages": "功能页", "Keyboard Shortcuts": "快捷键设置", "Open Conversation Page": "打开独立对话页", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index e8edea88..3bbf0009 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -97,6 +97,9 @@ "Delete Conversation": "刪除對話", "Clear conversations": "清空對話記錄", "Settings": "設定", + "Search": "搜尋", + "Search conversations...": "搜尋對話紀錄...", + "No conversations found": "沒有符合的對話紀錄", "Feature Pages": "功能頁面", "Keyboard Shortcuts": "快速鍵設定", "Open Conversation Page": "開啟獨立對話頁面", diff --git a/src/components/DeleteButton/index.jsx b/src/components/DeleteButton/index.jsx index ad5f5b76..de683e2a 100644 --- a/src/components/DeleteButton/index.jsx +++ b/src/components/DeleteButton/index.jsx @@ -13,6 +13,7 @@ function DeleteButton({ onConfirm, size, text }) { const { t } = useTranslation() const [waitConfirm, setWaitConfirm] = useState(false) const confirmRef = useRef(null) + const [confirming, setConfirming] = useState(false) useEffect(() => { if (waitConfirm) confirmRef.current.focus() @@ -28,16 +29,30 @@ function DeleteButton({ onConfirm, size, text }) { fontSize: '10px', ...(waitConfirm ? {} : { display: 'none' }), }} + disabled={confirming} + aria-busy={confirming ? 'true' : 'false'} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() }} onBlur={() => { - setWaitConfirm(false) + if (!confirming) setWaitConfirm(false) }} - onClick={() => { - setWaitConfirm(false) - onConfirm() + onClick={async (e) => { + if (confirming) return + e.preventDefault() + e.stopPropagation() + setConfirming(true) + try { + await onConfirm() + setWaitConfirm(false) + } catch (err) { + // Keep confirmation visible to allow retry; optionally log + // eslint-disable-next-line no-console + console.error(err) + } finally { + setConfirming(false) + } }} > {t('Confirm')} @@ -45,8 +60,20 @@ function DeleteButton({ onConfirm, size, text }) { { + role="button" + tabIndex={0} + aria-label={text} + aria-hidden={waitConfirm ? 'true' : undefined} + style={waitConfirm ? { visibility: 'hidden' } : {}} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + setWaitConfirm(true) + } + }} + onClick={(e) => { + e.stopPropagation() setWaitConfirm(true) }} > diff --git a/src/config/index.mjs b/src/config/index.mjs index fb504aee..5d50b250 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -475,6 +475,7 @@ export const defaultConfig = { selectionToolsNextToInputBox: false, alwaysPinWindow: false, focusAfterAnswer: true, + independentPanelCollapsed: true, apiKey: '', // openai ApiKey diff --git a/src/pages/IndependentPanel/App.jsx b/src/pages/IndependentPanel/App.jsx index 5552b610..f2258642 100644 --- a/src/pages/IndependentPanel/App.jsx +++ b/src/pages/IndependentPanel/App.jsx @@ -6,9 +6,10 @@ import { getSession, deleteSession, } from '../../services/local-session.mjs' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useMemo } from 'react' import './styles.scss' import { useConfig } from '../../hooks/use-config.mjs' +import { setUserConfig } from '../../config/index.mjs' import { useTranslation } from 'react-i18next' import ConfirmButton from '../../components/ConfirmButton' import ConversationCard from '../../components/ConversationCard' @@ -24,10 +25,13 @@ function App() { const [sessions, setSessions] = useState([]) const [sessionId, setSessionId] = useState(null) const [currentSession, setCurrentSession] = useState(null) - const [renderContent, setRenderContent] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [forceExpand, setForceExpand] = useState(false) const currentPort = useRef(null) + const searchInputRef = useRef(null) - const setSessionIdSafe = async (sessionId) => { + const stopCurrentPort = () => { if (currentPort.current) { try { currentPort.current.postMessage({ stop: true }) @@ -37,9 +41,19 @@ function App() { } currentPort.current = null } + } + + const setSessionIdSafe = async (sessionId) => { + stopCurrentPort() const { session, currentSessions } = await getSession(sessionId) - if (session) setSessionId(sessionId) - else if (currentSessions.length > 0) setSessionId(currentSessions[0].sessionId) + if (session && session.sessionId) { + setSessionId(session.sessionId) + } else if (Array.isArray(currentSessions) && currentSessions.length > 0) { + setSessionId(currentSessions[0].sessionId) + } else { + setSessionId(null) + setCurrentSession(null) + } } useEffect(() => { @@ -68,21 +82,32 @@ function App() { if ('sessions' in config && config['sessions']) setSessions(config['sessions']) }, [config]) + // Sync collapsed state from persisted config + useEffect(() => { + if (config && typeof config === 'object' && 'independentPanelCollapsed' in config) { + setCollapsed(!!config.independentPanelCollapsed) + } + }, [config?.independentPanelCollapsed]) + useEffect(() => { // eslint-disable-next-line ;(async () => { if (sessions.length > 0) { setCurrentSession((await getSession(sessionId)).session) - setRenderContent(false) - setTimeout(() => { - setRenderContent(true) - }) } })() }, [sessionId]) - const toggleSidebar = () => { - setCollapsed(!collapsed) + const toggleSidebar = async () => { + const next = !collapsed + // Ensure temporary expansion is cleared when toggling pin state + setForceExpand(false) + setCollapsed(next) + try { + await setUserConfig({ independentPanelCollapsed: next }) + } catch (e) { + // no-op: persist failure should not block UI toggle + } } const createNewChat = async () => { @@ -98,17 +123,136 @@ function App() { } const clearConversations = async () => { - const sessions = await resetSessions() - setSessions(sessions) - await setSessionIdSafe(sessions[0].sessionId) + const next = await resetSessions() + setSessions(next) + if (next && next.length > 0) { + await setSessionIdSafe(next[0].sessionId) + } else { + stopCurrentPort() + setSessionId(null) + setCurrentSession(null) + setSearchQuery('') + setDebouncedQuery('') + } } + const handleSearchChange = (e) => { + const raw = e?.target?.value ?? '' + // Keep Tab/LF/CR, remove other control chars (incl. DEL), then truncate by code points + const CP_TAB = 9 + const CP_LF = 10 + const CP_CR = 13 + const CP_PRINTABLE_MIN = 32 + const CP_DEL = 127 + const isAllowedCodePoint = (cp) => + cp === CP_TAB || cp === CP_LF || cp === CP_CR || (cp >= CP_PRINTABLE_MIN && cp !== CP_DEL) + const sanitizedArr = Array.from(raw).filter((ch) => { + const cp = ch.codePointAt(0) + return cp != null && isAllowedCodePoint(cp) + }) + const limited = sanitizedArr.slice(0, 500).join('') + setSearchQuery(limited) + } + + // Debounce search input for performance + useEffect(() => { + const id = setTimeout(() => setDebouncedQuery(searchQuery), 200) + return () => clearTimeout(id) + }, [searchQuery]) + + // Track mount state to guard async setState after unmount + const isMountedRef = useRef(true) + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + // Keyboard shortcuts: Ctrl/Cmd+F and '/' to focus search + useEffect(() => { + const focusSearch = () => { + if (searchInputRef.current) { + // Temporarily expand sidebar when focusing search via shortcuts + setForceExpand(true) + searchInputRef.current.focus() + searchInputRef.current.select() + } + } + const onKeyDown = (e) => { + const target = e.target + const isTypingField = + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + + // Always route find shortcut to panel search (and auto-expand temporarily) + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') { + e.preventDefault() + focusSearch() + return + } + + // Quick open search with '/' when not typing in a field + if (!isTypingField && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '/') { + e.preventDefault() + focusSearch() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + // Utility function to safely convert any value to a string + const toSafeString = (value) => + typeof value === 'string' ? value : value == null ? '' : String(value) + + // Normalization utility for search + const normalizeForSearch = (value) => + toSafeString(value) + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim() + + // Precompute a normalized index for sessions to reduce per-keystroke work + const normalizedIndex = useMemo(() => { + if (!Array.isArray(sessions)) return [] + const SEP = '\n—\n' + return sessions + .filter((s) => Boolean(s?.sessionId)) + .map((s) => { + const nameNorm = normalizeForSearch(s.sessionName) + let bodyNorm = '' + if (Array.isArray(s.conversationRecords)) { + bodyNorm = s.conversationRecords + .map((r) => `${normalizeForSearch(r?.question)} ${normalizeForSearch(r?.answer)}`) + .join(SEP) + } + return { session: s, nameNorm, bodyNorm } + }) + }, [sessions]) + + // Filter sessions based on search query using the precomputed index + const filteredSessions = useMemo(() => { + const q = normalizeForSearch(debouncedQuery).trim() + if (!q) return normalizedIndex.map((i) => i.session) + return normalizedIndex + .filter((i) => i.nameNorm.includes(q) || i.bodyNorm.includes(q)) + .map((i) => i.session) + }, [normalizedIndex, debouncedQuery]) + return (
-
+
-

-
- {sessions.map( - ( - session, - index, // TODO editable session name - ) => ( +
+ setForceExpand(true)} + onBlur={() => setForceExpand(false)} + /> +
+
+
+ {filteredSessions.length === 0 && debouncedQuery.trim().length > 0 && ( +
+ {t('No conversations found')} +
+ )} + {filteredSessions.map((session) => ( +
- ), - )} +
+ ))}

@@ -164,8 +378,8 @@ function App() {
- {renderContent && currentSession && currentSession.conversationRecords && ( -
+ {currentSession && currentSession.conversationRecords && ( +
Date: Fri, 26 Sep 2025 03:12:56 +0800 Subject: [PATCH 2/2] Harden delete confirmation accessibility --- src/components/DeleteButton/index.jsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/DeleteButton/index.jsx b/src/components/DeleteButton/index.jsx index de683e2a..1d35c7fc 100644 --- a/src/components/DeleteButton/index.jsx +++ b/src/components/DeleteButton/index.jsx @@ -14,6 +14,14 @@ function DeleteButton({ onConfirm, size, text }) { const [waitConfirm, setWaitConfirm] = useState(false) const confirmRef = useRef(null) const [confirming, setConfirming] = useState(false) + const isMountedRef = useRef(true) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) useEffect(() => { if (waitConfirm) confirmRef.current.focus() @@ -31,12 +39,14 @@ function DeleteButton({ onConfirm, size, text }) { }} disabled={confirming} aria-busy={confirming ? 'true' : 'false'} + aria-hidden={waitConfirm ? undefined : 'true'} + tabIndex={waitConfirm ? 0 : -1} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() }} onBlur={() => { - if (!confirming) setWaitConfirm(false) + if (!confirming && isMountedRef.current) setWaitConfirm(false) }} onClick={async (e) => { if (confirming) return @@ -45,13 +55,13 @@ function DeleteButton({ onConfirm, size, text }) { setConfirming(true) try { await onConfirm() - setWaitConfirm(false) + if (isMountedRef.current) setWaitConfirm(false) } catch (err) { // Keep confirmation visible to allow retry; optionally log // eslint-disable-next-line no-console console.error(err) } finally { - setConfirming(false) + if (isMountedRef.current) setConfirming(false) } }} >