feat(command-palette): add settings, navigation, message search, and ⌘K hints

This commit is contained in:
simosmik
2026-04-30 07:16:58 +00:00
parent 50b35ea9f5
commit 66dd81976f
4 changed files with 219 additions and 10 deletions

View File

@@ -288,6 +288,15 @@ export default function ProviderSelectionEmptyState({
}
</p>
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<span>Press</span>
<kbd className="inline-flex items-center gap-0.5 rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px]">
{typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"}
<span>K</span>
</kbd>
<span>to search sessions, files, and commits</span>
</p>
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner

View File

@@ -1,6 +1,21 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, GitCommit, MessageSquare, MessageSquarePlus, Settings, SunMoon } from 'lucide-react';
import {
Bell,
Bot,
FileText,
GitBranch,
GitCommit,
Info,
KeyRound,
ListChecks,
MessageSquare,
MessageSquarePlus,
Palette,
Plug,
Settings,
SunMoon,
} from 'lucide-react';
import {
Command,
@@ -19,13 +34,14 @@ import type { AppTab, Project } from '../../types/app';
import { useSessionsSource } from './sources/useSessionsSource';
import { useFilesSource } from './sources/useFilesSource';
import { useCommitsSource } from './sources/useCommitsSource';
import { useSessionMessageSearch } from './sources/useSessionMessageSearch';
type Mode = 'mixed' | 'actions' | 'files' | 'commits';
type CommandPaletteProps = {
selectedProject: Project | null;
onStartNewChat: (project: Project) => 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<string, Row>();
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({
<span className="text-xs text-muted-foreground">Select a project first</span>
)}
</CommandItem>
<CommandItem value="open settings" onSelect={() => run(onOpenSettings)}>
<CommandItem value="open settings" onSelect={() => run(() => onOpenSettings())}>
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Open settings</span>
</CommandItem>
@@ -133,16 +192,50 @@ export default function CommandPalette({
</CommandGroup>
)}
{showSessions && sessions.length > 0 && (
{showActions && (
<CommandGroup heading="Navigate">
{NAV_TABS.map((tab) => (
<CommandItem
key={tab.id as string}
value={`navigate ${tab.label} ${tab.keywords}`}
onSelect={() => run(() => onShowTab?.(tab.id))}
>
<span className="flex-1">{tab.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{showActions && (
<CommandGroup heading="Settings">
{SETTINGS_TABS.map(({ id, label, keywords, icon: Icon }) => (
<CommandItem
key={id}
value={`settings ${label} ${keywords}`}
onSelect={() => run(() => onOpenSettings(id))}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Settings: {label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{showSessions && sessionRows.length > 0 && (
<CommandGroup heading="Sessions">
{sessions.map((s) => (
{sessionRows.map((s) => (
<CommandItem
key={s.id}
value={`session ${s.label} ${s.id}`}
value={`session ${s.label} ${s.id} ${s.snippet ?? ''}`}
onSelect={() => run(() => navigate(`/session/${s.id}`))}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 truncate">{s.label}</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{s.label}</span>
{s.snippet && (
<span className="truncate text-xs text-muted-foreground">{s.snippet}</span>
)}
</div>
{s.provider && (
<span className="text-xs text-muted-foreground">{s.provider}</span>
)}

View File

@@ -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<SessionMessageMatch[]>([]);
const seqRef = useRef(0);
const esRef = useRef<EventSource | null>(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 };
}

View File

@@ -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 ? (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
@@ -158,6 +158,15 @@ export default function SidebarHeader({
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<kbd
aria-hidden
title="Open command palette"
className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground md:inline-flex"
>
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'}
<span>K</span>
</kbd>
)}
</div>
</div>