mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +00:00
feat(command-palette): add settings, navigation, message search, and ⌘K hints
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user