From 66dd81976f7639a8c1513ce95832009ab4fb2990 Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 30 Apr 2026 07:16:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(command-palette):=20add=20settings,=20navi?= =?UTF-8?q?gation,=20message=20search,=20and=20=E2=8C=98K=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProviderSelectionEmptyState.tsx | 9 ++ .../command-palette/CommandPalette.tsx | 109 ++++++++++++++++-- .../sources/useSessionMessageSearch.ts | 98 ++++++++++++++++ .../view/subcomponents/SidebarHeader.tsx | 13 ++- 4 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 src/components/command-palette/sources/useSessionMessageSearch.ts diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 5a5feeb7..62a578e8 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -288,6 +288,15 @@ export default function ProviderSelectionEmptyState({ }

+

+ Press + + {typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"} + K + + to search sessions, files, and commits +

+ {provider && tasksEnabled && isTaskMasterInstalled && (
void; - onOpenSettings: () => void; + onOpenSettings: (tab?: string) => void; onShowTab?: (tab: AppTab) => void; }; @@ -36,6 +52,25 @@ function parseMode(input: string): { mode: Mode; query: string } { return { mode: 'mixed', query: input }; } +const SETTINGS_TABS: Array<{ id: string; label: string; keywords: string; icon: React.ComponentType<{ className?: string }> }> = [ + { id: 'agents', label: 'Agents', keywords: 'agents subagents claude code', icon: Bot }, + { id: 'appearance', label: 'Appearance', keywords: 'appearance theme dark light language', icon: Palette }, + { id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch }, + { id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound }, + { id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks }, + { id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell }, + { id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug }, + { id: 'about', label: 'About', keywords: 'about version info', icon: Info }, +]; + +const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [ + { id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' }, + { id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' }, + { id: 'shell', label: 'Go to Shell', keywords: 'shell terminal console' }, + { id: 'git', label: 'Go to Git', keywords: 'git diff branches' }, + { id: 'tasks', label: 'Go to Tasks', keywords: 'tasks taskmaster' }, +]; + export default function CommandPalette({ selectedProject, onStartNewChat, @@ -62,10 +97,11 @@ export default function CommandPalette({ if (!open) setSearch(''); }, [open]); - const { mode } = parseMode(search); + const { mode, query } = parseMode(search); const projectId = selectedProject?.projectId; const { items: sessions } = useSessionsSource(projectId, open && (mode === 'mixed')); + const { items: messageMatches } = useSessionMessageSearch(projectId, query, open && mode === 'mixed'); const { items: files } = useFilesSource(projectId, open && (mode === 'mixed' || mode === 'files')); const { items: commits } = useCommitsSource(projectId, open && (mode === 'mixed' || mode === 'commits')); @@ -74,6 +110,29 @@ export default function CommandPalette({ const showFiles = mode === 'mixed' || mode === 'files'; const showCommits = mode === 'mixed' || mode === 'commits'; + const sessionRows = React.useMemo(() => { + if (!showSessions) return []; + type Row = { id: string; label: string; provider?: string; snippet?: string }; + const byId = new Map(); + for (const s of sessions) { + byId.set(s.id, { id: s.id, label: s.label, provider: s.provider }); + } + for (const m of messageMatches) { + const existing = byId.get(m.sessionId); + if (existing) { + existing.snippet = m.snippet; + } else { + byId.set(m.sessionId, { + id: m.sessionId, + label: m.label, + provider: m.provider, + snippet: m.snippet, + }); + } + } + return Array.from(byId.values()); + }, [sessions, messageMatches, showSessions]); + const filter = React.useCallback( (value: string, rawSearch: string) => { const stripped = parseMode(rawSearch).query.trim().toLowerCase(); @@ -119,7 +178,7 @@ export default function CommandPalette({ Select a project first )} - run(onOpenSettings)}> + run(() => onOpenSettings())}> Open settings @@ -133,16 +192,50 @@ export default function CommandPalette({ )} - {showSessions && sessions.length > 0 && ( + {showActions && ( + + {NAV_TABS.map((tab) => ( + run(() => onShowTab?.(tab.id))} + > + {tab.label} + + ))} + + )} + + {showActions && ( + + {SETTINGS_TABS.map(({ id, label, keywords, icon: Icon }) => ( + run(() => onOpenSettings(id))} + > + + Settings: {label} + + ))} + + )} + + {showSessions && sessionRows.length > 0 && ( - {sessions.map((s) => ( + {sessionRows.map((s) => ( run(() => navigate(`/session/${s.id}`))} > - {s.label} +
+ {s.label} + {s.snippet && ( + {s.snippet} + )} +
{s.provider && ( {s.provider} )} diff --git a/src/components/command-palette/sources/useSessionMessageSearch.ts b/src/components/command-palette/sources/useSessionMessageSearch.ts new file mode 100644 index 00000000..87598583 --- /dev/null +++ b/src/components/command-palette/sources/useSessionMessageSearch.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState } from 'react'; + +import { api } from '../../../utils/api'; +import type { LLMProvider } from '../../../types/app'; + +export type SessionMessageMatch = { + sessionId: string; + label: string; + snippet: string; + provider: LLMProvider; +}; + +type ProjectResult = { + projectId: string | null; + projectName: string; + sessions: Array<{ + sessionId: string; + provider: LLMProvider; + sessionSummary: string; + matches: Array<{ snippet: string }>; + }>; +}; + +const MIN_QUERY = 2; +const DEBOUNCE_MS = 250; + +export function useSessionMessageSearch( + projectId: string | undefined, + query: string, + enabled: boolean, +) { + const [items, setItems] = useState([]); + const seqRef = useRef(0); + const esRef = useRef(null); + + useEffect(() => { + const trimmed = query.trim(); + if (!enabled || !projectId || trimmed.length < MIN_QUERY) { + setItems([]); + esRef.current?.close(); + esRef.current = null; + return; + } + + const handle = setTimeout(() => { + esRef.current?.close(); + const seq = ++seqRef.current; + const url = api.searchConversationsUrl(trimmed); + const es = new EventSource(url); + esRef.current = es; + const accumulated: SessionMessageMatch[] = []; + + es.addEventListener('result', (evt) => { + if (seq !== seqRef.current) { + es.close(); + return; + } + try { + const data = JSON.parse((evt as MessageEvent).data) as { projectResult: ProjectResult }; + const pr = data.projectResult; + if (pr.projectId !== projectId) return; + for (const s of pr.sessions) { + accumulated.push({ + sessionId: s.sessionId, + label: s.sessionSummary || s.sessionId, + snippet: s.matches[0]?.snippet ?? '', + provider: s.provider, + }); + } + setItems([...accumulated]); + } catch { + // ignore malformed + } + }); + + const finish = () => { + if (seq !== seqRef.current) return; + es.close(); + esRef.current = null; + }; + es.addEventListener('done', finish); + es.addEventListener('error', finish); + }, DEBOUNCE_MS); + + return () => { + clearTimeout(handle); + }; + }, [projectId, query, enabled]); + + useEffect(() => { + return () => { + esRef.current?.close(); + esRef.current = null; + }; + }, []); + + return { items }; +} diff --git a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx index 551c0095..833be2ff 100644 --- a/src/components/sidebar/view/subcomponents/SidebarHeader.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarHeader.tsx @@ -148,9 +148,9 @@ export default function SidebarHeader({ placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')} value={searchFilter} onChange={(event) => onSearchFilterChange(event.target.value)} - className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" + className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0" /> - {searchFilter && ( + {searchFilter ? ( + ) : ( + + {typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'} + K + )}