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..1d35c7fc 100644 --- a/src/components/DeleteButton/index.jsx +++ b/src/components/DeleteButton/index.jsx @@ -13,6 +13,15 @@ function DeleteButton({ onConfirm, size, text }) { const { t } = useTranslation() 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() @@ -28,16 +37,32 @@ function DeleteButton({ onConfirm, size, text }) { fontSize: '10px', ...(waitConfirm ? {} : { display: 'none' }), }} + disabled={confirming} + aria-busy={confirming ? 'true' : 'false'} + aria-hidden={waitConfirm ? undefined : 'true'} + tabIndex={waitConfirm ? 0 : -1} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() }} onBlur={() => { - setWaitConfirm(false) + if (!confirming && isMountedRef.current) setWaitConfirm(false) }} - onClick={() => { - setWaitConfirm(false) - onConfirm() + onClick={async (e) => { + if (confirming) return + e.preventDefault() + e.stopPropagation() + setConfirming(true) + try { + await onConfirm() + 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 { + if (isMountedRef.current) setConfirming(false) + } }} > {t('Confirm')} @@ -45,8 +70,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 && ( +