feat(21-06): add conversation search input and Cmd+K shortcut
- Add Search/X icons and Input to ChatConversationList with 300ms debounce - Listen for nexus:focus-chat-search event to focus search input - Add onSearch handler to useKeyboardShortcuts (fires before input guard) - Wire Layout Cmd+K to open chat panel and dispatch focus event
This commit is contained in:
parent
6367789992
commit
0406362e04
3 changed files with 58 additions and 5 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Search, X } from "lucide-react";
|
||||||
import { useChatConversations } from "../hooks/useChatConversations";
|
import { useChatConversations } from "../hooks/useChatConversations";
|
||||||
import { useChatPanel } from "../context/ChatPanelContext";
|
import { useChatPanel } from "../context/ChatPanelContext";
|
||||||
import { ChatConversationItem } from "./ChatConversationItem";
|
import { ChatConversationItem } from "./ChatConversationItem";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -22,8 +23,25 @@ interface ChatConversationListProps {
|
||||||
|
|
||||||
export function ChatConversationList({ companyId }: ChatConversationListProps) {
|
export function ChatConversationList({ companyId }: ChatConversationListProps) {
|
||||||
const { activeConversationId, setActiveConversationId } = useChatPanel();
|
const { activeConversationId, setActiveConversationId } = useChatPanel();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(searchTerm), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
// Listen for focus-chat-search custom event (dispatched by Cmd+K in Layout)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => searchInputRef.current?.focus();
|
||||||
|
window.addEventListener("nexus:focus-chat-search", handler);
|
||||||
|
return () => window.removeEventListener("nexus:focus-chat-search", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation } =
|
const { data, isLoading, hasNextPage, fetchNextPage, createMutation, updateMutation, deleteMutation } =
|
||||||
useChatConversations(companyId);
|
useChatConversations(companyId, { search: debouncedSearch || undefined });
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -112,6 +130,29 @@ export function ChatConversationList({ companyId }: ChatConversationListProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="px-2 pb-1 pt-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
className="h-7 pl-7 pr-7 text-xs"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-1 space-y-0.5">
|
<div className="p-1 space-y-0.5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialog();
|
||||||
const { togglePanelVisible, setPanelVisible } = usePanel();
|
const { togglePanelVisible, setPanelVisible } = usePanel();
|
||||||
const { chatOpen, toggleChat } = useChatPanel();
|
const { chatOpen, setChatOpen, toggleChat } = useChatPanel();
|
||||||
const {
|
const {
|
||||||
companies,
|
companies,
|
||||||
loading: companiesLoading,
|
loading: companiesLoading,
|
||||||
|
|
@ -160,6 +160,10 @@ export function Layout() {
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
|
onSearch: () => {
|
||||||
|
if (!chatOpen) setChatOpen(true);
|
||||||
|
requestAnimationFrame(() => window.dispatchEvent(new Event("nexus:focus-chat-search")));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,19 @@ interface ShortcutHandlers {
|
||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
|
onSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSearch }: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Cmd+K / Ctrl+K → Search (global, works even from inputs)
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||||
|
|
@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSearch]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue