purcar-chat-api / src /components /QuickSwitcher.tsx
ihatebaselines's picture
Deploy Purcar gateway and local model engine
7e92e00 verified
Raw History Blame Contribute Delete
12 kB
"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import {
Search, MessageSquare, Folder, Brain, Database,
Sliders, Plus, Network, Volume2, VolumeX, ArrowRight,
CornerDownLeft, Zap, Pin, Users
} from "lucide-react";
import type { Conversation, Project } from "@/types";
import { playClickSound, toggleSoundMute, isSoundMuted } from "@/lib/soundEffects";
interface QuickSwitcherProps {
isOpen: boolean;
onClose: () => void;
conversations: Conversation[];
projects: Project[];
activeConvId: string;
onSelectConversation: (id: string) => void;
onSelectProject: (id: string | null) => void;
onNewSession: () => void;
onOpenKnowledge: () => void;
onOpenSettings: () => void;
onInsertCommand: (command: string) => void;
enableReasoning: boolean;
onToggleReasoning: () => void;
}
interface SearchItem {
id: string;
title: string;
subtitle?: string;
category: "session" | "project" | "command" | "action";
icon: React.ReactNode;
badge?: string;
action: () => void;
}
export const QuickSwitcher: React.FC<QuickSwitcherProps> = ({
isOpen,
onClose,
conversations,
projects,
activeConvId,
onSelectConversation,
onSelectProject,
onNewSession,
onOpenKnowledge,
onOpenSettings,
onInsertCommand,
enableReasoning,
onToggleReasoning,
}) => {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Focus input when opened
useEffect(() => {
if (isOpen) {
setQuery("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
const items = useMemo<SearchItem[]>(() => {
const list: SearchItem[] = [];
// Core actions
list.push({
id: "action_new_session",
title: "New Session",
subtitle: "Start a fresh chat with Purcar",
category: "action",
icon: <Plus size={14} className="text-[#a0e4a0]" />,
action: () => { onNewSession(); onClose(); },
});
list.push({
id: "action_toggle_reasoning",
title: enableReasoning ? "Disable Reasoning Mode" : "Enable Reasoning Mode",
subtitle: enableReasoning ? "Switch to direct high-speed responses" : "Enable <think> cognitive chain",
category: "action",
icon: <Brain size={14} className={enableReasoning ? "text-[#a0e4a0]" : "text-zinc-400"} />,
badge: enableReasoning ? "ON" : "OFF",
action: () => { onToggleReasoning(); onClose(); },
});
list.push({
id: "action_toggle_sound",
title: isSoundMuted() ? "Unmute Audio Haptics" : "Mute Audio Haptics",
subtitle: "Tactile Discord-inspired audio cues",
category: "action",
icon: isSoundMuted() ? <VolumeX size={14} className="text-zinc-400" /> : <Volume2 size={14} className="text-[#a0e4a0]" />,
action: () => { toggleSoundMute(); onClose(); },
});
list.push({
id: "action_knowledge",
title: "Knowledge Base (RAG)",
subtitle: "Manage private documents and embeddings",
category: "action",
icon: <Database size={14} className="text-cyan-400" />,
action: () => { onOpenKnowledge(); onClose(); },
});
list.push({
id: "action_settings",
title: "Engine Hyperparameters",
subtitle: "Temperature, Top-K, Repetition Penalty, Max Tokens",
category: "action",
icon: <Sliders size={14} className="text-amber-400" />,
action: () => { onOpenSettings(); onClose(); },
});
// Commands
[
{ name: "@web_search", desc: "Live web search & source citations", icon: <Network size={14} className="text-[#a0e4a0]" /> },
{ name: "@rag", desc: "Retrieve answers from knowledge base", icon: <Database size={14} className="text-cyan-400" /> },
{ name: "@reasoning", desc: "Force cognitive step-by-step thinking", icon: <Brain size={14} className="text-purple-400" /> },
{ name: "@raw", desc: "Bypass reasoning, RAG & web search", icon: <Zap size={14} className="text-amber-400" /> },
].forEach((cmd) => {
list.push({
id: `cmd_${cmd.name}`,
title: cmd.name,
subtitle: cmd.desc,
category: "command",
icon: cmd.icon,
badge: "command",
action: () => { onInsertCommand(`${cmd.name} `); onClose(); },
});
});
// Projects
projects.forEach((proj) => {
list.push({
id: `proj_${proj.id}`,
title: proj.name,
subtitle: "Filter workspace by this project",
category: "project",
icon: <Folder size={14} className="text-blue-400" />,
badge: "project",
action: () => { onSelectProject(proj.id); onClose(); },
});
});
// Conversations
conversations.forEach((conv) => {
list.push({
id: `conv_${conv.id}`,
title: conv.title || "Untitled Session",
subtitle: `${conv.messages?.length || 0} messages · ${new Date(conv.updatedAt || conv.createdAt).toLocaleDateString()}`,
category: "session",
icon: conv.isGroup ? <Users size={14} className="text-[#a0e4a0]" /> : <MessageSquare size={14} className="text-zinc-400" />,
badge: conv.id === activeConvId ? "active" : conv.isPinned ? "pinned" : undefined,
action: () => { onSelectConversation(conv.id); onClose(); },
});
});
return list;
}, [
conversations,
projects,
activeConvId,
enableReasoning,
onNewSession,
onToggleReasoning,
onOpenKnowledge,
onOpenSettings,
onInsertCommand,
onSelectProject,
onSelectConversation,
onClose,
]);
// Filter items by query
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.subtitle && item.subtitle.toLowerCase().includes(q)) ||
item.category.includes(q)
);
}, [items, query]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % (filtered.length || 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filtered.length) % (filtered.length || 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[selectedIndex]) {
playClickSound();
filtered[selectedIndex].action();
}
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
// Keep selected element visible
useEffect(() => {
if (listRef.current) {
const selectedEl = listRef.current.children[selectedIndex] as HTMLElement;
if (selectedEl) {
selectedEl.scrollIntoView({ block: "nearest" });
}
}
}, [selectedIndex]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4 bg-black/75 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-xl rounded-lg border border-white/15 bg-[#171414] shadow-2xl overflow-hidden font-mono text-xs text-[#f5f0eb]"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* Search Input Bar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.09] bg-[#1a1717]">
<Search size={15} className="text-zinc-400 shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
placeholder="Search sessions, commands (@), projects, or actions..."
className="flex-1 bg-transparent text-[#f5f0eb] placeholder-zinc-500 text-xs outline-none border-none"
/>
<kbd className="px-1.5 py-0.5 rounded bg-white/[0.08] text-[10px] text-zinc-400 border border-white/10">
ESC
</kbd>
</div>
{/* Results List */}
<div
ref={listRef}
className="max-h-80 overflow-y-auto p-1.5 space-y-0.5 divide-y divide-transparent"
>
{filtered.length === 0 ? (
<div className="p-8 text-center text-zinc-500 text-xs">
No matching sessions, commands, or actions found for "{query}"
</div>
) : (
filtered.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={item.id}
onClick={() => {
playClickSound();
item.action();
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors ${
isSelected
? "bg-white/[0.08] text-[#f5f0eb] border border-white/10"
: "text-zinc-300 hover:bg-white/[0.04] border border-transparent"
}`}
>
<div className="flex items-center gap-2.5 overflow-hidden">
<div className="shrink-0">{item.icon}</div>
<div className="overflow-hidden">
<div className="font-medium truncate flex items-center gap-2">
<span>{item.title}</span>
{item.badge && (
<span
className={`px-1.5 py-0.2 rounded text-[9px] uppercase tracking-wider ${
item.badge === "active"
? "bg-[#a0e4a0]/15 text-[#a0e4a0] border border-[#a0e4a0]/30"
: item.badge === "pinned"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
: "bg-white/[0.06] text-zinc-400 border border-white/10"
}`}
>
{item.badge}
</span>
)}
</div>
{item.subtitle && (
<div className="text-[10px] text-zinc-500 truncate">
{item.subtitle}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-zinc-500 shrink-0 text-[10px]">
{isSelected && (
<span className="flex items-center gap-1 text-[#a0e4a0]">
<span>Select</span>
<CornerDownLeft size={10} />
</span>
)}
</div>
</div>
);
})
)}
</div>
{/* Footer info */}
<div className="flex items-center justify-between px-3 py-2 bg-[#141212] border-t border-white/[0.07] text-[10px] text-zinc-500">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↑</kbd>
<kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↓</kbd>
<span>navigate</span>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↵</kbd>
<span>select</span>
</span>
</div>
<div>Discord Quick Switcher · <span className="text-[#a0e4a0]">Ctrl+K</span></div>
</div>
</div>
</div>
);
};