diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 610ace62b7e8ba3b0aca046588e864d31b510440..3ce862b9a5f902ac9f5802c17ca98c1c18561d2c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import ChatInterface from "./components/ChatInterface"; import ConversationDrawer from "./components/ConversationDrawer"; +import CommandPalette from "./components/CommandPalette"; import { Conversation, ConversationListUpdate } from "./types"; import { api } from "./services/api"; @@ -62,6 +63,8 @@ function App() { const [currentConversationId, setCurrentConversationId] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerCollapsed, setDrawerCollapsed] = useState(false); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [diffViewerTrigger, setDiffViewerTrigger] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const initialSlugResolved = useRef(false); @@ -102,6 +105,18 @@ function App() { loadConversations(); }, []); + // Global keyboard shortcut for command palette (Cmd+K / Ctrl+K) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setCommandPaletteOpen((prev) => !prev); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + // Handle conversation list updates from the message stream const handleConversationListUpdate = useCallback((update: ConversationListUpdate) => { if (update.type === "update" && update.conversation) { @@ -292,9 +307,30 @@ function App() { mostRecentCwd={mostRecentCwd} isDrawerCollapsed={drawerCollapsed} onToggleDrawerCollapse={toggleDrawerCollapsed} + openDiffViewerTrigger={diffViewerTrigger} /> + {/* Command Palette */} + setCommandPaletteOpen(false)} + conversations={conversations} + onNewConversation={() => { + startNewConversation(); + setCommandPaletteOpen(false); + }} + onSelectConversation={(id) => { + selectConversation(id); + setCommandPaletteOpen(false); + }} + onOpenDiffViewer={() => { + setDiffViewerTrigger((prev) => prev + 1); + setCommandPaletteOpen(false); + }} + hasCwd={!!(currentConversation?.cwd || mostRecentCwd)} + /> + {/* Backdrop for mobile drawer */} {drawerOpen && (
setDrawerOpen(false)} /> diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 5a2dba3d6f2ea502f3fbc8eba49c6972c39f1c28..02b696e6569c393f3311d5a76110b1ceacb093b4 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -363,6 +363,7 @@ interface ChatInterfaceProps { mostRecentCwd?: string | null; isDrawerCollapsed?: boolean; onToggleDrawerCollapse?: () => void; + openDiffViewerTrigger?: number; // increment to trigger opening diff viewer } function ChatInterface({ @@ -376,6 +377,7 @@ function ChatInterface({ mostRecentCwd, isDrawerCollapsed, onToggleDrawerCollapse, + openDiffViewerTrigger, }: ChatInterfaceProps) { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); @@ -761,6 +763,13 @@ function ChatInterface({ }; }, [isDisconnected, conversationId]); + // Handle external trigger to open diff viewer + useEffect(() => { + if (openDiffViewerTrigger && openDiffViewerTrigger > 0) { + setShowDiffViewer(true); + } + }, [openDiffViewerTrigger]); + const handleCancel = async () => { if (!conversationId || cancelling) return; diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7eae87d318605411d8ffd35edd36ce096b89c9fa --- /dev/null +++ b/ui/src/components/CommandPalette.tsx @@ -0,0 +1,303 @@ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Conversation } from "../types"; + +interface CommandItem { + id: string; + type: "action" | "conversation"; + title: string; + subtitle?: string; + icon?: React.ReactNode; + action: () => void; + keywords?: string[]; // Additional keywords for search +} + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + conversations: Conversation[]; + onNewConversation: () => void; + onSelectConversation: (id: string) => void; + onOpenDiffViewer: () => void; + hasCwd: boolean; +} + +// Simple fuzzy match - returns score (higher is better), -1 if no match +function fuzzyMatch(query: string, text: string): number { + const lowerQuery = query.toLowerCase(); + const lowerText = text.toLowerCase(); + + // Exact match gets highest score + if (lowerText === lowerQuery) return 1000; + + // Starts with gets high score + if (lowerText.startsWith(lowerQuery)) return 500 + (lowerQuery.length / lowerText.length) * 100; + + // Contains gets medium score + if (lowerText.includes(lowerQuery)) return 100 + (lowerQuery.length / lowerText.length) * 50; + + // Fuzzy match - all query chars must appear in order + let queryIdx = 0; + let score = 0; + let consecutiveBonus = 0; + + for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) { + if (lowerText[i] === lowerQuery[queryIdx]) { + score += 1 + consecutiveBonus; + consecutiveBonus += 0.5; + queryIdx++; + } else { + consecutiveBonus = 0; + } + } + + // All query chars must be found + if (queryIdx !== lowerQuery.length) return -1; + + return score; +} + +function CommandPalette({ + isOpen, + onClose, + conversations, + onNewConversation, + onSelectConversation, + onOpenDiffViewer, + hasCwd, +}: CommandPaletteProps) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Build list of command items + const allItems: CommandItem[] = useMemo(() => { + const items: CommandItem[] = []; + + // Actions + items.push({ + id: "new-conversation", + type: "action", + title: "New Conversation", + subtitle: "Start a new conversation", + icon: ( + + + + ), + action: () => { + onNewConversation(); + onClose(); + }, + keywords: ["new", "create", "start", "conversation", "chat"], + }); + + if (hasCwd) { + items.push({ + id: "open-diffs", + type: "action", + title: "View Diffs", + subtitle: "Open the git diff viewer", + icon: ( + + + + ), + action: () => { + onOpenDiffViewer(); + onClose(); + }, + keywords: ["diff", "git", "changes", "view", "compare"], + }); + } + + // Add conversations + conversations.forEach((conv) => { + items.push({ + id: `conv-${conv.conversation_id}`, + type: "conversation", + title: conv.slug || conv.conversation_id, + subtitle: conv.cwd || undefined, + icon: ( + + + + ), + action: () => { + onSelectConversation(conv.conversation_id); + onClose(); + }, + keywords: [conv.slug || "", conv.cwd || ""].filter(Boolean), + }); + }); + + return items; + }, [conversations, onNewConversation, onSelectConversation, onOpenDiffViewer, onClose, hasCwd]); + + // Filter and sort items based on query + const filteredItems = useMemo(() => { + if (!query.trim()) { + // No query - show actions first, then recent conversations + return allItems; + } + + // Score and filter items + const scored = allItems + .map((item) => { + let maxScore = fuzzyMatch(query, item.title); + + // Check subtitle + if (item.subtitle) { + const subtitleScore = fuzzyMatch(query, item.subtitle); + if (subtitleScore > maxScore) maxScore = subtitleScore * 0.8; // Slightly lower weight + } + + // Check keywords + if (item.keywords) { + for (const keyword of item.keywords) { + const keywordScore = fuzzyMatch(query, keyword); + if (keywordScore > maxScore) maxScore = keywordScore * 0.7; + } + } + + return { item, score: maxScore }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score); + + return scored.map(({ item }) => item); + }, [allItems, query]); + + // Reset selection when query changes + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + // Focus input when opened + useEffect(() => { + if (isOpen) { + setQuery(""); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (!listRef.current) return; + const selectedElement = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); + selectedElement?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (filteredItems[selectedIndex]) { + filteredItems[selectedIndex].action(); + } + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+ + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ esc +
+
+ +
+ {filteredItems.length === 0 ? ( +
No results found
+ ) : ( + filteredItems.map((item, index) => ( +
item.action()} + onMouseEnter={() => setSelectedIndex(index)} + > +
{item.icon}
+
+
{item.title}
+ {item.subtitle && ( +
{item.subtitle}
+ )} +
+ {item.type === "action" &&
Action
} +
+ )) + )} +
+ +
+ + + to navigate + + + to select + + + esc to close + +
+
+
+ ); +} + +export default CommandPalette; diff --git a/ui/src/styles.css b/ui/src/styles.css index 6b8de47efd2cd6ede454cdc9404e6c3928b362e1..232df119334eaf271cff1aaaadf01fb1775b6745 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3405,3 +3405,171 @@ svg { .text-link:hover { text-decoration-color: var(--blue-text); } + +/* Command Palette */ +.command-palette-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.command-palette { + background: var(--bg-base); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + max-width: 32rem; + max-height: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.command-palette-input-wrapper { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + gap: 0.75rem; +} + +.command-palette-search-icon { + color: var(--text-secondary); + flex-shrink: 0; +} + +.command-palette-input { + flex: 1; + border: none; + background: transparent; + font-size: 1rem; + color: var(--text-primary); + outline: none; +} + +.command-palette-input::placeholder { + color: var(--text-secondary); +} + +.command-palette-shortcut { + flex-shrink: 0; +} + +.command-palette-shortcut kbd, +.command-palette-footer kbd { + display: inline-block; + padding: 0.125rem 0.375rem; + font-size: 0.75rem; + font-family: inherit; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + color: var(--text-secondary); +} + +.command-palette-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.command-palette-empty { + padding: 2rem 1rem; + text-align: center; + color: var(--text-secondary); +} + +.command-palette-item { + display: flex; + align-items: center; + padding: 0.625rem 0.75rem; + border-radius: 0.5rem; + cursor: pointer; + gap: 0.75rem; +} + +.command-palette-item:hover, +.command-palette-item.selected { + background: var(--bg-secondary); +} + +.command-palette-item-icon { + flex-shrink: 0; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; +} + +.command-palette-item.selected .command-palette-item-icon { + color: var(--primary); +} + +.command-palette-item-content { + flex: 1; + min-width: 0; +} + +.command-palette-item-title { + font-size: 0.875rem; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-palette-item-subtitle { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.125rem; +} + +.command-palette-item-badge { + flex-shrink: 0; + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.125rem 0.375rem; + background: var(--bg-tertiary); + border-radius: 0.25rem; + color: var(--text-secondary); +} + +.command-palette-footer { + display: flex; + gap: 1rem; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border-color); + font-size: 0.75rem; + color: var(--text-secondary); +} + +.command-palette-footer span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +@media (max-width: 640px) { + .command-palette-overlay { + padding: 1rem; + padding-top: 5vh; + } + + .command-palette { + max-height: 80vh; + } + + .command-palette-footer { + display: none; + } +}