From e2e21346813d09a6f2729c66a1b1124061ec5da2 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Thu, 12 Mar 2026 22:25:13 -0300 Subject: [PATCH 01/29] feat: add full-text session search to project view Adds a search bar to the project sessions page that searches through all message content in session JSONL files, not just the first message. - New Rust backend command `search_project_sessions` with case-insensitive full-text search across all session messages - Web server route GET /api/projects/{id}/sessions/search?q={query} - Frontend search UI with debounced input, loading state, and result count - Pagination updates to reflect filtered results Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 113 +++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 3 +- src-tauri/src/web_server.rs | 19 +++++- src/App.tsx | 1 + src/components/SessionList.tsx | 101 +++++++++++++++++++++++++-- src/components/TabContent.tsx | 1 + src/lib/api.ts | 15 ++++ src/lib/apiAdapter.ts | 1 + 8 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 529eb1a2a..5096813b2 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -555,6 +555,119 @@ pub async fn get_project_sessions(project_id: String) -> Result, St Ok(sessions) } +/// Checks if any message in a JSONL session file contains the query (case-insensitive) +fn session_contains_query(jsonl_path: &PathBuf, query_lower: &str) -> bool { + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return false, + }; + + let reader = BufReader::new(file); + + for line in reader.lines() { + if let Ok(line) = line { + if let Ok(entry) = serde_json::from_str::(&line) { + if let Some(message) = entry.message { + if let Some(content) = message.content { + if content.to_lowercase().contains(query_lower) { + return true; + } + } + } + } + } + } + + false +} + +/// Searches through all session JSONL files in a project for messages containing the query +#[tauri::command] +pub async fn search_project_sessions( + project_id: String, + query: String, +) -> Result, String> { + log::info!( + "Searching sessions for project: {} with query: {}", + project_id, + query + ); + + let query_lower = query.to_lowercase(); + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let project_dir = claude_dir.join("projects").join(&project_id); + let todos_dir = claude_dir.join("todos"); + + if !project_dir.exists() { + return Err(format!("Project directory not found: {}", project_id)); + } + + let project_path = match get_project_path_from_sessions(&project_dir) { + Ok(path) => path, + Err(_) => decode_project_path(&project_id), + }; + + let mut sessions = Vec::new(); + + let entries = fs::read_dir(&project_dir) + .map_err(|e| format!("Failed to read project directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { + if !session_contains_query(&path, &query_lower) { + continue; + } + + let metadata = fs::metadata(&path) + .map_err(|e| format!("Failed to read file metadata: {}", e))?; + + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let (first_message, message_timestamp) = extract_first_user_message(&path); + + let todo_path = todos_dir.join(format!("{}.json", session_id)); + let todo_data = if todo_path.exists() { + fs::read_to_string(&todo_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + sessions.push(Session { + id: session_id.to_string(), + project_id: project_id.clone(), + project_path: project_path.clone(), + todo_data, + created_at, + first_message, + message_timestamp, + }); + } + } + } + + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + log::info!( + "Found {} matching sessions for project {} with query '{}'", + sessions.len(), + project_id, + query + ); + Ok(sessions) +} + /// Reads the Claude settings file #[tauri::command] pub async fn get_claude_settings() -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adbcf..2b39de6f3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -22,7 +22,7 @@ use commands::claude::{ clear_checkpoint_manager, continue_claude_code, create_checkpoint, create_project, execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, - get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, + get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, search_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, @@ -188,6 +188,7 @@ fn main() { list_projects, create_project, get_project_sessions, + search_project_sessions, get_home_directory, get_claude_settings, open_new_session, diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 01a28436f..a2bb3be42 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -1,7 +1,7 @@ use axum::extract::ws::{Message, WebSocket}; use axum::http::Method; use axum::{ - extract::{Path, State as AxumState, WebSocketUpgrade}, + extract::{Path, Query, State as AxumState, WebSocketUpgrade}, response::{Html, Json, Response}, routing::get, Router, @@ -129,6 +129,22 @@ async fn get_sessions( } } +#[derive(Deserialize)] +struct SearchQuery { + q: String, +} + +/// API endpoint to search sessions for a project +async fn search_sessions( + Path(project_id): Path, + Query(params): Query, +) -> Json>> { + match commands::claude::search_project_sessions(project_id, params.q).await { + Ok(sessions) => Json(ApiResponse::success(sessions)), + Err(e) => Json(ApiResponse::error(e.to_string())), + } +} + /// Simple agents endpoint - return empty for now (needs DB state) async fn get_agents() -> Json>> { Json(ApiResponse::success(vec![])) @@ -786,6 +802,7 @@ pub async fn create_web_server(port: u16) -> Result<(), Box diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9575c015c..9d2a28212 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -1,12 +1,15 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Clock, MessageSquare } from "lucide-react"; +import { Clock, MessageSquare, Search, Loader2, X } from "lucide-react"; import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Pagination } from "@/components/ui/pagination"; import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown"; import { TooltipProvider } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { truncateText, getFirstLine } from "@/lib/date-utils"; +import { useDebounce } from "@/hooks/useDebounce"; +import { api } from "@/lib/api"; import type { Session, ClaudeMdFile } from "@/lib/api"; interface SessionListProps { @@ -14,6 +17,10 @@ interface SessionListProps { * Array of sessions to display */ sessions: Session[]; + /** + * The project ID (for search API calls) + */ + projectId: string; /** * The current project path being viewed */ @@ -51,27 +58,107 @@ const ITEMS_PER_PAGE = 12; */ export const SessionList: React.FC = ({ sessions, + projectId, projectPath, onSessionClick, onEditClaudeFile, className, }) => { const [currentPage, setCurrentPage] = useState(1); - + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); + const debouncedQuery = useDebounce(searchQuery, 300); + + // Perform search when debounced query changes + useEffect(() => { + if (!debouncedQuery.trim()) { + setSearchResults(null); + setSearching(false); + return; + } + + let cancelled = false; + setSearching(true); + + api.searchProjectSessions(projectId, debouncedQuery.trim()) + .then((results) => { + if (!cancelled) { + setSearchResults(results); + setCurrentPage(1); + } + }) + .catch((err) => { + console.error("Search failed:", err); + if (!cancelled) { + setSearchResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setSearching(false); + } + }); + + return () => { cancelled = true; }; + }, [debouncedQuery, projectId]); + + const displayedSessions = searchResults !== null ? searchResults : sessions; + // Calculate pagination - const totalPages = Math.ceil(sessions.length / ITEMS_PER_PAGE); + const totalPages = Math.ceil(displayedSessions.length / ITEMS_PER_PAGE); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; - const currentSessions = sessions.slice(startIndex, endIndex); - + const currentSessions = displayedSessions.slice(startIndex, endIndex); + // Reset to page 1 if sessions change - React.useEffect(() => { + useEffect(() => { setCurrentPage(1); }, [sessions.length]); + + const clearSearch = useCallback(() => { + setSearchQuery(""); + setSearchResults(null); + setCurrentPage(1); + }, []); return (
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9 h-9" + /> + {searchQuery && ( + + )} +
+ + {/* Search status */} + {searching && ( +
+ + Searching sessions... +
+ )} + {searchResults !== null && !searching && ( +

+ {searchResults.length === 0 + ? "No sessions found" + : `${searchResults.length} of ${sessions.length} sessions`} +

+ )} + {/* CLAUDE.md Memories Dropdown */} {onEditClaudeFile && ( = ({ tab, isActive }) => { {!loading && ( { // Update current tab to show the selected session diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..8032ffa86 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -502,6 +502,21 @@ export const api = { } }, + /** + * Searches through all session messages for a project + * @param projectId - The ID of the project + * @param query - The search query + * @returns Promise resolving to an array of matching sessions + */ + async searchProjectSessions(projectId: string, query: string): Promise { + try { + return await apiCall('search_project_sessions', { projectId, query }); + } catch (error) { + console.error("Failed to search project sessions:", error); + throw error; + } + }, + /** * Fetch list of agents from GitHub repository * @returns Promise resolving to list of available agents on GitHub diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index ece52c491..b80b91816 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -167,6 +167,7 @@ function mapCommandToEndpoint(command: string, _params?: any): string { // Project and session commands 'list_projects': '/api/projects', 'get_project_sessions': '/api/projects/{projectId}/sessions', + 'search_project_sessions': '/api/projects/{projectId}/sessions/search?q={query}', // Agent commands 'list_agents': '/api/agents', From 3cb95d626aa26c38fb188d02610aac3b03897199 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Thu, 12 Mar 2026 22:36:38 -0300 Subject: [PATCH 02/29] fix: address code review findings for session search - Add separate error state instead of masking failures as empty results - Use request ID ref to invalidate stale in-flight searches on query or project changes - Redact search query from info-level logs (log length only) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 9 ++++----- src/components/SessionList.tsx | 34 +++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 5096813b2..b525ffe54 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -588,9 +588,9 @@ pub async fn search_project_sessions( query: String, ) -> Result, String> { log::info!( - "Searching sessions for project: {} with query: {}", + "Searching sessions for project: {} (query length: {})", project_id, - query + query.len() ); let query_lower = query.to_lowercase(); @@ -660,10 +660,9 @@ pub async fn search_project_sessions( sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); log::info!( - "Found {} matching sessions for project {} with query '{}'", + "Found {} matching sessions for project {}", sessions.len(), - project_id, - query + project_id ); Ok(sessions) } diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9d2a28212..55badd05a 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Clock, MessageSquare, Search, Loader2, X } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -67,40 +67,52 @@ export const SessionList: React.FC = ({ const [currentPage, setCurrentPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); + const [searchError, setSearchError] = useState(null); const [searching, setSearching] = useState(false); const debouncedQuery = useDebounce(searchQuery, 300); + const searchRequestId = useRef(0); + + // Invalidate immediately when raw query or projectId changes + useEffect(() => { + searchRequestId.current += 1; + if (!searchQuery.trim()) { + setSearchResults(null); + setSearchError(null); + setSearching(false); + } + }, [searchQuery, projectId]); // Perform search when debounced query changes useEffect(() => { if (!debouncedQuery.trim()) { setSearchResults(null); + setSearchError(null); setSearching(false); return; } - let cancelled = false; + const requestId = ++searchRequestId.current; setSearching(true); + setSearchError(null); api.searchProjectSessions(projectId, debouncedQuery.trim()) .then((results) => { - if (!cancelled) { + if (searchRequestId.current === requestId) { setSearchResults(results); setCurrentPage(1); } }) .catch((err) => { console.error("Search failed:", err); - if (!cancelled) { - setSearchResults([]); + if (searchRequestId.current === requestId) { + setSearchError("Search failed. Please try again."); } }) .finally(() => { - if (!cancelled) { + if (searchRequestId.current === requestId) { setSearching(false); } }); - - return () => { cancelled = true; }; }, [debouncedQuery, projectId]); const displayedSessions = searchResults !== null ? searchResults : sessions; @@ -119,6 +131,7 @@ export const SessionList: React.FC = ({ const clearSearch = useCallback(() => { setSearchQuery(""); setSearchResults(null); + setSearchError(null); setCurrentPage(1); }, []); @@ -151,7 +164,10 @@ export const SessionList: React.FC = ({ Searching sessions...
)} - {searchResults !== null && !searching && ( + {searchError && !searching && ( +

{searchError}

+ )} + {searchResults !== null && !searching && !searchError && (

{searchResults.length === 0 ? "No sessions found" From f732b225059c3a04aae8e823fe4e9f781993dd81 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Thu, 12 Mar 2026 22:45:28 -0300 Subject: [PATCH 03/29] fix: address review pass 2 - spawn_blocking, path validation, stale results - Wrap blocking FS scan in tokio::task::spawn_blocking to avoid pinning async worker threads - Validate project_id is a single normal path component to prevent path traversal - Clear stale search results in the error handler Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 122 +++++++++++++++++-------------- src/components/SessionList.tsx | 2 + 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index b525ffe54..47be8adb1 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -593,78 +593,94 @@ pub async fn search_project_sessions( query.len() ); + // Validate project_id to prevent path traversal + let project_component = std::path::Path::new(&project_id); + if project_component.is_absolute() + || project_component.components().count() != 1 + || !matches!( + project_component.components().next(), + Some(std::path::Component::Normal(_)) + ) + { + return Err("Invalid project id".to_string()); + } + let query_lower = query.to_lowercase(); let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; - let project_dir = claude_dir.join("projects").join(&project_id); + let project_dir = claude_dir.join("projects").join(project_component); let todos_dir = claude_dir.join("todos"); if !project_dir.exists() { return Err(format!("Project directory not found: {}", project_id)); } - let project_path = match get_project_path_from_sessions(&project_dir) { - Ok(path) => path, - Err(_) => decode_project_path(&project_id), - }; - - let mut sessions = Vec::new(); + tokio::task::spawn_blocking(move || { + let project_path = match get_project_path_from_sessions(&project_dir) { + Ok(path) => path, + Err(_) => decode_project_path(&project_id), + }; - let entries = fs::read_dir(&project_dir) - .map_err(|e| format!("Failed to read project directory: {}", e))?; + let mut sessions = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; - let path = entry.path(); + let entries = fs::read_dir(&project_dir) + .map_err(|e| format!("Failed to read project directory: {}", e))?; - if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { - if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { - if !session_contains_query(&path, &query_lower) { - continue; - } - - let metadata = fs::metadata(&path) - .map_err(|e| format!("Failed to read file metadata: {}", e))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); - let created_at = metadata - .created() - .or_else(|_| metadata.modified()) - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { + if !session_contains_query(&path, &query_lower) { + continue; + } - let (first_message, message_timestamp) = extract_first_user_message(&path); + let metadata = fs::metadata(&path) + .map_err(|e| format!("Failed to read file metadata: {}", e))?; - let todo_path = todos_dir.join(format!("{}.json", session_id)); - let todo_data = if todo_path.exists() { - fs::read_to_string(&todo_path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) - } else { - None - }; + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); - sessions.push(Session { - id: session_id.to_string(), - project_id: project_id.clone(), - project_path: project_path.clone(), - todo_data, - created_at, - first_message, - message_timestamp, - }); + let (first_message, message_timestamp) = extract_first_user_message(&path); + + let todo_path = todos_dir.join(format!("{}.json", session_id)); + let todo_data = if todo_path.exists() { + fs::read_to_string(&todo_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + sessions.push(Session { + id: session_id.to_string(), + project_id: project_id.clone(), + project_path: project_path.clone(), + todo_data, + created_at, + first_message, + message_timestamp, + }); + } } } - } - sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - log::info!( - "Found {} matching sessions for project {}", - sessions.len(), - project_id - ); - Ok(sessions) + log::info!( + "Found {} matching sessions for project {}", + sessions.len(), + project_id + ); + Ok(sessions) + }) + .await + .map_err(|e| format!("Search task failed: {}", e))? } /// Reads the Claude settings file diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 55badd05a..a58e8a4cb 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -105,6 +105,8 @@ export const SessionList: React.FC = ({ .catch((err) => { console.error("Search failed:", err); if (searchRequestId.current === requestId) { + setSearchResults([]); + setCurrentPage(1); setSearchError("Search failed. Please try again."); } }) From a31ff9efb1689ae531f9cf930dc9be48861d7ba6 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Thu, 12 Mar 2026 22:53:55 -0300 Subject: [PATCH 04/29] fix: address review pass 3 - input validation and error fallback - Reject blank and oversized (>256 char) queries before scanning - Fall back to showing all sessions on search error instead of clearing the list Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 8 ++++++++ src/components/SessionList.tsx | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 47be8adb1..3f4a7a24c 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -587,6 +587,14 @@ pub async fn search_project_sessions( project_id: String, query: String, ) -> Result, String> { + let query = query.trim().to_string(); + if query.is_empty() { + return Ok(Vec::new()); + } + if query.len() > 256 { + return Err("Query is too long".to_string()); + } + log::info!( "Searching sessions for project: {} (query length: {})", project_id, diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index a58e8a4cb..bf53b5a61 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -105,8 +105,7 @@ export const SessionList: React.FC = ({ .catch((err) => { console.error("Search failed:", err); if (searchRequestId.current === requestId) { - setSearchResults([]); - setCurrentPage(1); + setSearchResults(null); setSearchError("Search failed. Please try again."); } }) From ca3282b9bcfe67b0cab83ae312c780bd2b68c06a Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Thu, 12 Mar 2026 22:59:48 -0300 Subject: [PATCH 05/29] fix: address review pass 4 - a11y, project switch, page reset, API envelope - Add aria-label to clear search button for screen readers - Hard reset search state on project switch to prevent stale cross-project results - Reset page on sessions array identity change, not just length - Make search query param optional with serde(default) to maintain consistent ApiResponse envelope Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/web_server.rs | 4 ++++ src/components/SessionList.tsx | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index a2bb3be42..3a8f3411f 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -131,6 +131,7 @@ async fn get_sessions( #[derive(Deserialize)] struct SearchQuery { + #[serde(default)] q: String, } @@ -139,6 +140,9 @@ async fn search_sessions( Path(project_id): Path, Query(params): Query, ) -> Json>> { + if params.q.is_empty() { + return Json(ApiResponse::error("Missing query parameter 'q'".to_string())); + } match commands::claude::search_project_sessions(project_id, params.q).await { Ok(sessions) => Json(ApiResponse::success(sessions)), Err(e) => Json(ApiResponse::error(e.to_string())), diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index bf53b5a61..81b00d6a0 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -72,7 +72,16 @@ export const SessionList: React.FC = ({ const debouncedQuery = useDebounce(searchQuery, 300); const searchRequestId = useRef(0); - // Invalidate immediately when raw query or projectId changes + // Hard reset when project context changes + useEffect(() => { + searchRequestId.current += 1; + setSearchResults(null); + setSearchError(null); + setSearching(false); + setCurrentPage(1); + }, [projectId]); + + // Invalidate when query is cleared useEffect(() => { searchRequestId.current += 1; if (!searchQuery.trim()) { @@ -80,7 +89,7 @@ export const SessionList: React.FC = ({ setSearchError(null); setSearching(false); } - }, [searchQuery, projectId]); + }, [searchQuery]); // Perform search when debounced query changes useEffect(() => { @@ -124,10 +133,10 @@ export const SessionList: React.FC = ({ const endIndex = startIndex + ITEMS_PER_PAGE; const currentSessions = displayedSessions.slice(startIndex, endIndex); - // Reset to page 1 if sessions change + // Reset to page 1 if sessions dataset changes useEffect(() => { setCurrentPage(1); - }, [sessions.length]); + }, [sessions, projectId]); const clearSearch = useCallback(() => { setSearchQuery(""); @@ -151,6 +160,7 @@ export const SessionList: React.FC = ({ {searchQuery && ( + + + )} + + + + ); + })} + + ) : ( + /* Default view: grid layout */ +

+ {currentSessions.map((session, index) => ( + + handleSessionClick(session)} + > +
+
+
+
+ +
+

+ Session on {formatSessionDate(session)} +

+
+
+ {session.todo_data && ( + + Todo + + )} +
+ {session.first_message ? ( +

+ {truncateText(getFirstLine(session.first_message), 120)} +

+ ) : ( +

+ No messages yet +

)}
- - {/* First message preview */} - {session.first_message ? ( -

- {truncateText(getFirstLine(session.first_message), 120)} +

+

+ {session.id.slice(-8)}

- ) : ( -

- No messages yet -

- )} -
- - {/* Metadata footer */} -
-

- {session.id.slice(-8)} -

- {session.todo_data && ( - - )} + {session.todo_data && ( + + )} +
-
- - - ))} - + + + ))} + + )} - + = ({
); -}; \ No newline at end of file +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 8032ffa86..e49e16f30 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -53,6 +53,11 @@ export interface Session { message_timestamp?: string; } +export interface SessionSearchResult extends Session { + /** Matching text snippets from the session */ + snippets: string[]; +} + /** * Represents the settings from ~/.claude/settings.json */ @@ -508,9 +513,9 @@ export const api = { * @param query - The search query * @returns Promise resolving to an array of matching sessions */ - async searchProjectSessions(projectId: string, query: string): Promise { + async searchProjectSessions(projectId: string, query: string): Promise { try { - return await apiCall('search_project_sessions', { projectId, query }); + return await apiCall('search_project_sessions', { projectId, query }); } catch (error) { console.error("Failed to search project sessions:", error); throw error; From de5724f65875d649b4c3f8316fd65330b76e439b Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 07:49:05 -0300 Subject: [PATCH 12/29] fix: accordion accessibility and symlink hardening - Use semantic button element for accordion toggle with aria-expanded and aria-controls - Skip symlinked session files during search scan Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 8 +++++++- src/components/SessionList.tsx | 11 +++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 5b176ed96..2d2bea0f6 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -682,9 +682,15 @@ pub async fn search_project_sessions( for entry in entries { let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let file_type = entry + .file_type() + .map_err(|e| format!("Failed to read entry type: {}", e))?; + if file_type.is_symlink() || !file_type.is_file() { + continue; + } let path = entry.path(); - if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { let snippets = extract_matching_snippets(&path, &query_lower); if snippets.is_empty() { diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 14dc6acd5..15456f33e 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -272,9 +272,12 @@ export const SessionList: React.FC = ({ session.todo_data && "bg-primary/5" )}> {/* Accordion header */} -
toggleExpanded(session.id)} + aria-expanded={isExpanded} + aria-controls={`session-snippets-${session.id}`} > = ({ {snippets.length} match{snippets.length !== 1 ? 'es' : ''} -
+ {/* Accordion content: matching snippets */} @@ -316,7 +319,7 @@ export const SessionList: React.FC = ({ transition={{ duration: 0.2 }} className="overflow-hidden" > -
+
{snippets.map((snippet, i) => (
Date: Fri, 13 Mar 2026 08:01:29 -0300 Subject: [PATCH 13/29] fix: Unicode-safe highlighting, deduplicate click paths - Use RegExp with gi flags for Unicode-safe text highlighting - Use consistent lowered string for snippet extraction offsets - Deduplicate session click handlers (prefer prop over event) Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 26 +++++++++++++++----------- src/components/SessionList.tsx | 31 ++++++++++++++++--------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 2d2bea0f6..43b679e84 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -578,6 +578,7 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec= MAX_SNIPPETS { @@ -587,33 +588,36 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec(&line) { if let Some(message) = entry.message { if let Some(content) = message.content { + // Work entirely on the lowered string for consistent offsets let content_lower = content.to_lowercase(); - // Find all occurrences in this message let mut search_from = 0; while let Some(pos) = content_lower[search_from..].find(query_lower) { if snippets.len() >= MAX_SNIPPETS { break; } let abs_pos = search_from + pos; - // Extract a window around the match + // Extract window on the lowered string (offsets are consistent) let start = abs_pos.saturating_sub(CONTEXT_CHARS); - let end = (abs_pos + query_lower.len() + CONTEXT_CHARS).min(content.len()); - // Align to char boundaries - let start = content.floor_char_boundary(start); - let end = content.ceil_char_boundary(end); - let mut snippet = content[start..end].to_string(); - // Clean up: collapse whitespace and trim - snippet = snippet.split_whitespace().collect::>().join(" "); + let end = + (abs_pos + query_len + CONTEXT_CHARS).min(content_lower.len()); + let start = content_lower.floor_char_boundary(start); + let end = content_lower.ceil_char_boundary(end); + // Use the lowered string for the snippet text to keep offset consistency + // but preserve readability by extracting from original where possible. + // Since lowercase can change length, use the lowered text for slicing. + let raw = &content_lower[start..end]; + let mut snippet = + raw.split_whitespace().collect::>().join(" "); if start > 0 { snippet = format!("...{}", snippet); } - if end < content.len() { + if end < content_lower.len() { snippet = format!("{}...", snippet); } if !snippet.is_empty() { snippets.push(snippet); } - search_from = abs_pos + query_lower.len(); + search_from = abs_pos + query_len; } } } diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 15456f33e..f7fc13e98 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -24,26 +24,25 @@ interface SessionListProps { const ITEMS_PER_PAGE = 12; -/** Highlights all occurrences of `query` in `text` (case-insensitive) */ +/** Highlights all occurrences of `query` in `text` (case-insensitive, Unicode-safe) */ function HighlightedText({ text, query }: { text: string; query: string }) { if (!query.trim()) return <>{text}; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(escaped, 'gi'); const parts: React.ReactNode[] = []; - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); let lastIndex = 0; + let match: RegExpExecArray | null; - let pos = lowerText.indexOf(lowerQuery, lastIndex); - while (pos !== -1) { - if (pos > lastIndex) { - parts.push(text.slice(lastIndex, pos)); + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); } parts.push( - - {text.slice(pos, pos + query.length)} + + {match[0]} ); - lastIndex = pos + query.length; - pos = lowerText.indexOf(lowerQuery, lastIndex); + lastIndex = match.index + match[0].length; } if (lastIndex < text.length) { @@ -180,11 +179,13 @@ export const SessionList: React.FC = ({ }, []); const handleSessionClick = useCallback((session: Session) => { - const event = new CustomEvent('claude-session-selected', { + if (onSessionClick) { + onSessionClick(session); + return; + } + window.dispatchEvent(new CustomEvent('claude-session-selected', { detail: { session, projectPath } - }); - window.dispatchEvent(event); - onSessionClick?.(session); + })); }, [projectPath, onSessionClick]); return ( From 30706705cf6f3108dbb841c62a41687dcd06b114 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:08:40 -0300 Subject: [PATCH 14/29] fix: preserve original casing in search snippets Use char-by-char case-insensitive matching on original content instead of slicing from lowercased string, preserving readability in search result snippets. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 67 +++++++++++++++++++------------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 43b679e84..5cbc9f0d8 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -578,7 +578,6 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec= MAX_SNIPPETS { @@ -588,36 +587,50 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec(&line) { if let Some(message) = entry.message { if let Some(content) = message.content { - // Work entirely on the lowered string for consistent offsets - let content_lower = content.to_lowercase(); - let mut search_from = 0; - while let Some(pos) = content_lower[search_from..].find(query_lower) { + // Find matches by comparing chars case-insensitively + // to preserve original casing in snippets + let chars_orig: Vec<(usize, char)> = + content.char_indices().collect(); + let query_chars: Vec = query_lower.chars().collect(); + let mut ci = 0; + while ci + query_chars.len() <= chars_orig.len() { if snippets.len() >= MAX_SNIPPETS { break; } - let abs_pos = search_from + pos; - // Extract window on the lowered string (offsets are consistent) - let start = abs_pos.saturating_sub(CONTEXT_CHARS); - let end = - (abs_pos + query_len + CONTEXT_CHARS).min(content_lower.len()); - let start = content_lower.floor_char_boundary(start); - let end = content_lower.ceil_char_boundary(end); - // Use the lowered string for the snippet text to keep offset consistency - // but preserve readability by extracting from original where possible. - // Since lowercase can change length, use the lowered text for slicing. - let raw = &content_lower[start..end]; - let mut snippet = - raw.split_whitespace().collect::>().join(" "); - if start > 0 { - snippet = format!("...{}", snippet); - } - if end < content_lower.len() { - snippet = format!("{}...", snippet); - } - if !snippet.is_empty() { - snippets.push(snippet); + let matched = chars_orig[ci..ci + query_chars.len()] + .iter() + .zip(&query_chars) + .all(|((_, c), q)| { + c.to_lowercase().eq(q.to_lowercase()) + }); + if matched { + // Context window in chars + let ctx_start_ci = ci.saturating_sub(CONTEXT_CHARS); + let ctx_end_ci = + (ci + query_chars.len() + CONTEXT_CHARS).min(chars_orig.len()); + let start = chars_orig[ctx_start_ci].0; + let end = if ctx_end_ci < chars_orig.len() { + chars_orig[ctx_end_ci].0 + } else { + content.len() + }; + let raw = &content[start..end]; + let mut snippet = + raw.split_whitespace().collect::>().join(" "); + if start > 0 { + snippet = format!("...{}", snippet); + } + if end < content.len() { + snippet = format!("{}...", snippet); + } + if !snippet.is_empty() { + snippets.push(snippet); + } + // Advance past the match + ci += query_chars.len(); + } else { + ci += 1; } - search_from = abs_pos + query_len; } } } From f4fda21106626b4b92c55ae18291bcf4f8de82e3 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:13:36 -0300 Subject: [PATCH 15/29] fix: prevent search view flicker during query transitions Keep search mode active while query is non-empty or search is in-progress to prevent momentary grid layout flicker. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index f7fc13e98..dadf7e448 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -144,8 +144,8 @@ export const SessionList: React.FC = ({ }); }, [debouncedQuery, projectId]); - const isSearchActive = searchResults !== null; - const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? searchResults : sessions; + const isSearchActive = searchQuery.trim() !== '' || searching || searchResults !== null; + const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination const totalPages = Math.max(1, Math.ceil(displayedSessions.length / ITEMS_PER_PAGE)); From a0c37963c743d2649c1110a3620434b4fefe7c82 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:21:20 -0300 Subject: [PATCH 16/29] fix: use debouncedQuery for search-active state to prevent blank-out Prevents sessions from disappearing during the 300ms debounce delay before the search actually starts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index dadf7e448..0bcddf524 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -144,7 +144,7 @@ export const SessionList: React.FC = ({ }); }, [debouncedQuery, projectId]); - const isSearchActive = searchQuery.trim() !== '' || searching || searchResults !== null; + const isSearchActive = debouncedQuery.trim() !== '' || searching || searchResults !== null; const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination From 72150074de99ade738bbf54766403d137e3fa5b3 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:26:15 -0300 Subject: [PATCH 17/29] fix: require minimum 2-char query to avoid expensive single-char scans Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 5cbc9f0d8..2417318e0 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -648,7 +648,7 @@ pub async fn search_project_sessions( query: String, ) -> Result, String> { let query = query.trim().to_string(); - if query.is_empty() { + if query.is_empty() || query.len() < 2 { return Ok(Vec::new()); } if query.len() > 256 { From 571bf76d95a06ebe69e5ac0fd6f3128aebe5ac81 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:35:22 -0300 Subject: [PATCH 18/29] fix: match frontend 2-char minimum with backend to prevent blank grid Only enter search mode when query is >= 2 chars, matching the backend minimum. Prevents 1-char input from hiding the session grid. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 0bcddf524..720226223 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -111,7 +111,7 @@ export const SessionList: React.FC = ({ skipNextSearch.current = false; return; } - if (!debouncedQuery.trim()) { + if (debouncedQuery.trim().length < 2) { setSearchResults(null); setSearchError(null); setSearching(false); @@ -144,7 +144,7 @@ export const SessionList: React.FC = ({ }); }, [debouncedQuery, projectId]); - const isSearchActive = debouncedQuery.trim() !== '' || searching || searchResults !== null; + const isSearchActive = searchQuery.trim().length >= 2 || searching || searchResults !== null; const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination From 8f5fdbef3525fbc7ff8593cc1387edfd554a36e5 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 08:55:34 -0300 Subject: [PATCH 19/29] feat: add open session and copy resume command buttons to accordion header - Open session button (external link icon) on accordion header row - Copy resume command button copies 'claude --resume ' to clipboard with checkmark confirmation - Removed redundant "Open session" link from expanded accordion body - Restructured header to avoid nested buttons (a11y) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 107 ++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 42 deletions(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 720226223..644fcdc2e 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Clock, MessageSquare, Search, Loader2, X, ChevronDown } from "lucide-react"; +import { Clock, MessageSquare, Search, Loader2, X, ChevronDown, ExternalLink, Copy, Check } from "lucide-react"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Pagination } from "@/components/ui/pagination"; @@ -77,6 +77,7 @@ export const SessionList: React.FC = ({ const [searchError, setSearchError] = useState(null); const [searching, setSearching] = useState(false); const [expandedSessions, setExpandedSessions] = useState>(new Set()); + const [copiedId, setCopiedId] = useState(null); const debouncedQuery = useDebounce(searchQuery, 300); const searchRequestId = useRef(0); const skipNextSearch = useRef(false); @@ -178,6 +179,12 @@ export const SessionList: React.FC = ({ }); }, []); + const copyResumeCommand = useCallback((sessionId: string) => { + navigator.clipboard.writeText(`claude --resume ${sessionId}`); + setCopiedId(sessionId); + setTimeout(() => setCopiedId(null), 2000); + }, []); + const handleSessionClick = useCallback((session: Session) => { if (onSessionClick) { onSessionClick(session); @@ -273,42 +280,67 @@ export const SessionList: React.FC = ({ session.todo_data && "bg-primary/5" )}> {/* Accordion header */} - +
+ +
- - {snippets.length} match{snippets.length !== 1 ? 'es' : ''} - - +
{/* Accordion content: matching snippets */} @@ -331,15 +363,6 @@ export const SessionList: React.FC = ({
))}
-
)} From a2db8eb3978af8b5e811806c29077274ce9f4fe8 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 09:36:35 -0300 Subject: [PATCH 20/29] fix: debounce-aware search mode toggle and async clipboard write - Use debouncedQuery for search-active check to prevent premature grid disappearance - Await clipboard write before showing success checkmark Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 644fcdc2e..b211e4ca5 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -145,7 +145,7 @@ export const SessionList: React.FC = ({ }); }, [debouncedQuery, projectId]); - const isSearchActive = searchQuery.trim().length >= 2 || searching || searchResults !== null; + const isSearchActive = debouncedQuery.trim().length >= 2 || searching || searchResults !== null; const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination @@ -180,9 +180,13 @@ export const SessionList: React.FC = ({ }, []); const copyResumeCommand = useCallback((sessionId: string) => { - navigator.clipboard.writeText(`claude --resume ${sessionId}`); - setCopiedId(sessionId); - setTimeout(() => setCopiedId(null), 2000); + if (!navigator.clipboard) return; + navigator.clipboard.writeText(`claude --resume ${sessionId}`) + .then(() => { + setCopiedId(sessionId); + setTimeout(() => setCopiedId(null), 2000); + }) + .catch((err) => console.error("Failed to copy:", err)); }, []); const handleSessionClick = useCallback((session: Session) => { From 46d52d894463b77954143ea4ea140997a6a1a811 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 09:47:50 -0300 Subject: [PATCH 21/29] fix: skip bad session files gracefully instead of aborting search Log warnings and continue scanning when individual directory entries or session files are unreadable, rather than failing the entire search. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 2417318e0..c0cee1137 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -698,10 +698,20 @@ pub async fn search_project_sessions( .map_err(|e| format!("Failed to read project directory: {}", e))?; for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; - let file_type = entry - .file_type() - .map_err(|e| format!("Failed to read entry type: {}", e))?; + let entry = match entry { + Ok(e) => e, + Err(e) => { + log::warn!("Skipping unreadable directory entry: {}", e); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(e) => { + log::warn!("Skipping entry with unreadable type {:?}: {}", entry.path(), e); + continue; + } + }; if file_type.is_symlink() || !file_type.is_file() { continue; } @@ -714,8 +724,13 @@ pub async fn search_project_sessions( continue; } - let metadata = fs::metadata(&path) - .map_err(|e| format!("Failed to read file metadata: {}", e))?; + let metadata = match fs::metadata(&path) { + Ok(m) => m, + Err(e) => { + log::warn!("Skipping unreadable session file {:?}: {}", path, e); + continue; + } + }; let created_at = metadata .created() From 3f19b576609f578d06ed0926ec512006e6456a87 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 09:56:52 -0300 Subject: [PATCH 22/29] fix: prevent blank flash when clearing search or switching projects Require both live query and debounced state for search-active mode so clearing input immediately exits search view. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index b211e4ca5..96e7bf1ed 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -145,7 +145,9 @@ export const SessionList: React.FC = ({ }); }, [debouncedQuery, projectId]); - const isSearchActive = debouncedQuery.trim().length >= 2 || searching || searchResults !== null; + const isSearchActive = + searchQuery.trim().length >= 2 && + (debouncedQuery.trim().length >= 2 || searching || searchResults !== null); const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination From f2a21b0d57dda1218ad3379f4f00e31ccd89f315 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 10:03:56 -0300 Subject: [PATCH 23/29] fix: update search placeholder to reflect project scope Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 96e7bf1ed..a5ceba31a 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -208,7 +208,7 @@ export const SessionList: React.FC = ({
setSearchQuery(e.target.value)} aria-label="Search sessions" From 3a0e40bd49fcab7767748447c9d7f2a2708f5886 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 10:12:46 -0300 Subject: [PATCH 24/29] fix: normalize search query once and reuse for search and highlighting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index a5ceba31a..41004bdaa 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -79,6 +79,7 @@ export const SessionList: React.FC = ({ const [expandedSessions, setExpandedSessions] = useState>(new Set()); const [copiedId, setCopiedId] = useState(null); const debouncedQuery = useDebounce(searchQuery, 300); + const normalizedQuery = debouncedQuery.trim(); const searchRequestId = useRef(0); const skipNextSearch = useRef(false); @@ -112,7 +113,7 @@ export const SessionList: React.FC = ({ skipNextSearch.current = false; return; } - if (debouncedQuery.trim().length < 2) { + if (normalizedQuery.length < 2) { setSearchResults(null); setSearchError(null); setSearching(false); @@ -123,7 +124,7 @@ export const SessionList: React.FC = ({ setSearching(true); setSearchError(null); - api.searchProjectSessions(projectId, debouncedQuery.trim()) + api.searchProjectSessions(projectId, normalizedQuery) .then((results) => { if (searchRequestId.current === requestId) { setSearchResults(results); @@ -147,7 +148,7 @@ export const SessionList: React.FC = ({ const isSearchActive = searchQuery.trim().length >= 2 && - (debouncedQuery.trim().length >= 2 || searching || searchResults !== null); + (normalizedQuery.length >= 2 || searching || searchResults !== null); const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; // Calculate pagination @@ -365,7 +366,7 @@ export const SessionList: React.FC = ({ key={i} className="text-xs text-muted-foreground bg-muted/50 rounded p-2 leading-relaxed" > - +
))} From 5a9b73c6cf316fa9e0634e0e5c4c494a7315f3c4 Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 10:18:27 -0300 Subject: [PATCH 25/29] fix: use non-absolute result count wording for search results Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/SessionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 41004bdaa..2a4d1751a 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -241,7 +241,7 @@ export const SessionList: React.FC = ({

{searchResults.length === 0 ? "No sessions found" - : `${searchResults.length} of ${sessions.length} sessions`} + : `Showing ${searchResults.length} result${searchResults.length !== 1 ? 's' : ''}`}

)} From 15c1fda15715fba481755283b4af65fad94eefec Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 10:43:35 -0300 Subject: [PATCH 26/29] style: apply cargo fmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 12 ++++++++++-- src-tauri/src/commands/claude.rs | 17 ++++++++--------- src-tauri/src/main.rs | 6 +++--- src-tauri/src/web_server.rs | 5 ++++- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 0152c93a4..ba0c14740 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,10 @@ "typescript": "~5.6.2", "vite": "^6.0.3", }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.6", + "@rollup/rollup-linux-x64-gnu": "^4.45.1", + }, }, }, "trustedDependencies": [ @@ -139,7 +143,7 @@ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], @@ -357,7 +361,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="], @@ -1061,6 +1065,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1087,6 +1093,8 @@ "rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index c0cee1137..7920d74e8 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -589,8 +589,7 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec = - content.char_indices().collect(); + let chars_orig: Vec<(usize, char)> = content.char_indices().collect(); let query_chars: Vec = query_lower.chars().collect(); let mut ci = 0; while ci + query_chars.len() <= chars_orig.len() { @@ -600,9 +599,7 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec ft, Err(e) => { - log::warn!("Skipping entry with unreadable type {:?}: {}", entry.path(), e); + log::warn!( + "Skipping entry with unreadable type {:?}: {}", + entry.path(), + e + ); continue; } }; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2b39de6f3..b026e08f5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -22,13 +22,13 @@ use commands::claude::{ clear_checkpoint_manager, continue_claude_code, create_checkpoint, create_project, execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, - get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, search_project_sessions, + get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, - track_checkpoint_message, track_session_messages, update_checkpoint_settings, - update_hooks_config, validate_hook_command, ClaudeProcessState, + search_project_sessions, track_checkpoint_message, track_session_messages, + update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 005c0009c..3b8c889c9 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -807,7 +807,10 @@ pub async fn create_web_server(port: u16) -> Result<(), Box Date: Fri, 13 Mar 2026 11:03:43 -0300 Subject: [PATCH 27/29] feat: add search operators (AND, OR, NOT, exact phrase) - Parse query into AND terms (default), OR groups, NOT terms (-prefix), and "quoted phrases" - Session matching: all AND terms present, at least one per OR group, no NOT terms found - Multi-term snippet extraction with per-term context windows - Return highlight_terms in search results for frontend highlighting - HighlightedText component now accepts multiple terms and highlights each independently using regex alternation - 10 unit tests for query parser covering all operator combinations Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 370 +++++++++++++++++++++++++++---- src/components/SessionList.tsx | 13 +- src/lib/api.ts | 2 + 3 files changed, 339 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 7920d74e8..26461435f 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -64,6 +64,151 @@ pub struct SessionSearchResult { pub session: Session, /// Matching text snippets from the session pub snippets: Vec, + /// Positive search terms for frontend highlighting + pub highlight_terms: Vec, +} + +/// Parsed search query with AND, OR, NOT operators +#[derive(Debug, Clone)] +struct ParsedQuery { + /// Terms that must ALL be present (AND) + and_terms: Vec, + /// Groups where at least one term must match + or_groups: Vec>, + /// Terms that must NOT be present + not_terms: Vec, + /// All positive terms for snippet extraction and highlighting + highlight_terms: Vec, +} + +/// Parses a search query supporting AND (default), OR, NOT (-), and "exact phrase". +/// +/// Examples: +/// `alpha beta` → AND: [alpha, beta] +/// `alpha OR beta` → OR: [[alpha, beta]] +/// `-test` → NOT: [test] +/// `"exact phrase"` → AND: ["exact phrase"] +/// `"fix bug" refactor OR cleanup -test` → AND: [fix bug], OR: [[refactor, cleanup]], NOT: [test] +fn parse_query(raw: &str) -> ParsedQuery { + // Step 1: Tokenize respecting quotes + let mut tokens: Vec<(String, bool)> = Vec::new(); // (term, is_negated) + let mut chars = raw.chars().peekable(); + let mut current = String::new(); + let mut in_quotes = false; + let mut is_negated = false; + + while let Some(&ch) = chars.peek() { + if in_quotes { + chars.next(); + if ch == '"' { + in_quotes = false; + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + } else { + current.push(ch); + } + } else if ch == '"' { + chars.next(); + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + in_quotes = true; + } else if ch.is_whitespace() { + chars.next(); + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + } else if ch == '-' && current.is_empty() { + chars.next(); + is_negated = true; + } else { + chars.next(); + current.push(ch); + } + } + // Handle unclosed quotes or trailing token + if !current.is_empty() { + tokens.push((current, is_negated)); + } + + // Step 2: Build ParsedQuery by processing OR groups + let mut and_terms: Vec = Vec::new(); + let mut or_groups: Vec> = Vec::new(); + let mut not_terms: Vec = Vec::new(); + let mut pending_or_group: Vec = Vec::new(); + + let mut i = 0; + while i < tokens.len() { + let (ref term, negated) = tokens[i]; + let term_lower = term.to_lowercase(); + + if negated { + not_terms.push(term_lower); + i += 1; + continue; + } + + // Check if next token is OR + let next_is_or = i + 1 < tokens.len() && tokens[i + 1].0 == "OR" && !tokens[i + 1].1; + + if term == "OR" && !negated { + // OR without left context — treat as literal + if pending_or_group.is_empty() { + and_terms.push(term_lower); + } + // If pending_or_group is non-empty, this OR was already handled + i += 1; + continue; + } + + if !pending_or_group.is_empty() { + // We're continuing an OR chain + pending_or_group.push(term_lower); + if !next_is_or { + // End of OR chain + or_groups.push(pending_or_group.clone()); + pending_or_group.clear(); + } else { + i += 2; // skip term + OR + continue; + } + } else if next_is_or { + // Start a new OR chain + pending_or_group.push(term_lower); + i += 2; // skip term + OR + continue; + } else { + and_terms.push(term_lower); + } + + i += 1; + } + + // Flush any remaining OR group + if !pending_or_group.is_empty() { + or_groups.push(pending_or_group); + } + + // Build highlight_terms from all positive terms + let mut highlight_terms: Vec = Vec::new(); + highlight_terms.extend(and_terms.iter().cloned()); + for group in &or_groups { + highlight_terms.extend(group.iter().cloned()); + } + + ParsedQuery { + and_terms, + or_groups, + not_terms, + highlight_terms, + } } /// Represents a message entry in the JSONL file @@ -564,10 +709,62 @@ pub async fn get_project_sessions(project_id: String) -> Result, St Ok(sessions) } -/// Checks if any message in a JSONL session file contains the query (case-insensitive) -/// Extracts matching text snippets from a session JSONL file. -/// Returns up to MAX_SNIPPETS snippets containing the query, trimmed to context around the match. -fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec { +/// Checks if a session matches the parsed query (AND, OR, NOT logic). +/// Reads all message content and checks term presence. +fn session_matches(jsonl_path: &PathBuf, query: &ParsedQuery) -> bool { + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return false, + }; + + let reader = BufReader::new(file); + let mut all_content = String::new(); + + for line in reader.lines() { + if let Ok(line) = line { + if let Ok(entry) = serde_json::from_str::(&line) { + if let Some(message) = entry.message { + if let Some(content) = message.content { + all_content.push(' '); + all_content.push_str(&content); + } + } + } + } + } + + let content_lower = all_content.to_lowercase(); + + // Check NOT terms first (early exit) + for term in &query.not_terms { + if content_lower.contains(term.as_str()) { + return false; + } + } + + // Check AND terms — all must be present + for term in &query.and_terms { + if !content_lower.contains(term.as_str()) { + return false; + } + } + + // Check OR groups — at least one term per group + for group in &query.or_groups { + if !group + .iter() + .any(|term| content_lower.contains(term.as_str())) + { + return false; + } + } + + true +} + +/// Extracts matching text snippets for multiple search terms. +/// Returns up to MAX_SNIPPETS snippets with context around each match. +fn extract_snippets_for_terms(jsonl_path: &PathBuf, terms: &[String]) -> Vec { const MAX_SNIPPETS: usize = 20; const CONTEXT_CHARS: usize = 100; @@ -576,9 +773,16 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec return Vec::new(), }; + if terms.is_empty() { + return Vec::new(); + } + let reader = BufReader::new(file); let mut snippets = Vec::new(); + // Pre-compute query chars for each term + let term_chars: Vec> = terms.iter().map(|t| t.chars().collect()).collect(); + for line in reader.lines() { if snippets.len() >= MAX_SNIPPETS { break; @@ -587,46 +791,48 @@ fn extract_matching_snippets(jsonl_path: &PathBuf, query_lower: &str) -> Vec(&line) { if let Some(message) = entry.message { if let Some(content) = message.content { - // Find matches by comparing chars case-insensitively - // to preserve original casing in snippets let chars_orig: Vec<(usize, char)> = content.char_indices().collect(); - let query_chars: Vec = query_lower.chars().collect(); - let mut ci = 0; - while ci + query_chars.len() <= chars_orig.len() { + + // Search for each term in this message + for query_chars in &term_chars { if snippets.len() >= MAX_SNIPPETS { break; } - let matched = chars_orig[ci..ci + query_chars.len()] - .iter() - .zip(&query_chars) - .all(|((_, c), q)| c.to_lowercase().eq(q.to_lowercase())); - if matched { - // Context window in chars - let ctx_start_ci = ci.saturating_sub(CONTEXT_CHARS); - let ctx_end_ci = - (ci + query_chars.len() + CONTEXT_CHARS).min(chars_orig.len()); - let start = chars_orig[ctx_start_ci].0; - let end = if ctx_end_ci < chars_orig.len() { - chars_orig[ctx_end_ci].0 - } else { - content.len() - }; - let raw = &content[start..end]; - let mut snippet = - raw.split_whitespace().collect::>().join(" "); - if start > 0 { - snippet = format!("...{}", snippet); + let mut ci = 0; + while ci + query_chars.len() <= chars_orig.len() { + if snippets.len() >= MAX_SNIPPETS { + break; } - if end < content.len() { - snippet = format!("{}...", snippet); - } - if !snippet.is_empty() { - snippets.push(snippet); + let matched = chars_orig[ci..ci + query_chars.len()] + .iter() + .zip(query_chars) + .all(|((_, c), q)| c.to_lowercase().eq(q.to_lowercase())); + if matched { + let ctx_start_ci = ci.saturating_sub(CONTEXT_CHARS); + let ctx_end_ci = (ci + query_chars.len() + CONTEXT_CHARS) + .min(chars_orig.len()); + let start = chars_orig[ctx_start_ci].0; + let end = if ctx_end_ci < chars_orig.len() { + chars_orig[ctx_end_ci].0 + } else { + content.len() + }; + let raw = &content[start..end]; + let mut snippet = + raw.split_whitespace().collect::>().join(" "); + if start > 0 { + snippet = format!("...{}", snippet); + } + if end < content.len() { + snippet = format!("{}...", snippet); + } + if !snippet.is_empty() { + snippets.push(snippet); + } + ci += query_chars.len(); + } else { + ci += 1; } - // Advance past the match - ci += query_chars.len(); - } else { - ci += 1; } } } @@ -662,7 +868,11 @@ pub async fn search_project_sessions( return Err("Invalid project id".to_string()); } - let query_lower = query.to_lowercase(); + let parsed = parse_query(&query); + if parsed.and_terms.is_empty() && parsed.or_groups.is_empty() && parsed.not_terms.is_empty() { + return Ok(Vec::new()); + } + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; let projects_dir = claude_dir.join("projects"); let project_dir = projects_dir.join(&project_id); @@ -718,10 +928,10 @@ pub async fn search_project_sessions( if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { - let snippets = extract_matching_snippets(&path, &query_lower); - if snippets.is_empty() { + if !session_matches(&path, &parsed) { continue; } + let snippets = extract_snippets_for_terms(&path, &parsed.highlight_terms); let metadata = match fs::metadata(&path) { Ok(m) => m, @@ -761,6 +971,7 @@ pub async fn search_project_sessions( message_timestamp, }, snippets, + highlight_terms: parsed.highlight_terms.clone(), }); } } @@ -2431,6 +2642,83 @@ mod tests { Ok(()) } + #[test] + fn test_parse_query_single_term() { + let q = parse_query("hello"); + assert_eq!(q.and_terms, vec!["hello"]); + assert!(q.or_groups.is_empty()); + assert!(q.not_terms.is_empty()); + assert_eq!(q.highlight_terms, vec!["hello"]); + } + + #[test] + fn test_parse_query_and_terms() { + let q = parse_query("alpha beta gamma"); + assert_eq!(q.and_terms, vec!["alpha", "beta", "gamma"]); + assert!(q.or_groups.is_empty()); + assert!(q.not_terms.is_empty()); + } + + #[test] + fn test_parse_query_or_group() { + let q = parse_query("alpha OR beta"); + assert!(q.and_terms.is_empty()); + assert_eq!(q.or_groups, vec![vec!["alpha", "beta"]]); + assert!(q.not_terms.is_empty()); + assert_eq!(q.highlight_terms, vec!["alpha", "beta"]); + } + + #[test] + fn test_parse_query_chained_or() { + let q = parse_query("alpha OR beta OR gamma"); + assert!(q.and_terms.is_empty()); + assert_eq!(q.or_groups, vec![vec!["alpha", "beta", "gamma"]]); + } + + #[test] + fn test_parse_query_not_term() { + let q = parse_query("alpha -beta"); + assert_eq!(q.and_terms, vec!["alpha"]); + assert_eq!(q.not_terms, vec!["beta"]); + assert_eq!(q.highlight_terms, vec!["alpha"]); + } + + #[test] + fn test_parse_query_quoted_phrase() { + let q = parse_query("\"hello world\" test"); + assert_eq!(q.and_terms, vec!["hello world", "test"]); + assert!(q.or_groups.is_empty()); + } + + #[test] + fn test_parse_query_mixed() { + let q = parse_query("\"fix bug\" refactor OR cleanup -test"); + assert_eq!(q.and_terms, vec!["fix bug"]); + assert_eq!(q.or_groups, vec![vec!["refactor", "cleanup"]]); + assert_eq!(q.not_terms, vec!["test"]); + assert_eq!(q.highlight_terms, vec!["fix bug", "refactor", "cleanup"]); + } + + #[test] + fn test_parse_query_standalone_or() { + let q = parse_query("OR"); + assert_eq!(q.and_terms, vec!["or"]); + assert!(q.or_groups.is_empty()); + } + + #[test] + fn test_parse_query_negated_phrase() { + let q = parse_query("-\"bad phrase\""); + assert!(q.and_terms.is_empty()); + assert_eq!(q.not_terms, vec!["bad phrase"]); + } + + #[test] + fn test_parse_query_case_preservation() { + let q = parse_query("Hello WORLD"); + assert_eq!(q.and_terms, vec!["hello", "world"]); + } + #[test] fn test_get_project_path_from_sessions_normal_case() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 2a4d1751a..86459958e 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -25,10 +25,13 @@ interface SessionListProps { const ITEMS_PER_PAGE = 12; /** Highlights all occurrences of `query` in `text` (case-insensitive, Unicode-safe) */ -function HighlightedText({ text, query }: { text: string; query: string }) { - if (!query.trim()) return <>{text}; - const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(escaped, 'gi'); +function HighlightedText({ text, terms }: { text: string; terms: string[] }) { + if (!terms.length) return <>{text}; + const escaped = terms + .filter(t => t.trim()) + .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + if (!escaped.length) return <>{text}; + const re = new RegExp(escaped.join('|'), 'gi'); const parts: React.ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; @@ -366,7 +369,7 @@ export const SessionList: React.FC = ({ key={i} className="text-xs text-muted-foreground bg-muted/50 rounded p-2 leading-relaxed" > - + ))} diff --git a/src/lib/api.ts b/src/lib/api.ts index e49e16f30..4a0269216 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -56,6 +56,8 @@ export interface Session { export interface SessionSearchResult extends Session { /** Matching text snippets from the session */ snippets: string[]; + /** Positive search terms for frontend highlighting */ + highlight_terms: string[]; } /** From 5464dd575735023329300b956da4a6a62a99754d Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 11:25:20 -0300 Subject: [PATCH 28/29] fix: treat AND as explicit operator instead of literal search term Skip uppercase AND tokens in query parser since AND is the default behavior. Prevents "and" from being highlighted in search results. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 26461435f..5cffbc787 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -155,6 +155,12 @@ fn parse_query(raw: &str) -> ParsedQuery { continue; } + // Skip explicit AND operator (it's the default behavior) + if term == "AND" && !negated { + i += 1; + continue; + } + // Check if next token is OR let next_is_or = i + 1 < tokens.len() && tokens[i + 1].0 == "OR" && !tokens[i + 1].1; @@ -2713,6 +2719,14 @@ mod tests { assert_eq!(q.not_terms, vec!["bad phrase"]); } + #[test] + fn test_parse_query_explicit_and() { + let q = parse_query("Altera AND outages"); + assert_eq!(q.and_terms, vec!["altera", "outages"]); + assert!(q.or_groups.is_empty()); + assert_eq!(q.highlight_terms, vec!["altera", "outages"]); + } + #[test] fn test_parse_query_case_preservation() { let q = parse_query("Hello WORLD"); From b3e38f9112958e82ecbe19c874f042af69738f2c Mon Sep 17 00:00:00 2001 From: Jose Monteiro Date: Fri, 13 Mar 2026 14:37:52 -0300 Subject: [PATCH 29/29] feat: add global cross-project session search to projects view Add a search bar to the ProjectList that filters projects by name (instant, client-side) and searches across all projects' sessions (debounced backend call). Results are grouped by project with expandable accordion snippets, highlighting, and action buttons. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/claude.rs | 147 ++++++++ src-tauri/src/main.rs | 5 +- src-tauri/src/web_server.rs | 18 + src/components/HighlightedText.tsx | 32 ++ src/components/ProjectList.tsx | 539 ++++++++++++++++++++++------- src/components/SessionList.tsx | 32 +- src/lib/api.ts | 14 + src/lib/apiAdapter.ts | 1 + 8 files changed, 638 insertions(+), 150 deletions(-) create mode 100644 src/components/HighlightedText.tsx diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 5cffbc787..e1a3c14d8 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -997,6 +997,153 @@ pub async fn search_project_sessions( .map_err(|e| format!("Search task failed: {}", e))? } +/// Searches through all projects' session JSONL files for messages containing the query +#[tauri::command] +pub async fn search_all_sessions(query: String) -> Result, String> { + let query = query.trim().to_string(); + if query.is_empty() || query.len() < 2 { + return Ok(Vec::new()); + } + if query.len() > 256 { + return Err("Query is too long".to_string()); + } + + log::info!( + "Searching all sessions across all projects (query length: {})", + query.len() + ); + + let parsed = parse_query(&query); + if parsed.and_terms.is_empty() && parsed.or_groups.is_empty() && parsed.not_terms.is_empty() { + return Ok(Vec::new()); + } + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Ok(Vec::new()); + } + + let todos_dir = claude_dir.join("todos"); + + tokio::task::spawn_blocking(move || { + const MAX_RESULTS: usize = 100; + + let mut results: Vec = Vec::new(); + + let project_entries = fs::read_dir(&projects_dir) + .map_err(|e| format!("Failed to read projects directory: {}", e))?; + + for project_entry in project_entries { + if results.len() >= MAX_RESULTS { + break; + } + + let project_entry = match project_entry { + Ok(e) => e, + Err(_) => continue, + }; + let project_dir = project_entry.path(); + if !project_dir.is_dir() { + continue; + } + + let project_id = match project_dir.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + let project_path = match get_project_path_from_sessions(&project_dir) { + Ok(path) => path, + Err(_) => decode_project_path(&project_id), + }; + + let session_entries = match fs::read_dir(&project_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in session_entries { + if results.len() >= MAX_RESULTS { + break; + } + + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if file_type.is_symlink() || !file_type.is_file() { + continue; + } + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { + if !session_matches(&path, &parsed) { + continue; + } + let snippets = extract_snippets_for_terms(&path, &parsed.highlight_terms); + + let metadata = match fs::metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let (first_message, message_timestamp) = extract_first_user_message(&path); + + let todo_path = todos_dir.join(format!("{}.json", session_id)); + let todo_data = if todo_path.exists() { + fs::read_to_string(&todo_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + results.push(SessionSearchResult { + session: Session { + id: session_id.to_string(), + project_id: project_id.clone(), + project_path: project_path.clone(), + todo_data, + created_at, + first_message, + message_timestamp, + }, + snippets, + highlight_terms: parsed.highlight_terms.clone(), + }); + } + } + } + } + + results.sort_by(|a, b| b.session.created_at.cmp(&a.session.created_at)); + results.truncate(MAX_RESULTS); + + log::info!( + "Found {} matching sessions across all projects", + results.len() + ); + Ok(results) + }) + .await + .map_err(|e| format!("Search task failed: {}", e))? +} + /// Reads the Claude settings file #[tauri::command] pub async fn get_claude_settings() -> Result { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b026e08f5..f11d676f3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -26,8 +26,8 @@ use commands::claude::{ get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, - save_claude_md_file, save_claude_settings, save_system_prompt, search_files, - search_project_sessions, track_checkpoint_message, track_session_messages, + save_claude_md_file, save_claude_settings, save_system_prompt, search_all_sessions, + search_files, search_project_sessions, track_checkpoint_message, track_session_messages, update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; use commands::mcp::{ @@ -189,6 +189,7 @@ fn main() { create_project, get_project_sessions, search_project_sessions, + search_all_sessions, get_home_directory, get_claude_settings, open_new_session, diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 3b8c889c9..b3270c547 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -150,6 +150,20 @@ async fn search_sessions( } } +/// API endpoint to search sessions across all projects +async fn search_all_sessions_handler( + Query(params): Query, +) -> Json>> { + let query = params.query.trim().to_string(); + if query.is_empty() { + return Json(ApiResponse::success(Vec::new())); + } + match commands::claude::search_all_sessions(query).await { + Ok(sessions) => Json(ApiResponse::success(sessions)), + Err(e) => Json(ApiResponse::error(e.to_string())), + } +} + /// Simple agents endpoint - return empty for now (needs DB state) async fn get_agents() -> Json>> { Json(ApiResponse::success(vec![])) @@ -811,6 +825,10 @@ pub async fn create_web_server(port: u16) -> Result<(), Box{text}; + const escaped = terms + .filter(t => t.trim()) + .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + if (!escaped.length) return <>{text}; + const re = new RegExp(escaped.join('|'), 'gi'); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + parts.push( + + {match[0]} + + ); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +} diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx index 8d8ab7995..260f3be39 100644 --- a/src/components/ProjectList.tsx +++ b/src/components/ProjectList.tsx @@ -1,14 +1,27 @@ -import React, { useState } from "react"; -import { motion } from "framer-motion"; -import { +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { FolderOpen, ChevronLeft, - ChevronRight + ChevronRight, + Search, + Loader2, + X, + ChevronDown, + ExternalLink, + Copy, + Check, + Clock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import type { Project } from "@/lib/api"; +import { Input } from "@/components/ui/input"; +import { HighlightedText } from "@/components/HighlightedText"; +import type { Project, SessionSearchResult } from "@/lib/api"; +import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { truncateText, getFirstLine } from "@/lib/date-utils"; +import { useDebounce } from "@/hooks/useDebounce"; interface ProjectListProps { /** @@ -52,7 +65,7 @@ const getDisplayPath = (path: string, maxLength: number = 30): string => { for (const indicator of homeIndicators) { if (path.includes(indicator)) { const parts = path.split('/'); - const userIndex = parts.findIndex((_part, i) => + const userIndex = parts.findIndex((_part, i) => i > 0 && parts[i - 1] === indicator.split('/')[1] ); if (userIndex > 0) { @@ -62,27 +75,28 @@ const getDisplayPath = (path: string, maxLength: number = 30): string => { } } } - + // Truncate if too long if (displayPath.length > maxLength) { const start = displayPath.substring(0, Math.floor(maxLength / 2) - 2); const end = displayPath.substring(displayPath.length - Math.floor(maxLength / 2) + 2); return `${start}...${end}`; } - + return displayPath; }; -/** - * ProjectList component - Displays recent projects in a Cursor-like interface - * - * @example - * console.log('Selected:', project)} - * onOpenProject={() => console.log('Open project')} - * /> - */ +function formatResultDate(result: SessionSearchResult) { + const date = result.message_timestamp + ? new Date(result.message_timestamp) + : new Date(result.created_at * 1000); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + export const ProjectList: React.FC = ({ projects, onProjectClick, @@ -91,26 +105,153 @@ export const ProjectList: React.FC = ({ }) => { const [showAll, setShowAll] = useState(false); const [currentPage, setCurrentPage] = useState(1); - + const [searchQuery, setSearchQuery] = useState(""); + const [globalResults, setGlobalResults] = useState(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [expandedSessions, setExpandedSessions] = useState>(new Set()); + const [copiedId, setCopiedId] = useState(null); + const debouncedQuery = useDebounce(searchQuery, 300); + const normalizedQuery = debouncedQuery.trim(); + const searchRequestId = useRef(0); + + // Client-side project name filtering + const filteredProjects = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return projects; + return projects.filter(p => { + const name = getProjectName(p.path).toLowerCase(); + const path = p.path.toLowerCase(); + return name.includes(q) || path.includes(q); + }); + }, [projects, searchQuery]); + + // Global session search + useEffect(() => { + searchRequestId.current += 1; + if (normalizedQuery.length < 2) { + setGlobalResults(null); + setSearchError(null); + setSearching(false); + setExpandedSessions(new Set()); + return; + } + + const requestId = searchRequestId.current; + setSearching(true); + setSearchError(null); + + api.searchAllSessions(normalizedQuery) + .then((results) => { + if (searchRequestId.current === requestId) { + setGlobalResults(results); + setExpandedSessions(new Set()); + } + }) + .catch((err) => { + console.error("Global search failed:", err); + if (searchRequestId.current === requestId) { + setGlobalResults(null); + setSearchError("Search failed. Please try again."); + } + }) + .finally(() => { + if (searchRequestId.current === requestId) { + setSearching(false); + } + }); + }, [normalizedQuery]); + + // Group global results by project + const groupedResults = useMemo(() => { + if (!globalResults) return null; + const groups: Map = new Map(); + for (const result of globalResults) { + const key = result.project_id; + if (!groups.has(key)) { + groups.set(key, { projectPath: result.project_path, results: [] }); + } + groups.get(key)!.results.push(result); + } + return groups; + }, [globalResults]); + // Determine how many projects to show const projectsPerPage = showAll ? 10 : 5; - const totalPages = Math.ceil(projects.length / projectsPerPage); - + const totalPages = Math.ceil(filteredProjects.length / projectsPerPage); + // Calculate which projects to display const startIndex = showAll ? (currentPage - 1) * projectsPerPage : 0; const endIndex = startIndex + projectsPerPage; - const displayedProjects = projects.slice(startIndex, endIndex); - + const displayedProjects = filteredProjects.slice(startIndex, endIndex); + const handleViewAll = () => { setShowAll(true); setCurrentPage(1); }; - + const handleViewLess = () => { setShowAll(false); setCurrentPage(1); }; + const clearSearch = useCallback(() => { + setSearchQuery(""); + setGlobalResults(null); + setSearchError(null); + setExpandedSessions(new Set()); + }, []); + + const toggleExpanded = useCallback((sessionId: string) => { + setExpandedSessions(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const copyResumeCommand = useCallback((sessionId: string) => { + if (!navigator.clipboard) return; + navigator.clipboard.writeText(`claude --resume ${sessionId}`) + .then(() => { + setCopiedId(sessionId); + setTimeout(() => setCopiedId(null), 2000); + }) + .catch((err) => console.error("Failed to copy:", err)); + }, []); + + const handleOpenSession = useCallback((result: SessionSearchResult) => { + // Find the matching project and navigate into it, then select the session + const project = projects.find(p => p.id === result.project_id); + if (project) { + onProjectClick(project); + // After navigating to the project, select the session via CustomEvent + setTimeout(() => { + window.dispatchEvent(new CustomEvent('claude-session-selected', { + detail: { + session: { + id: result.id, + project_id: result.project_id, + project_path: result.project_path, + created_at: result.created_at, + first_message: result.first_message, + message_timestamp: result.message_timestamp, + todo_data: result.todo_data, + }, + projectPath: result.project_path, + } + })); + }, 100); + } + }, [projects, onProjectClick]); + + const isSearchActive = searchQuery.trim().length > 0; + const hasGlobalResults = globalResults !== null && globalResults.length > 0; + return (
@@ -137,107 +278,139 @@ export const ProjectList: React.FC = ({
+ + {/* Search bar */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + aria-label="Search projects and sessions" + className="pl-9 pr-9 h-9" + /> + {searchQuery && ( + + )} +
{/* Content */} -
- {/* Recent projects section */} +
+ {/* Projects section */} {displayedProjects.length > 0 ? (
-

Recent Projects

- {!showAll ? ( - - ) : ( - - )} -
- -
- {displayedProjects.map((project, index) => ( - - onProjectClick(project)} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className="w-full text-left px-3 py-2 rounded-md hover:bg-accent/50 transition-colors flex items-center justify-between" - > - - {getProjectName(project.path)} - - - {getDisplayPath(project.path, 35)} - - - - ))} -
- - {/* Pagination controls */} - {showAll && totalPages > 1 && ( -
- - - - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( - + ) : ( + + ) + )} +
+ +
+ {displayedProjects.map((project, index) => ( + - {page} - + onProjectClick(project)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className="w-full text-left px-3 py-2 rounded-md hover:bg-accent/50 transition-colors flex items-center justify-between" + > + + {getProjectName(project.path)} + + + {getDisplayPath(project.path, 35)} + + + ))}
- - - - -
- )} + + {/* Pagination controls */} + {!isSearchActive && showAll && totalPages > 1 && ( +
+ + + + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( + + ))} +
+ + + + +
+ )} +
+ ) : isSearchActive ? ( + +

No projects matching "{searchQuery.trim()}"

) : ( @@ -265,8 +438,140 @@ export const ProjectList: React.FC = ({
)} + + {/* Global session search results */} + {isSearchActive && ( +
+
+ {searching && ( +
+ + Searching sessions across all projects... +
+ )} + {searchError && !searching && ( +

{searchError}

+ )} + {globalResults !== null && !searching && !searchError && ( +

+ {globalResults.length === 0 + ? "No sessions found across projects" + : `Sessions matching "${normalizedQuery}" — ${globalResults.length} result${globalResults.length !== 1 ? 's' : ''}`} +

+ )} +
+ + {hasGlobalResults && groupedResults && ( +
+ {Array.from(groupedResults.entries()).map(([projectId, group]) => ( + +

+ + {getProjectName(group.projectPath)} + {getDisplayPath(group.projectPath, 40)} +

+
+ {group.results.map((result) => { + const snippets = result.snippets || []; + const isExpanded = expandedSessions.has(result.id); + + return ( +
+ {/* Accordion header */} +
+ +
+ + +
+
+ + {/* Accordion content */} + + {isExpanded && snippets.length > 0 && ( + +
+
+ {snippets.map((snippet, i) => ( +
+ +
+ ))} +
+
+
+ )} +
+
+ ); + })} +
+
+ ))} +
+ )} +
+ )}
); -}; +}; diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 86459958e..8c3476f8c 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Pagination } from "@/components/ui/pagination"; import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { HighlightedText } from "@/components/HighlightedText"; import { cn } from "@/lib/utils"; import { truncateText, getFirstLine } from "@/lib/date-utils"; import { useDebounce } from "@/hooks/useDebounce"; @@ -24,37 +25,6 @@ interface SessionListProps { const ITEMS_PER_PAGE = 12; -/** Highlights all occurrences of `query` in `text` (case-insensitive, Unicode-safe) */ -function HighlightedText({ text, terms }: { text: string; terms: string[] }) { - if (!terms.length) return <>{text}; - const escaped = terms - .filter(t => t.trim()) - .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - if (!escaped.length) return <>{text}; - const re = new RegExp(escaped.join('|'), 'gi'); - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = re.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - parts.push( - - {match[0]} - - ); - lastIndex = match.index + match[0].length; - } - - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - - return <>{parts}; -} - function formatSessionDate(session: Session) { const date = session.message_timestamp ? new Date(session.message_timestamp) diff --git a/src/lib/api.ts b/src/lib/api.ts index 4a0269216..945da7510 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -524,6 +524,20 @@ export const api = { } }, + /** + * Search across all projects' sessions for messages matching the query + * @param query - The search query (supports AND, OR, NOT, "exact phrase") + * @returns Promise resolving to an array of matching sessions across all projects + */ + async searchAllSessions(query: string): Promise { + try { + return await apiCall('search_all_sessions', { query }); + } catch (error) { + console.error("Failed to search all sessions:", error); + throw error; + } + }, + /** * Fetch list of agents from GitHub repository * @returns Promise resolving to list of available agents on GitHub diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index 2a552efca..db96a0631 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -168,6 +168,7 @@ function mapCommandToEndpoint(command: string, _params?: any): string { 'list_projects': '/api/projects', 'get_project_sessions': '/api/projects/{projectId}/sessions', 'search_project_sessions': '/api/projects/{projectId}/sessions/search', + 'search_all_sessions': '/api/sessions/search/global', // Agent commands 'list_agents': '/api/agents',