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' },