From 20b400be56724042fa3107e932ec9978c5f3cf1a Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 18:00:20 +0200 Subject: [PATCH] feat: add session content search with Ctrl+Shift+F Full-text search across all session messages via a global search overlay. Shows matching messages with highlighted context snippets, session title, project name, and author. Click or Enter to jump directly to the matching message in its session. Available via Ctrl+Shift+F shortcut or the command palette. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 20 ++ src/renderer/components/CommandPalette.tsx | 14 +- .../components/SessionSearchPanel.tsx | 259 ++++++++++++++++++ src/renderer/lib/keyboardShortcuts.ts | 1 + 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/SessionSearchPanel.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b4b45d2..b570be8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,6 +8,7 @@ import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingMo import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel'; import { NewSessionModal } from '@renderer/components/NewSessionModal'; import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel'; +import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel'; import { SettingsPanel } from '@renderer/components/SettingsPanel'; import { Sidebar } from '@renderer/components/Sidebar'; import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel'; @@ -116,6 +117,7 @@ export default function App() { const [showDiscoveryModal, setShowDiscoveryModal] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); + const [showSearch, setShowSearch] = useState(false); // Terminal state const [terminalOpen, setTerminalOpen] = useState(false); @@ -315,6 +317,13 @@ export default function App() { return; } + // ── Ctrl/Cmd+Shift+F — Search sessions ── + if (mod && e.shiftKey && e.key === 'F') { + e.preventDefault(); + setShowSearch((prev) => !prev); + return; + } + // ── Ctrl/Cmd+, — Open settings ── if (mod && e.key === ',') { e.preventDefault(); @@ -787,12 +796,23 @@ export default function App() { onAddProject={() => void api.addProject()} onOpenAppDataFolder={() => void api.openAppDataFolder()} onShowShortcuts={() => setShowShortcuts(true)} + onShowSearch={() => setShowSearch(true)} /> )} {showShortcuts && ( setShowShortcuts(false)} /> )} + + {showSearch && workspace && ( + setShowSearch(false)} + onSelectSession={(sessionId) => { + void api.selectSession(sessionId); + }} + /> + )} ); } diff --git a/src/renderer/components/CommandPalette.tsx b/src/renderer/components/CommandPalette.tsx index ec76f30..845cc07 100644 --- a/src/renderer/components/CommandPalette.tsx +++ b/src/renderer/components/CommandPalette.tsx @@ -50,6 +50,7 @@ export interface CommandPaletteProps { onAddProject: () => void; onOpenAppDataFolder: () => void; onShowShortcuts: () => void; + onShowSearch: () => void; } /** Score how well `query` matches `text` (and optional `keywords`). 0 = no match. */ @@ -90,6 +91,7 @@ export function CommandPalette({ onAddProject, onOpenAppDataFolder, onShowShortcuts, + onShowSearch, }: CommandPaletteProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); @@ -222,6 +224,16 @@ export function CommandPalette({ }); // ── General ── + cmds.push({ + id: 'search-sessions', + label: 'Search Sessions', + category: 'General', + keywords: 'find search messages content text', + shortcut: shortcutKeys('search-sessions'), + icon: , + action: onShowSearch, + }); + cmds.push({ id: 'settings', label: 'Open Settings', @@ -304,7 +316,7 @@ export function CommandPalette({ onSelectSession, onSelectProject, onNewSession, onCreateScratchpad, onOpenSettings, onOpenProjectSettings, onToggleTerminal, onSetTheme, onDuplicateSession, onPinSession, onArchiveSession, onAddProject, - onOpenAppDataFolder, onShowShortcuts, + onOpenAppDataFolder, onShowShortcuts, onShowSearch, ]); const filteredCommands = useMemo(() => { diff --git a/src/renderer/components/SessionSearchPanel.tsx b/src/renderer/components/SessionSearchPanel.tsx new file mode 100644 index 0000000..ff077e1 --- /dev/null +++ b/src/renderer/components/SessionSearchPanel.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Search, MessageSquare, ArrowRight } from 'lucide-react'; + +import type { WorkspaceState } from '@shared/domain/workspace'; +import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; +import { isScratchpadProject } from '@shared/domain/project'; + +export interface SessionSearchPanelProps { + workspace: WorkspaceState; + onClose: () => void; + onSelectSession: (sessionId: string) => void; +} + +interface SearchHit { + session: SessionRecord; + projectName: string; + message: ChatMessageRecord; + /** The matching substring context around the first token hit. */ + snippet: string; + /** Character offset of the match within the snippet for highlighting. */ + matchStart: number; + matchLength: number; +} + +function extractSnippet(content: string, query: string): { snippet: string; matchStart: number; matchLength: number } | undefined { + const lower = content.toLowerCase(); + const qLower = query.toLowerCase().trim(); + if (!qLower) return undefined; + + // Find first occurrence of query in content + const idx = lower.indexOf(qLower); + if (idx === -1) { + // Try individual tokens + const tokens = qLower.split(/\s+/).filter(Boolean); + for (const token of tokens) { + const tidx = lower.indexOf(token); + if (tidx !== -1) { + const start = Math.max(0, tidx - 40); + const end = Math.min(content.length, tidx + token.length + 80); + const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/\n/g, ' ') + (end < content.length ? '…' : ''); + const adjustedStart = (start > 0 ? 1 : 0) + (tidx - start); + return { snippet, matchStart: adjustedStart, matchLength: token.length }; + } + } + return undefined; + } + + const start = Math.max(0, idx - 40); + const end = Math.min(content.length, idx + qLower.length + 80); + const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/\n/g, ' ') + (end < content.length ? '…' : ''); + const adjustedStart = (start > 0 ? 1 : 0) + (idx - start); + return { snippet, matchStart: adjustedStart, matchLength: qLower.length }; +} + +export function SessionSearchPanel({ workspace, onClose, onSelectSession }: SessionSearchPanelProps) { + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + // Escape to close in capture phase + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopImmediatePropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleEscape, true); + return () => document.removeEventListener('keydown', handleEscape, true); + }, [onClose]); + + // Build project name lookup + const projectNames = useMemo(() => { + const map = new Map(); + for (const p of workspace.projects) { + map.set(p.id, isScratchpadProject(p) ? 'Scratchpad' : p.name); + } + return map; + }, [workspace.projects]); + + // Search across all sessions and messages + const hits = useMemo(() => { + const q = query.trim(); + if (!q) return []; + + const results: SearchHit[] = []; + const activeSessions = workspace.sessions.filter((s) => !s.isArchived); + + for (const session of activeSessions) { + for (const message of session.messages) { + if (!message.content) continue; + const extracted = extractSnippet(message.content, q); + if (extracted) { + results.push({ + session, + projectName: projectNames.get(session.projectId) ?? 'Unknown', + message, + ...extracted, + }); + } + } + } + + // Limit results for performance and sort by session recency + return results.slice(0, 50); + }, [query, workspace.sessions, projectNames]); + + useEffect(() => { setSelectedIndex(0); }, [query]); + + const handleSelect = useCallback((hit: SearchHit) => { + onClose(); + onSelectSession(hit.session.id); + // After navigation, scroll to the matching message + requestAnimationFrame(() => { + setTimeout(() => { + const el = document.querySelector(`[data-message-id="${CSS.escape(hit.message.id)}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'); + setTimeout(() => el.classList.remove('ring-1', 'ring-[var(--color-accent)]/40', 'rounded-lg'), 2000); + } + }, 100); + }); + }, [onClose, onSelectSession]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (hits.length > 0) setSelectedIndex((i) => Math.min(i + 1, hits.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const hit = hits[selectedIndex]; + if (hit) handleSelect(hit); + } + }, + [hits, selectedIndex, handleSelect], + ); + + useEffect(() => { + const item = listRef.current?.querySelector(`[data-search-index="${selectedIndex}"]`); + item?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + return ( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Search input */} +
+ + setQuery(e.target.value)} + aria-label="Search session content" + autoComplete="off" + spellCheck={false} + /> + {query && ( + + {hits.length} result{hits.length !== 1 ? 's' : ''} + + )} +
+ + {/* Results */} +
+ {query && hits.length === 0 ? ( +
+ No matches found +
+ ) : !query ? ( +
+ Type to search across all session messages +
+ ) : ( + hits.map((hit, index) => { + const isSelected = index === selectedIndex; + return ( + + ); + }) + )} +
+ + {/* Footer hints */} +
+ + ↑↓ + navigate + + + + jump to message + + + esc + close + +
+
+
+ ); +} diff --git a/src/renderer/lib/keyboardShortcuts.ts b/src/renderer/lib/keyboardShortcuts.ts index 4f65924..c26e1ec 100644 --- a/src/renderer/lib/keyboardShortcuts.ts +++ b/src/renderer/lib/keyboardShortcuts.ts @@ -17,6 +17,7 @@ export interface ShortcutDefinition { export const shortcuts: ShortcutDefinition[] = [ // ── Navigation ── { id: 'command-palette', label: 'Command palette', keys: `${MOD}+K`, category: 'Navigation' }, + { id: 'search-sessions', label: 'Search sessions', keys: `${MOD}+Shift+F`, category: 'Navigation' }, { id: 'settings', label: 'Open settings', keys: `${MOD}+,`, category: 'Navigation' }, { id: 'toggle-terminal', label: 'Toggle terminal', keys: 'Ctrl+`', category: 'Navigation' }, { id: 'shortcut-help', label: 'Keyboard shortcuts', keys: `${MOD}+/`, category: 'Navigation' },