diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 8fe4b3c240..6c221c52e5 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -10,7 +10,6 @@ import { IconFolderPlus, IconMessage, IconApps, - IconX, IconSearch, IconClipboardSmile, IconFolder, @@ -37,6 +36,7 @@ import { useThreadManagement } from '@/hooks/useThreadManagement' import { useTranslation } from '@/i18n/react-i18next-compat' import { useMemo, useState, useEffect, useRef } from 'react' import { toast } from 'sonner' +import SearchDialog from '@/containers/dialogs/SearchDialog' import { DownloadManagement } from '@/containers/DownloadManegement' import { useSmallScreen } from '@/hooks/useMediaQuery' import { useClickOutside } from '@/hooks/useClickOutside' @@ -86,15 +86,15 @@ const LeftPanel = () => { const setLeftPanel = useLeftPanel((state) => state.setLeftPanel) const { t } = useTranslation() const navigate = useNavigate() - const [searchTerm, setSearchTerm] = useState('') + // Search/filter removed — show everything const { isAuthenticated } = useAuth() const isSmallScreen = useSmallScreen() const prevScreenSizeRef = useRef(null) const isInitialMountRef = useRef(true) const panelRef = useRef(null) - const searchContainerRef = useRef(null) - const searchContainerMacRef = useRef(null) + const searchContainerRef = useRef(null) + const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false) // Determine if we're in a resizable context (large screen with panel open) const isResizableContext = !isSmallScreen && open @@ -107,11 +107,7 @@ const LeftPanel = () => { } }, null, - [ - panelRef.current, - searchContainerRef.current, - searchContainerMacRef.current, - ] + [panelRef.current, searchContainerRef.current] ) // Auto-collapse panel only when window is resized @@ -160,7 +156,7 @@ const LeftPanel = () => { const deleteAllThreads = useThreads((state) => state.deleteAllThreads) const unstarAllThreads = useThreads((state) => state.unstarAllThreads) - const getFilteredThreads = useThreads((state) => state.getFilteredThreads) + // filtering removed; no longer using getFilteredThreads const threads = useThreads((state) => state.threads) const { folders, addFolder, updateFolder, getFolderById } = @@ -177,17 +173,15 @@ const LeftPanel = () => { null ) + // Show all threads (no filtering) const filteredThreads = useMemo(() => { - return getFilteredThreads(searchTerm) + // threads is an object/map in the store; convert to array + return Object.values(threads) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getFilteredThreads, searchTerm, threads]) + }, [threads]) - const filteredProjects = useMemo(() => { - if (!searchTerm) return folders - return folders.filter((folder) => - folder.name.toLowerCase().includes(searchTerm.toLowerCase()) - ) - }, [folders, searchTerm]) + // Show all projects (no filtering) + const filteredProjects = useMemo(() => folders, [folders]) // Memoize categorized threads based on filteredThreads const favoritedThreads = useMemo(() => { @@ -245,10 +239,7 @@ const LeftPanel = () => { className="fixed inset-0 bg-black/50 backdrop-blur z-30" onClick={(e) => { // Don't close if clicking on search container or if currently searching - if ( - searchContainerRef.current?.contains(e.target as Node) || - searchContainerMacRef.current?.contains(e.target as Node) - ) { + if (searchContainerRef.current?.contains(e.target as Node)) { return } setLeftPanel(false) @@ -283,72 +274,12 @@ const LeftPanel = () => { - {!IS_MACOS && ( -
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- )} + {/* Search bar moved below the main menu (under New Chat) for all platforms */}
- {IS_MACOS && ( -
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- )} + {mainMenus.map((menu) => { if (!menu.isEnabled) { @@ -400,6 +331,22 @@ const LeftPanel = () => { ) })} +
+ + +
{filteredProjects.length > 0 && !(IS_IOS || IS_ANDROID) && ( @@ -578,25 +525,9 @@ const LeftPanel = () => {
)} - {filteredThreads.length === 0 && searchTerm.length > 0 && ( -
- - {t('common:recents')} - - -
- -
- {t('common:noResultsFound')} -
-
-

- {t('common:noResultsFoundDesc')} -

-
- )} + - {Object.keys(threads).length === 0 && !searchTerm && ( + {Object.keys(threads).length === 0 && ( <>
@@ -687,6 +618,7 @@ const LeftPanel = () => { deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined } /> + ) } diff --git a/web-app/src/containers/dialogs/SearchDialog.tsx b/web-app/src/containers/dialogs/SearchDialog.tsx new file mode 100644 index 0000000000..4076e34043 --- /dev/null +++ b/web-app/src/containers/dialogs/SearchDialog.tsx @@ -0,0 +1,499 @@ +import { useState, useMemo } from 'react' +import { Link } from '@tanstack/react-router' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useThreadManagement } from '@/hooks/useThreadManagement' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' +import { IconCheck } from '@tabler/icons-react' +import { useThreads } from '@/hooks/useThreads' +import { useMessages } from '@/hooks/useMessages' +import { useTranslation } from '@/i18n/react-i18next-compat' + +interface SearchDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +// Simple calendar date range picker (no external deps). +function CalendarPicker({ + startIso, + endIso, + onChange, +}: { + startIso: string + endIso: string + onChange: (start: string, end: string) => void +}) { + const toDate = (iso: string) => (iso ? new Date(iso + 'T00:00:00') : null) + const startDate = toDate(startIso) + const endDate = toDate(endIso) + + const today = new Date() + // Always start calendar view from today for better UX (even if startIso represents epoch) + const [viewYear, setViewYear] = useState(today.getFullYear()) + const [viewMonth, setViewMonth] = useState(today.getMonth()) + + const firstOfMonth = new Date(viewYear, viewMonth, 1) + const startWeekDay = firstOfMonth.getDay() // 0..6 (Sun..Sat) + + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + + const cells: (Date | null)[] = [] + for (let i = 0; i < startWeekDay; i++) cells.push(null) + for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(viewYear, viewMonth, d)) + + const isoFor = (d: Date) => `${d.getFullYear().toString().padStart(4, '0')}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}` + + const isBetween = (d: Date) => { + if (!d) return false + if (!startDate && !endDate) return false + const t = d.getTime() + const s = startDate ? startDate.getTime() : 0 + const e = endDate ? new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate(), 23, 59, 59, 999).getTime() : Infinity + return t >= s && t <= e + } + + const handleDayClick = (d: Date | null) => { + if (!d) return + const iso = isoFor(d) + // selection logic: if neither set -> set start; if start set and end not set -> if clicked >= start -> set end else set start; if both set -> start=clicked, end='' + if (!startIso) return onChange(iso, '') + if (startIso && !endIso) { + const s = new Date(startIso + 'T00:00:00').getTime() + const t = d.getTime() + if (t >= s) return onChange(startIso, iso) + return onChange(iso, '') + } + // both set -> start = clicked, clear end + return onChange(iso, '') + } + + // Right-click sets the end date directly (context menu). We prevent default context menu. + const handleDayRightClick = (d: Date | null) => { + if (!d) return + const iso = isoFor(d) + // Keep existing startIso, set end to clicked date + return onChange(startIso || '', iso) + } + + return ( +
+
+ +
{firstOfMonth.toLocaleString(undefined, { month: 'long', year: 'numeric' })}
+ +
+ +
+ {['Su','Mo','Tu','We','Th','Fr','Sa'].map((d) => ( +
{d}
+ ))} +
+ +
+ {cells.map((cell, idx) => { + if (!cell) return
+ const iso = isoFor(cell) + const selectedStart = startIso === iso + const selectedEnd = endIso === iso + const between = isBetween(cell) + return ( + + ) + })} +
+
+ ) +} + +export default function SearchDialog({ open, onOpenChange }: SearchDialogProps) { + const { t } = useTranslation() + const [query, setQuery] = useState('') + const [selectedProjects, setSelectedProjects] = useState([]) + + const threadsMap = useThreads((s) => s.threads) + + const allThreads = useMemo(() => Object.values(threadsMap), [threadsMap]) + + const { folders } = useThreadManagement() + const getMessages = useMessages((s) => s.getMessages) + // date range state: start defaults to Timestamp(0), end defaults to infinite (empty) + const [startDateIso, setStartDateIso] = useState('1970-01-01') + const [endDateIso, setEndDateIso] = useState('') + + const results = useMemo(() => { + // compute date bounds in ms + const startTs = startDateIso ? new Date(startDateIso + 'T00:00:00').getTime() : 0 + const endTs = endDateIso ? new Date(endDateIso + 'T23:59:59.999').getTime() : Infinity + // First filter by selected projects (if any) + let source = allThreads + if (selectedProjects.length > 0) { + const setIds = new Set(selectedProjects) + source = allThreads.filter((th: any) => setIds.has(th.metadata?.project?.id)) + } + + if (!query && startTs === 0 && endTs === Infinity) return source + const q = query.toLowerCase() + return source.filter((t: any) => { + let latestTime = NaN + const cand = t.updated + if (cand) { + let tt = NaN + if (typeof cand === 'number') tt = cand + else if (typeof cand === 'string') { + if (/^\d+$/.test(cand)) tt = Number(cand) + else { + const d = new Date(cand) + if (!isNaN(d.getTime())) tt = d.getTime() + } + } + if (!isNaN(tt)) { + if (tt < 1e12) tt = tt * 1000 + latestTime = tt + } + } + // if no timestamp, treat as 0 + if (isNaN(latestTime)) latestTime = 0 + // filter by date range + if (latestTime < startTs || latestTime > endTs) return false + const title = (t.title || t.name || t.metadata?.title || '').toString().toLowerCase() + if (title.includes(q)) return true + + // build a content string from common locations (messages, preview, content fields) + let content = '' + if (t.preview) content += ' ' + String(t.preview) + if (t.content) content += ' ' + String(t.content) + const threadMessages = (t.messages && Array.isArray(t.messages)) ? t.messages : (getMessages ? getMessages(t.id) : []) + if (threadMessages && Array.isArray(threadMessages) && threadMessages.length > 0) { + const extractText = (m: any) => { + if (!m) return '' + // If message has a plain text field + if (typeof m.text === 'string') return m.text + if (typeof m.content === 'string') return m.content + // If content is an array of blocks: [{ text, value }] + if (Array.isArray(m.content)) { + try { + return m.content + .filter((b: any) => b && (b.type === 'text' || b.type === 'Text' || !b.type)) + .map((b: any) => { + // preferred path: block.text.value + if (b?.text && typeof b.text === 'object' && typeof b.text.value === 'string') return b.text.value + // fallback: block.value + if (typeof b.value === 'string') return b.value + // if block itself is a string + if (typeof b === 'string') return b + return '' + }) + .filter(Boolean) + .join(' ') + } catch { + return '' + } + } + // Fallback to JSON-stringify small content + try { + return JSON.stringify(m.content || m) + } catch { + return '' + } + } + + content += ' ' + threadMessages.map((m: any) => String(extractText(m) || '')).join(' ') + } + // metadata fields + if (t.metadata) { + try { + content += ' ' + JSON.stringify(t.metadata) + } catch {} + } + + return content.toLowerCase().includes(q) + }) + }, [allThreads, query, selectedProjects, startDateIso, endDateIso]) + + // Format YYYY-MM-DD ISO string into a locale-friendly date (dd/mm/yyyy where appropriate) + const formatIso = (iso: string) => { + if (!iso) return '' + try { + const d = new Date(iso + 'T00:00:00') + return d.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' }) + } catch { + return iso + } + } + + return ( + + + + {t('common:search')} + + +
+
+ setQuery(e.target.value)} + placeholder={t('common:search')} + autoFocus + /> +
+ + {/* Project filter dropdown placed after the input */} + {folders && folders.length > 0 && ( +
+
+ + + + + + {folders.map((f: any) => { + const selected = selectedProjects.includes(f.id) + return ( + { + e.preventDefault() + setSelectedProjects((prev) => { + if (prev.includes(f.id)) return prev.filter((id) => id !== f.id) + return [...prev, f.id] + }) + }} + > +
+ {f.name} + {selected && } +
+
+ ) + })} + {selectedProjects.length > 0 && ( + { + e.preventDefault() + setSelectedProjects([]) + }} + > + {t('common:clear')} + + )} +
+
+
+ + {/* Separate dropdown for the calendar picker */} +
+ + + + + +
+ { + setStartDateIso(s) + setEndDateIso(e) + }} + /> +
+ +
+ +
+
+
+
+
+ )} + +
+ {results.length === 0 ? ( +
{t('common:noResultsFound')}
+ ) : ( + results.map((thread: any) => { + const title = thread.title || thread.metadata?.title || thread.id + let dateStr = 'undefined' + let latestTime = NaN + const cand = thread.updated + if (cand) { + let t = NaN + if (typeof cand === 'number') t = cand + else if (typeof cand === 'string') { + if (/^\d+$/.test(cand)) t = Number(cand) + else { + const d = new Date(cand) + if (!isNaN(d.getTime())) t = d.getTime() + } + } + if (!isNaN(t)) { + if (t < 1e12) t = t * 1000 + latestTime = t + } + } + + if (!isNaN(latestTime)) { + dateStr = new Date(latestTime).toLocaleString() + } + + // build content for snippet + let content = '' + if (thread.preview) content += ' ' + String(thread.preview) + if (thread.content) content += ' ' + String(thread.content) + const threadMessages = (thread.messages && Array.isArray(thread.messages)) ? thread.messages : (getMessages ? getMessages(thread.id) : []) + if (threadMessages && Array.isArray(threadMessages) && threadMessages.length > 0) { + const extractText = (m: any) => { + if (!m) return '' + if (typeof m.text === 'string') return m.text + if (typeof m.content === 'string') return m.content + if (Array.isArray(m.content)) { + try { + return m.content + .filter((b: any) => b && (b.type === 'text' || b.type === 'Text' || !b.type)) + .map((b: any) => { + if (b?.text && typeof b.text === 'object' && typeof b.text.value === 'string') return b.text.value + if (typeof b.value === 'string') return b.value + if (typeof b === 'string') return b + return '' + }) + .filter(Boolean) + .join(' ') + } catch { + return '' + } + } + try { + return JSON.stringify(m.content || m) + } catch { + return '' + } + } + content += ' ' + threadMessages.map((m: any) => String(extractText(m) || '')).join(' ') + } + + const q = query.trim() + const contentLower = content.toLowerCase() + let snippet = '' + if (q) { + const idx = contentLower.indexOf(q.toLowerCase()) + if (idx !== -1) { + const SNIP_LEN = 80 + const start = Math.max(0, idx - Math.floor(SNIP_LEN / 3)) + const end = Math.min(content.length, start + SNIP_LEN) + snippet = (start > 0 ? '... ' : '') + content.slice(start, end) + (end < content.length ? ' ...' : '') + } + } + + // highlight helper + const renderHighlighted = (text: string, q: string) => { + if (!q) return text + const parts = text.split(new RegExp(`(${q.replace(/[-\\/\\^$*+?.()|[\]{}]/g, '\\$&')})`, 'ig')) + return parts.map((part, i) => + part.toLowerCase() === q.toLowerCase() ? ( + {part} + ) : ( + {part} + ) + ) + } + + return ( +
+ onOpenChange(false)} + className="block px-2 py-1 rounded hover:bg-main-view-fg/10" + > +
{renderHighlighted(title, q)}
+ {dateStr && ( +
+ {q && dateStr.toLowerCase().includes(q.toLowerCase()) ? renderHighlighted(dateStr, q) : dateStr} +
+ )} + {snippet ? ( +
{renderHighlighted(snippet, q)}
+ ) : ( + thread.preview && ( +
{thread.preview}
+ ) + )} + +
+ ) + }) + )} +
+
+
+
+ ) +} diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index 699c15a08c..5e76adcf94 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -129,6 +129,7 @@ "enterApiKey": "API Key eingeben", "scrollToBottom": "Zum Ende scrollen", "generateAiResponse": "KI-Antwort generieren", + "clear": "Löschen", "addModel": { "title": "Modell hinzufügen", "modelId": "Modell ID", @@ -277,7 +278,10 @@ "update": "Aktualisieren", "searchProjects": "Projekte durchsuchen...", "noProjectsFound": "Keine Projekte gefunden", - "tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff" + "tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff", + "selected": "ausgewählt", + "filterByProject": "Nach Projekten filtern", + "filterByDate": "Nach Datum filtern" }, "toast": { "allThreadsUnfavorited": { diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index e39465545b..9ef59db6cd 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -135,6 +135,7 @@ "enterApiKey": "Enter API Key", "scrollToBottom": "Scroll to bottom", "generateAiResponse": "Generate AI Response", + "clear": "Clear", "addModel": { "title": "Add Model", "modelId": "Model ID", @@ -287,7 +288,10 @@ "update": "Update", "searchProjects": "Search projects...", "noProjectsFound": "No projects found", - "tryDifferentSearch": "Try a different search term" + "tryDifferentSearch": "Try a different search term", + "filterByProject": "Filter by project", + "filterByDate": "Filter by date", + "selected": "selected" }, "toast": { "allThreadsUnfavorited": { diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index 77af93d319..1879a1213a 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -129,6 +129,7 @@ "enterApiKey": "Masukkan Kunci API", "scrollToBottom": "Gulir ke bawah", "generateAiResponse": "Hasilkan Respons AI", + "clear": "Bersihkan", "addModel": { "title": "Tambah Model", "modelId": "ID Model", @@ -359,6 +360,9 @@ "update": "Perbarui", "searchProjects": "Cari proyek...", "noProjectsFound": "Tidak ada proyek ditemukan", - "tryDifferentSearch": "Coba kata kunci pencarian lain" + "tryDifferentSearch": "Coba kata kunci pencarian lain", + "filterByProject": "Saring berdasarkan proyek", + "filterByDate": "Filter berdasarkan tanggal", + "selected": "dipilih" } } diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index ee25f60680..7a0b764a68 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -129,6 +129,7 @@ "enterApiKey": "Wprowadź klucz API", "scrollToBottom": "Przewiń na sam dół", "generateAiResponse": "Wygeneruj odpowiedź SI", + "clear": "Wyczyść", "addModel": { "title": "Dodaj Model", "modelId": "Identyfikator Modelu", @@ -277,7 +278,10 @@ "update": "Aktualizuj", "searchProjects": "Szukaj projektów...", "noProjectsFound": "Nie znaleziono projektów", - "tryDifferentSearch": "Spróbuj innego wyszukiwania" + "tryDifferentSearch": "Spróbuj innego wyszukiwania", + "filterByProject": "Filtruj według projektu", + "filterByDate": "Filtruj według daty", + "selected": "wybrano" }, "toast": { "allThreadsUnfavorited": { diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 28ddd29a7f..339c7909d5 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -128,6 +128,7 @@ "createAssistant": "Tạo trợ lý", "enterApiKey": "Nhập khóa API", "scrollToBottom": "Cuộn xuống dưới cùng", + "clear": "Xóa", "addModel": { "title": "Thêm mô hình", "modelId": "ID mô hình", @@ -226,7 +227,10 @@ "noProjectsAvailable": "Không có dự án nào", "searchProjects": "Tìm kiếm dự án...", "noProjectsFound": "Không tìm thấy dự án nào", - "tryDifferentSearch": "Thử từ khóa tìm kiếm khác" + "tryDifferentSearch": "Thử từ khóa tìm kiếm khác", + "filterByProject": "Lọc theo dự án", + "filterByDate": "Lọc theo ngày", + "selected": "đã chọn" }, "dialogs": { "changeDataFolder": { diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index 69b15ac90d..47809cb011 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -128,6 +128,7 @@ "createAssistant": "创建助手", "enterApiKey": "输入 API 密钥", "scrollToBottom": "滚动到底部", + "clear": "清除", "addModel": { "title": "添加模型", "modelId": "模型 ID", @@ -226,7 +227,10 @@ "noProjectsAvailable": "没有可用的项目", "searchProjects": "搜索项目...", "noProjectsFound": "未找到项目", - "tryDifferentSearch": "尝试不同的搜索词" + "tryDifferentSearch": "尝试不同的搜索词", + "filterByProject": "按项目过滤", + "filterByDate": "按日期过滤", + "selected": "已选择" }, "dialogs": { "changeDataFolder": { diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index 809ac0cd4b..d4d3b93063 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -128,6 +128,7 @@ "createAssistant": "建立助理", "enterApiKey": "輸入 API 金鑰", "scrollToBottom": "滾動到底部", + "clear": "清除", "addModel": { "title": "新增模型", "modelId": "模型 ID", @@ -226,7 +227,10 @@ "noProjectsAvailable": "沒有可用的專案", "searchProjects": "搜尋專案...", "noProjectsFound": "找不到專案", - "tryDifferentSearch": "嘗試不同的搜尋詞" + "tryDifferentSearch": "嘗試不同的搜尋詞", + "filterByProject": "按專案篩選", + "filterByDate": "按日期篩選", + "selected": "已選擇" }, "dialogs": { "changeDataFolder": {